evOo-local/packages/adapters/src/kokoro-voice.ts
cynarAI 8ae3764b05
Speak German through a local Piper server, since Kokoro has no German voices (#182)
Kokoro-FastAPI ships English, Spanish, French, Hindi, Italian, Japanese,
Portuguese and Chinese voices only. The German presets pointed at voice
ids no Kokoro server has, so German users either got English voices or
picked a voice that failed to speak.

The local voice now also lists the German voices whose Piper model is
installed (PIPER_BASE_URL) and speaks them through Piper's /synthesize
as WAV. Thorsten, the clearest model, is the German default. Mobile
plays WAV as well as MP3, and the E2E voice stub stands in for Piper.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 11:55:51 +02:00

165 lines
5.3 KiB
TypeScript

import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import { formatVoiceLabel } from "@rakazo/core";
import { listPiperVoices, synthesizePiper } from "./piper-voice.js";
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" },
{ id: "af_bella", label: "Bella", description: "expressive" },
{ id: "af_sky", label: "Sky", description: "bright" },
{ id: "af_nova", label: "Nova", description: "energetic" },
{ id: "am_adam", label: "Adam", description: "clear" },
{ id: "am_michael", label: "Michael", description: "steady" },
{ id: "bf_emma", label: "Emma", description: "British" },
{ id: "bf_isabella", label: "Isabella", description: "British" },
{ id: "bm_george", label: "George", description: "British" },
];
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<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,
"Local voice",
"checking the local server",
await readVoiceJson(res),
),
};
} catch {
return { ok: false, message: kokoroUnreachable() };
}
}
async listVoices(_apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
const [kokoro, piper] = await Promise.all([
this.listKokoroVoices(context).catch((error: unknown) => error as Error),
listPiperVoices(context.signal),
]);
if (kokoro instanceof Error) {
if (piper.length > 0) return piper;
throw kokoro;
}
return [...kokoro, ...piper];
}
private async listKokoroVoices(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, "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<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 120_000);
const piper = await synthesizePiper(request.voiceId, request.text, signal);
if (piper) return piper;
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) };
})
.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);
}