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.
158 lines
5.1 KiB
TypeScript
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, " ");
|
|
}
|