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