* 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>
67 lines
2.0 KiB
TypeScript
67 lines
2.0 KiB
TypeScript
import type { VoiceProvider } from "@rakazo/adapter-kit";
|
|
import { KokoroVoiceProvider } from "./kokoro-voice.js";
|
|
import { SCRIPTED_VOICE_CATALOG_ENTRY, ScriptedVoiceProvider } from "./scripted-voice.js";
|
|
|
|
export const VOICE_CATALOG = [
|
|
{
|
|
id: "kokoro",
|
|
name: "Local voice",
|
|
description: "Free local speech on your server.",
|
|
transcribe: false,
|
|
},
|
|
] as const;
|
|
|
|
export { SCRIPTED_VOICE_CATALOG_ENTRY };
|
|
|
|
export type HostedVoiceProviderId = (typeof VOICE_CATALOG)[number]["id"];
|
|
export type VoiceProviderId = HostedVoiceProviderId | "scripted";
|
|
|
|
export function scriptedVoiceEnabled() {
|
|
return process.env.AGENT_RUNTIME === "scripted";
|
|
}
|
|
|
|
export function listVoiceCatalog() {
|
|
return scriptedVoiceEnabled()
|
|
? [...VOICE_CATALOG, SCRIPTED_VOICE_CATALOG_ENTRY]
|
|
: [...VOICE_CATALOG];
|
|
}
|
|
|
|
export function voiceCatalogEntry(id: string) {
|
|
if (id === SCRIPTED_VOICE_CATALOG_ENTRY.id) return SCRIPTED_VOICE_CATALOG_ENTRY;
|
|
return VOICE_CATALOG.find((entry) => entry.id === id);
|
|
}
|
|
|
|
export function isVoiceProviderId(value: string): value is VoiceProviderId {
|
|
if (value === "scripted") return scriptedVoiceEnabled();
|
|
return VOICE_CATALOG.some((entry) => entry.id === value);
|
|
}
|
|
|
|
/** Construct the provider adapter selected by a persisted voice credential. */
|
|
export function createVoiceProvider(kind: string): VoiceProvider {
|
|
switch (kind) {
|
|
case "kokoro":
|
|
return new KokoroVoiceProvider();
|
|
case "scripted":
|
|
if (!scriptedVoiceEnabled()) break;
|
|
return new ScriptedVoiceProvider();
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`Unknown voice provider "${kind}". Use kokoro.`);
|
|
}
|
|
|
|
export class NoVoiceConfigured extends Error {
|
|
readonly reason: "key" | "voice";
|
|
|
|
constructor(reason: "key" | "voice") {
|
|
super(
|
|
reason === "key" ? "Turn on speaking in Voice settings." : "Pick a voice in Voice settings.",
|
|
);
|
|
this.reason = reason;
|
|
this.name = "NoVoiceConfigured";
|
|
}
|
|
}
|
|
|
|
export const MAX_SPEAK_CHARS = 2000;
|
|
export const MAX_TRANSCRIBE_BYTES = 8 * 1024 * 1024;
|