import type { VoiceInfo } from "@rakazo/contracts"; /** Friendly German presets when Kokoro exposes df_/dm_ voices. */ export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [ { id: "df_eva", label: "Eva", description: "warm" }, { id: "dm_bernd", label: "Bernd", description: "klar" }, { id: "df_victoria", label: "Victoria", description: "freundlich" }, { id: "dm_martin", label: "Martin", description: "ruhig" }, ]; /** * Kokoro encodes a voice's language in the first letter of its id (`df_eva` is German, * `af_heart` American English, `bf_emma` British English). */ const KOKORO_LANGUAGE_PREFIXES: Record = { de: "d", en: "ab", es: "e", fr: "f", hi: "h", it: "i", ja: "j", pt: "p", zh: "z", }; function voiceLanguagePrefixes(locale: string): string | undefined { const language = locale.toLowerCase().split("-")[0] ?? ""; return KOKORO_LANGUAGE_PREFIXES[language]; } const PRESET_BY_ID = new Map(GERMAN_VOICE_PRESETS.map((voice) => [voice.id, voice])); /** Strip engine prefix (df_eva → Eva) for display when no preset exists. */ export function formatVoiceLabel(id: string): string { const preset = PRESET_BY_ID.get(id); if (preset) return preset.label; const suffix = id.replace(/^[a-z]{2}_/, ""); if (!suffix) return id; return suffix.charAt(0).toUpperCase() + suffix.slice(1).replace(/_/g, " "); } export function voiceDescription(id: string, fallback?: string): string | undefined { return PRESET_BY_ID.get(id)?.description ?? fallback; } /** * Only the voices that speak the UI language. Ids without a Kokoro language prefix are kept, * and every voice is kept when none speaks the language, so the picker never ends up empty. */ export function voicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] { const prefixes = voiceLanguagePrefixes(locale); if (!prefixes) return voices; const matching = voices.filter((voice) => { const prefix = /^([a-z])[fm]_/.exec(voice.id)?.[1]; return !prefix || prefixes.includes(prefix); }); return matching.length > 0 ? matching : voices; } export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined { const listed = voicesForLocale(voices, locale); return listed.find((voice) => voice.id === "df_eva") ?? listed[0]; } /** The voice to keep using for this UI language: the current one when it fits, else the default. */ export function voiceIdForLocale( voices: VoiceInfo[], locale: string, currentId: string, ): string | undefined { const listed = voicesForLocale(voices, locale); if (listed.some((voice) => voice.id === currentId)) return currentId; return defaultVoiceForLocale(voices, locale)?.id; } /** Match a user-provided name (Eva, bernd, df_eva) against available voices. */ export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo | undefined { const needle = query.trim().toLowerCase(); if (!needle) return undefined; const byId = voices.find((voice) => voice.id.toLowerCase() === needle); if (byId) return byId; const byLabel = voices.find((voice) => voice.label.toLowerCase() === needle); if (byLabel) return byLabel; const suffix = needle.replace(/^[a-z]{2}_/, ""); return voices.find((voice) => { const label = voice.label.toLowerCase(); const idSuffix = voice.id.replace(/^[a-z]{2}_/, "").toLowerCase(); return label.includes(suffix) || idSuffix.includes(suffix) || suffix.includes(idSuffix); }); }