Speak German through a local Piper server, since Kokoro has no German voices (#182)
Kokoro-FastAPI ships English, Spanish, French, Hindi, Italian, Japanese, Portuguese and Chinese voices only. The German presets pointed at voice ids no Kokoro server has, so German users either got English voices or picked a voice that failed to speak. The local voice now also lists the German voices whose Piper model is installed (PIPER_BASE_URL) and speaks them through Piper's /synthesize as WAV. Thorsten, the clearest model, is the German default. Mobile plays WAV as well as MP3, and the E2E voice stub stands in for Piper. Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6c0fba8cc1
commit
8ae3764b05
@ -68,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
export KOKORO_BASE_URL=http://127.0.0.1:8880
|
||||
```
|
||||
|
||||
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
|
||||
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
|
||||
|
||||
## Tech debt (brand / packages)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureApiRequestContext, currentApiBase, rpc } from "./api";
|
||||
import {
|
||||
MAX_VOICE_AUDIO_BYTES,
|
||||
playMpeg,
|
||||
playSpeech,
|
||||
speakText,
|
||||
speakUtterance,
|
||||
VOICE_RESPONSE_TIMEOUT_MS,
|
||||
@ -92,7 +92,7 @@ describe("mobile speech", () => {
|
||||
}
|
||||
vi.stubGlobal("Audio", ImmediateAudio);
|
||||
|
||||
await expect(playMpeg(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
|
||||
await expect(playSpeech(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects oversized audio without waiting for response cancellation", async () => {
|
||||
|
||||
@ -35,7 +35,7 @@ export async function speakText(text: string, opts: SpeechOptions = {}): Promise
|
||||
);
|
||||
if (!prepared.ready) return false;
|
||||
for (const utterance of prepared.utterances) {
|
||||
await playMpeg(await renderUtterance(utterance, opts, requestContext));
|
||||
await playSpeech(await renderUtterance(utterance, opts, requestContext));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -84,7 +84,13 @@ async function renderUtterance(
|
||||
}
|
||||
}
|
||||
|
||||
export async function playMpeg(bytes: Uint8Array): Promise<void> {
|
||||
/** Kokoro answers with MP3, Piper (German voices) with WAV. */
|
||||
function clipFormat(bytes: Uint8Array): { type: string; extension: string } {
|
||||
const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
|
||||
return riff ? { type: "audio/wav", extension: "wav" } : { type: "audio/mpeg", extension: "mp3" };
|
||||
}
|
||||
|
||||
export async function playSpeech(bytes: Uint8Array): Promise<void> {
|
||||
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
|
||||
if (typeof AudioCtor === "function") {
|
||||
await playWithHtmlAudio(AudioCtor, bytes);
|
||||
@ -94,7 +100,7 @@ export async function playMpeg(bytes: Uint8Array): Promise<void> {
|
||||
}
|
||||
|
||||
async function playWithHtmlAudio(AudioCtor: typeof Audio, bytes: Uint8Array): Promise<void> {
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" });
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: clipFormat(bytes).type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const audio = new AudioCtor(url);
|
||||
@ -132,7 +138,7 @@ async function playWithNativeAudio(bytes: Uint8Array): Promise<void> {
|
||||
interruptionMode: "mixWithOthers",
|
||||
shouldPlayInBackground: false,
|
||||
});
|
||||
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.mp3`);
|
||||
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.${clipFormat(bytes).extension}`);
|
||||
file.create({ overwrite: true });
|
||||
file.write(bytesToBase64(bytes), { encoding: "base64" });
|
||||
const player = createAudioPlayer({ uri: file.uri });
|
||||
|
||||
@ -50,9 +50,12 @@ test("German voice step lists German voices after choosing Deutsch", async ({ pa
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("onboarding-voice-dm_bernd")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-voice-df_eva")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("main conversation does not show a group subtitle for one Oo", async ({ page }) => {
|
||||
|
||||
@ -122,18 +122,18 @@ test("German lists only German voices and switches the saved voice to one", asyn
|
||||
await expect(settings.getByRole("heading", { name: "Sprache", exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
|
||||
.toBe("df_eva");
|
||||
.toBe("dm_thorsten");
|
||||
await settings.getByTestId("settings-nav-voice").click();
|
||||
voice = settings.getByTestId("voice-settings");
|
||||
const cards = voice.getByTestId("voice-card");
|
||||
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(1);
|
||||
await expect(cards.filter({ hasText: "Bernd" })).toHaveCount(1);
|
||||
await expect(cards.filter({ hasText: "Thorsten" })).toHaveCount(1);
|
||||
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0);
|
||||
await expect(voice.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute(
|
||||
await expect(voice.getByRole("button", { name: "Thorsten", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(voice.getByRole("button", { name: "Bernd anhören", exact: true })).toBeVisible();
|
||||
await expect(voice.getByRole("button", { name: "Eva anhören", exact: true })).toBeVisible();
|
||||
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
|
||||
@ -8,10 +8,11 @@ import {
|
||||
describe("offlineOnboardingVoices", () => {
|
||||
it("offers German voices for de", () => {
|
||||
expect(offlineOnboardingVoices("de").map((voice) => voice.id)).toEqual([
|
||||
"dm_thorsten",
|
||||
"df_kerstin",
|
||||
"df_eva",
|
||||
"dm_bernd",
|
||||
"df_victoria",
|
||||
"dm_martin",
|
||||
"df_ramona",
|
||||
"dm_karlsson",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -26,16 +27,17 @@ describe("onboardingVoicesFromList", () => {
|
||||
it("uses offline German presets when the server only lists English voices", () => {
|
||||
const englishOnly = [{ id: "af_alloy", label: "Alloy", description: "neutral" }];
|
||||
expect(onboardingVoicesFromList(englishOnly, "de").map((voice) => voice.id)).toEqual([
|
||||
"dm_thorsten",
|
||||
"df_kerstin",
|
||||
"df_eva",
|
||||
"dm_bernd",
|
||||
"df_victoria",
|
||||
"dm_martin",
|
||||
"df_ramona",
|
||||
"dm_karlsson",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultOnboardingVoiceId", () => {
|
||||
it("prefers df_eva for German", () => {
|
||||
expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("df_eva");
|
||||
it("prefers Thorsten for German", () => {
|
||||
expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("dm_thorsten");
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,7 +27,7 @@ Minimum secrets (use independent long random values):
|
||||
| `SCREEN_PROXY_SECRET` | long random |
|
||||
| `SANDBOX_SUPERVISOR_TOKEN` | required for Docker sandboxes |
|
||||
|
||||
Optional: `OPENROUTER_API_KEY`, Composio/Pipedream, `KOKORO_BASE_URL`.
|
||||
Optional: `OPENROUTER_API_KEY`, Composio/Pipedream, `KOKORO_BASE_URL`, `PIPER_BASE_URL`.
|
||||
|
||||
## 2. Postgres
|
||||
|
||||
@ -109,7 +109,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
export KOKORO_BASE_URL=http://127.0.0.1:8880
|
||||
```
|
||||
|
||||
Then connect **Kokoro** in Voice settings. See [kokoro-voice.md](./kokoro-voice.md).
|
||||
Then connect **Kokoro** in Voice settings. German voices come from Piper (`PIPER_BASE_URL`). See [kokoro-voice.md](./kokoro-voice.md).
|
||||
|
||||
## 7. Antigravity / future tools
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Kokoro (lokale Stimme)
|
||||
|
||||
Kokoro ist die einzige Stimme in EvOo: **Kokoro-82M** (Apache 2.0) läuft lokal über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), ohne Cloud-Dienst und ohne API-Key.
|
||||
Kokoro ist die lokale Stimme in EvOo: **Kokoro-82M** (Apache 2.0) läuft über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), ohne Cloud-Dienst und ohne API-Key. Deutsche Stimmen kommen aus Piper (siehe unten).
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@ -23,12 +23,29 @@ export KOKORO_BASE_URL=http://127.0.0.1:8880
|
||||
|
||||
## Stimmen und Sprache
|
||||
|
||||
Kokoro kodiert die Sprache im ersten Buchstaben der Stimm-ID (`a`/`b` Englisch, `d` Deutsch, `e` Spanisch, `f` Französisch, …). EvOo zeigt nur die Stimmen der eingestellten App-Sprache und wechselt die gespeicherte Stimme mit, wenn die Sprache wechselt. Bietet der Server keine Stimme in dieser Sprache an (das Standard-Image von Kokoro-FastAPI hat keine deutschen `df_`/`dm_`-Stimmen), zeigt EvOo alle Stimmen.
|
||||
Kokoro kodiert die Sprache im ersten Buchstaben der Stimm-ID (`a`/`b` Englisch, `d` Deutsch, `e` Spanisch, `f` Französisch, …). EvOo zeigt nur die Stimmen der eingestellten App-Sprache und wechselt die gespeicherte Stimme mit, wenn die Sprache wechselt. Bietet der Server keine Stimme in dieser Sprache an, zeigt EvOo alle Stimmen.
|
||||
|
||||
## Deutsche Stimmen (Piper)
|
||||
|
||||
Kokoro hat keine deutschen Stimmen. Die kommen aus [Piper](https://github.com/OHF-Voice/piper1-gpl), das ebenfalls lokal und ohne GPU läuft. EvOo führt sie als `dm_thorsten`, `df_kerstin`, `df_eva`, `df_ramona` und `dm_karlsson` und zeigt nur die, deren Modell auf dem Piper-Server liegt.
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/piper && ~/piper/bin/pip install "piper-tts[http]"
|
||||
mkdir -p ~/piper/voices && cd ~/piper/voices
|
||||
for v in de_DE-thorsten-high de_DE-kerstin-low de_DE-eva_k-x_low de_DE-ramona-low de_DE-karlsson-low; do
|
||||
~/piper/bin/python -m piper.download_voices "$v"
|
||||
done
|
||||
~/piper/bin/python -m piper.http_server -m de_DE-thorsten-high --data-dir ~/piper/voices --host 127.0.0.1 --port 5150
|
||||
export PIPER_BASE_URL=http://127.0.0.1:5150
|
||||
```
|
||||
|
||||
Ohne `PIPER_BASE_URL` fragt EvOo Piper gar nicht erst an.
|
||||
|
||||
## Fehler
|
||||
|
||||
| Meldung | Ursache |
|
||||
|---------|---------|
|
||||
| *Kokoro lokal nicht erreichbar…* | Kokoro-FastAPI läuft nicht oder `KOKORO_BASE_URL` stimmt nicht. |
|
||||
| *Deutsche Stimme nicht erreichbar…* | Piper läuft nicht oder `PIPER_BASE_URL` fehlt. |
|
||||
|
||||
API-Dokumentation nach Start: `http://127.0.0.1:8880/docs`
|
||||
|
||||
@ -1169,7 +1169,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "update_bot",
|
||||
description:
|
||||
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, Oo look, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description or look, pick a different voice (e.g. Eva, Bernd), or turn speaking on/off. Do not claim you updated the profile without calling this tool.",
|
||||
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, Oo look, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description or look, pick a different voice (e.g. Eva, Thorsten), or turn speaking on/off. Do not claim you updated the profile without calling this tool.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -1192,7 +1192,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
voice_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Voice id from the connected provider (e.g. df_eva, dm_bernd). Match the user's requested voice name.",
|
||||
"Voice id from the connected provider (e.g. dm_thorsten, af_heart). Match the user's requested voice name.",
|
||||
},
|
||||
auto_speak: {
|
||||
type: "boolean",
|
||||
|
||||
@ -8,7 +8,8 @@ import type {
|
||||
VoiceSynthesizeRequest,
|
||||
VoiceVerifyResult,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { formatVoiceLabel, GERMAN_VOICE_PRESETS } from "@rakazo/core";
|
||||
import { formatVoiceLabel } from "@rakazo/core";
|
||||
import { listPiperVoices, synthesizePiper } from "./piper-voice.js";
|
||||
import {
|
||||
readVoiceAudio,
|
||||
readVoiceJson,
|
||||
@ -25,7 +26,6 @@ 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" },
|
||||
@ -79,6 +79,18 @@ export class KokoroVoiceProvider implements VoiceProvider {
|
||||
}
|
||||
|
||||
async listVoices(_apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
|
||||
const [kokoro, piper] = await Promise.all([
|
||||
this.listKokoroVoices(context).catch((error: unknown) => error as Error),
|
||||
listPiperVoices(context.signal),
|
||||
]);
|
||||
if (kokoro instanceof Error) {
|
||||
if (piper.length > 0) return piper;
|
||||
throw kokoro;
|
||||
}
|
||||
return [...kokoro, ...piper];
|
||||
}
|
||||
|
||||
private async listKokoroVoices(context: AdapterContext): Promise<VoiceInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, {
|
||||
signal: voiceDeadline(context.signal, 20_000),
|
||||
@ -100,6 +112,8 @@ export class KokoroVoiceProvider implements VoiceProvider {
|
||||
|
||||
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
|
||||
const signal = voiceDeadline(request.signal ?? context.signal, 120_000);
|
||||
const piper = await synthesizePiper(request.voiceId, request.text, signal);
|
||||
if (piper) return piper;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${kokoroBaseUrl()}/v1/audio/speech`, {
|
||||
|
||||
65
packages/adapters/src/piper-voice.ts
Normal file
65
packages/adapters/src/piper-voice.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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<string, string> = {
|
||||
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 only where configured, so a server without it skips the request entirely. */
|
||||
export function piperBaseUrl(): string | undefined {
|
||||
const raw = process.env.PIPER_BASE_URL?.trim();
|
||||
return raw ? raw.replace(/\/+$/, "") : undefined;
|
||||
}
|
||||
|
||||
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<VoiceInfo[]> {
|
||||
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<SpeechClip | undefined> {
|
||||
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");
|
||||
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/wav" };
|
||||
}
|
||||
@ -123,6 +123,74 @@ describe("KokoroVoiceProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("German voices through Piper", () => {
|
||||
function routeFetch(routes: Record<string, () => Response | Promise<Response>>) {
|
||||
const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => {
|
||||
const route = routes[String(url)];
|
||||
if (!route) throw new Error("fetch failed");
|
||||
return route();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it("lists the German voices whose Piper model is installed next to Kokoro's", async () => {
|
||||
vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150/");
|
||||
routeFetch({
|
||||
"http://127.0.0.1:8880/v1/audio/voices": () =>
|
||||
new Response(JSON.stringify({ voices: ["af_heart"] })),
|
||||
"http://piper.test:5150/voices": () =>
|
||||
new Response(JSON.stringify({ "de_DE-thorsten-high": {}, "en_US-lessac-medium": {} })),
|
||||
});
|
||||
const voices = await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx);
|
||||
expect(voices.map((voice) => voice.id)).toEqual(["af_heart", "dm_thorsten"]);
|
||||
expect(voices[1]?.label).toBe("Thorsten");
|
||||
});
|
||||
|
||||
it("keeps German voices when Kokoro is down and skips Piper when it is not configured", async () => {
|
||||
vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150");
|
||||
routeFetch({
|
||||
"http://piper.test:5150/voices": () =>
|
||||
new Response(JSON.stringify({ "de_DE-eva_k-x_low": {} })),
|
||||
});
|
||||
const voices = await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx);
|
||||
expect(voices.map((voice) => voice.id)).toEqual(["df_eva"]);
|
||||
|
||||
vi.stubEnv("PIPER_BASE_URL", "");
|
||||
const fetchMock = routeFetch({
|
||||
"http://127.0.0.1:8880/v1/audio/voices": () =>
|
||||
new Response(JSON.stringify({ voices: ["af_heart"] })),
|
||||
});
|
||||
await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("speaks a German voice through Piper's /synthesize as WAV", async () => {
|
||||
vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150");
|
||||
const fetchMock = routeFetch({
|
||||
"http://piper.test:5150/synthesize": () => new Response(new Uint8Array([0x52, 0x49])),
|
||||
});
|
||||
const clip = await new KokoroVoiceProvider().synthesize(
|
||||
{ text: "Hallo", voiceId: "dm_thorsten", apiKey: KOKORO_LOCAL_TOKEN },
|
||||
ctx,
|
||||
);
|
||||
expect(clip.mimeType).toBe("audio/wav");
|
||||
expect([...clip.bytes]).toEqual([0x52, 0x49]);
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({ text: "Hallo", voice: "de_DE-thorsten-high" });
|
||||
});
|
||||
|
||||
it("names Piper when a German voice is picked but Piper is missing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
await expect(
|
||||
new KokoroVoiceProvider().synthesize(
|
||||
{ text: "Hallo", voiceId: "df_eva", apiKey: KOKORO_LOCAL_TOKEN },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/Piper/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice response limits", () => {
|
||||
it.each([["Kokoro", () => new KokoroVoiceProvider(), "af_heart"] as const])(
|
||||
"rejects an oversized %s speech response before buffering it",
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import type { VoiceInfo } from "@rakazo/contracts";
|
||||
|
||||
/** Friendly German presets when Kokoro exposes df_/dm_ voices. */
|
||||
/**
|
||||
* German voices. Kokoro ships none, so the local voice serves these from Piper; their ids
|
||||
* follow Kokoro's scheme so the language filter treats them like any other voice.
|
||||
*/
|
||||
export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [
|
||||
{ id: "dm_thorsten", label: "Thorsten", description: "klar" },
|
||||
{ id: "df_kerstin", label: "Kerstin", description: "freundlich" },
|
||||
{ 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" },
|
||||
{ id: "df_ramona", label: "Ramona", description: "ruhig" },
|
||||
{ id: "dm_karlsson", label: "Karlsson", description: "tief" },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -60,7 +64,7 @@ export function voicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[
|
||||
|
||||
export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined {
|
||||
const listed = voicesForLocale(voices, locale);
|
||||
return listed.find((voice) => voice.id === "df_eva") ?? listed[0];
|
||||
return listed.find((voice) => voice.id === GERMAN_VOICE_PRESETS[0]?.id) ?? listed[0];
|
||||
}
|
||||
|
||||
/** The voice to keep using for this UI language: the current one when it fits, else the default. */
|
||||
@ -74,7 +78,7 @@ export function voiceIdForLocale(
|
||||
return defaultVoiceForLocale(voices, locale)?.id;
|
||||
}
|
||||
|
||||
/** Match a user-provided name (Eva, bernd, df_eva) against available voices. */
|
||||
/** Match a user-provided name (Eva, thorsten, df_eva) against available voices. */
|
||||
export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo | undefined {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return undefined;
|
||||
|
||||
@ -82,6 +82,7 @@ async function main() {
|
||||
process.env.SIGNUP_ALLOWLIST = "";
|
||||
process.env.CI = "1";
|
||||
process.env.KOKORO_BASE_URL = kokoro.baseUrl;
|
||||
process.env.PIPER_BASE_URL = kokoro.baseUrl;
|
||||
|
||||
execSync("pnpm --filter @rakazo/db generate", { stdio: "inherit", env: process.env });
|
||||
execSync("pnpm --filter @rakazo/db exec prisma migrate deploy", {
|
||||
|
||||
@ -2,8 +2,8 @@ import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
/**
|
||||
* Stands in for the local Kokoro speech server so onboarding's voice step never depends on
|
||||
* whatever happens to listen on the default port of the machine running the suite.
|
||||
* Stands in for the local Kokoro and Piper speech servers so onboarding's voice step never
|
||||
* depends on whatever happens to listen on the default ports of the machine running the suite.
|
||||
*/
|
||||
export async function startFakeKokoro(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
||||
const server = createServer((req, res) => {
|
||||
@ -22,6 +22,19 @@ export async function startFakeKokoro(): Promise<{ baseUrl: string; close: () =>
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/voices") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ "de_DE-thorsten-high": {}, "de_DE-eva_k-x_low": {} }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/synthesize") {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "audio/wav" });
|
||||
res.end(Buffer.from("RIFF\0\0\0\0WAVE"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user