* Pick the voice from a carousel in the app language, local voice only Voice settings now list only the voices that speak the app language (German shows German voices, English shows English ones) as a horizontal carousel: one card per voice, a play button next to the name, tap to make it the active voice. Changing the app language moves the saved voice to that language. The hosted voice providers (ElevenLabs, OpenAI, Cartesia, Fish Audio) are removed; Kokoro on the server is the only voice. E2E runs against a stub Kokoro server so the carousel is covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
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<string, string> = {
|
|
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);
|
|
});
|
|
}
|