73 lines
2.7 KiB
TypeScript
73 lines
2.7 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 on 127.0.0.1:5150 by default outside unit tests, or at PIPER_BASE_URL. */
|
|
export function piperBaseUrl(): string | undefined {
|
|
const raw = process.env.PIPER_BASE_URL;
|
|
if (raw !== undefined) {
|
|
const trimmed = raw.trim();
|
|
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
|
}
|
|
if (process.env.VITEST || process.env.NODE_ENV === "test") return undefined;
|
|
return "http://127.0.0.1:5150";
|
|
}
|
|
|
|
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");
|
|
const contentType = res.headers.get("content-type");
|
|
const mimeType = contentType?.startsWith("audio/mpeg") ? "audio/mpeg" : "audio/wav";
|
|
return { bytes: await readVoiceAudio(res, signal), mimeType };
|
|
}
|