evOo-local/packages/adapters/src/kokoro-voice.ts
cynarAI 459d7c99e4
Pick the voice from a carousel in the app language, local voice only (#166)
* 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>
2026-10-05 22:20:06 +02:00

151 lines
4.8 KiB
TypeScript

import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import { formatVoiceLabel, GERMAN_VOICE_PRESETS } from "@rakazo/core";
import {
readVoiceAudio,
readVoiceJson,
requireOk,
voiceDeadline,
voiceHttpError,
} from "./voice-http.js";
/** Placeholder stored in voice credentials — Kokoro needs no paid API key. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
const DEFAULT_BASE_URL = "http://127.0.0.1:8880";
const MODEL = "kokoro";
/** Curated presets when the server is down or returns bare voice ids. */
export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [
...GERMAN_VOICE_PRESETS,
{ id: "af_heart", label: "Heart", description: "warm" },
{ id: "af_bella", label: "Bella", description: "expressive" },
{ id: "af_sky", label: "Sky", description: "bright" },
{ id: "af_nova", label: "Nova", description: "energetic" },
{ id: "am_adam", label: "Adam", description: "clear" },
{ id: "am_michael", label: "Michael", description: "steady" },
{ id: "bf_emma", label: "Emma", description: "British" },
{ id: "bf_isabella", label: "Isabella", description: "British" },
{ id: "bm_george", label: "George", description: "British" },
];
const FALLBACK_BY_ID = new Map(KOKORO_FALLBACK_VOICES.map((voice) => [voice.id, voice]));
export function kokoroBaseUrl(): string {
const raw = process.env.KOKORO_BASE_URL?.trim();
return raw && raw.length > 0 ? raw.replace(/\/+$/, "") : DEFAULT_BASE_URL;
}
export function kokoroUnreachable(): string {
return "Lokaler Sprachdienst nicht erreichbar. Prüfe, ob der Sprachserver auf Port 8880 läuft.";
}
export class KokoroVoiceProvider implements VoiceProvider {
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "kokoro",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: false },
};
}
async verify(_apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
try {
const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, {
signal: voiceDeadline(context.signal, 20_000),
});
if (res.ok) return { ok: true };
return {
ok: false,
message: voiceHttpError(
res.status,
"Local voice",
"checking the local server",
await readVoiceJson(res),
),
};
} catch {
return { ok: false, message: kokoroUnreachable() };
}
}
async listVoices(_apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
try {
const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, {
signal: voiceDeadline(context.signal, 20_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) {
throw new Error(voiceHttpError(res.status, "Local voice", "listing voices", body));
}
const voices = voicesFrom(body);
if (voices.length === 0) return [...KOKORO_FALLBACK_VOICES];
return voices;
} catch (error) {
if (error instanceof Error && /listing voices failed|speaking failed/.test(error.message)) {
throw error;
}
throw new Error(kokoroUnreachable());
}
}
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 120_000);
let res: Response;
try {
res = await fetch(`${kokoroBaseUrl()}/v1/audio/speech`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: MODEL,
input: request.text,
voice: request.voiceId,
response_format: "mp3",
stream: false,
}),
signal,
});
} catch {
throw new Error(kokoroUnreachable());
}
await requireOk(res, "Local voice", "speaking");
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
}
}
function voicesFrom(body: unknown): VoiceInfo[] {
const raw = extractVoiceIds(body);
return raw
.map((id) => {
const known = FALLBACK_BY_ID.get(id);
return known ?? { id, label: formatVoiceLabel(id) };
})
.filter((voice) => voice.id);
}
function extractVoiceIds(body: unknown): string[] {
if (!body || typeof body !== "object") return [];
const record = body as Record<string, unknown>;
const voices = record.voices;
if (!Array.isArray(voices)) return [];
return voices
.map((entry) => {
if (typeof entry === "string") return entry.trim();
if (entry && typeof entry === "object") {
const voice = entry as Record<string, unknown>;
return String(voice.id ?? voice.name ?? "").trim();
}
return "";
})
.filter(Boolean);
}