evOo-local/packages/adapters/src/kokoro-voice.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

158 lines
5.1 KiB
TypeScript

import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
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[] = [
{ 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 (
"Kokoro lokal nicht erreichbar — starte Kokoro-FastAPI auf Port 8880, z. B. " +
"docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest"
);
}
export class KokoroVoiceProvider implements VoiceProvider {
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "kokoro",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: false },
};
}
async verify(_apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
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,
"Kokoro",
"checking the local server",
await readVoiceJson(res),
),
};
} catch {
return { ok: false, message: kokoroUnreachable() };
}
}
async listVoices(_apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
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, "Kokoro", "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<SpeechClip> {
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, "Kokoro", "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<string, unknown>;
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<string, unknown>;
return String(voice.id ?? voice.name ?? "").trim();
}
return "";
})
.filter(Boolean);
}
function formatVoiceLabel(id: string): string {
const suffix = id.replace(/^[a-z]{2}_/, "");
if (!suffix) return id;
return suffix.charAt(0).toUpperCase() + suffix.slice(1).replace(/_/g, " ");
}