evOo-local/packages/adapters/src/piper-voice.ts

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 };
}