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>
66 lines
2.4 KiB
TypeScript
66 lines
2.4 KiB
TypeScript
import type { SpeechClip, VoiceInfo } from "@rakazo/adapter-kit";
|
|
import { GERMAN_VOICE_PRESETS } from "@rakazo/core";
|
|
import { readVoiceAudio, readVoiceJson, requireOk, voiceDeadline } from "./voice-http.js";
|
|
|
|
/** The Piper model behind each German voice; Kokoro has no German voices of its own. */
|
|
export const PIPER_MODELS: Record<string, string> = {
|
|
dm_thorsten: "de_DE-thorsten-high",
|
|
df_kerstin: "de_DE-kerstin-low",
|
|
df_eva: "de_DE-eva_k-x_low",
|
|
df_ramona: "de_DE-ramona-low",
|
|
dm_karlsson: "de_DE-karlsson-low",
|
|
};
|
|
|
|
/** Piper runs only where configured, so a server without it skips the request entirely. */
|
|
export function piperBaseUrl(): string | undefined {
|
|
const raw = process.env.PIPER_BASE_URL?.trim();
|
|
return raw ? raw.replace(/\/+$/, "") : undefined;
|
|
}
|
|
|
|
export function piperUnreachable(): string {
|
|
return "Deutsche Stimme nicht erreichbar. Prüfe, ob der Piper-Server läuft.";
|
|
}
|
|
|
|
/** The German voices whose Piper model the server has downloaded. */
|
|
export async function listPiperVoices(signal: AbortSignal): Promise<VoiceInfo[]> {
|
|
const baseUrl = piperBaseUrl();
|
|
if (!baseUrl) return [];
|
|
try {
|
|
const res = await fetch(`${baseUrl}/voices`, { signal: voiceDeadline(signal, 20_000) });
|
|
if (!res.ok) return [];
|
|
const body = await readVoiceJson(res, { requireValid: true });
|
|
const models = new Set(body && typeof body === "object" ? Object.keys(body) : []);
|
|
return GERMAN_VOICE_PRESETS.filter((voice) => {
|
|
const model = PIPER_MODELS[voice.id];
|
|
return model !== undefined && models.has(model);
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Speaks through Piper when the voice is one of its models, otherwise returns undefined. */
|
|
export async function synthesizePiper(
|
|
voiceId: string | undefined,
|
|
text: string,
|
|
signal: AbortSignal,
|
|
): Promise<SpeechClip | undefined> {
|
|
const model = voiceId ? PIPER_MODELS[voiceId] : undefined;
|
|
if (!model) return undefined;
|
|
const baseUrl = piperBaseUrl();
|
|
if (!baseUrl) throw new Error(piperUnreachable());
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${baseUrl}/synthesize`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ text, voice: model }),
|
|
signal,
|
|
});
|
|
} catch {
|
|
throw new Error(piperUnreachable());
|
|
}
|
|
await requireOk(res, "Local voice", "speaking");
|
|
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/wav" };
|
|
}
|