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>
247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { KOKORO_LOCAL_TOKEN, KokoroVoiceProvider } from "./kokoro-voice.js";
|
|
import {
|
|
SCRIPTED_MPEG,
|
|
SCRIPTED_TRANSCRIPT,
|
|
SCRIPTED_VOICE_ID,
|
|
ScriptedVoiceProvider,
|
|
} from "./scripted-voice.js";
|
|
import {
|
|
createVoiceProvider,
|
|
isVoiceProviderId,
|
|
listVoiceCatalog,
|
|
VOICE_CATALOG,
|
|
} from "./voice-factory.js";
|
|
import { MAX_SYNTHESIZED_AUDIO_BYTES } from "./voice-http.js";
|
|
|
|
const ctx = {
|
|
operationId: "voice",
|
|
traceId: "voice",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
};
|
|
|
|
const previousRuntime = process.env.AGENT_RUNTIME;
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
if (previousRuntime === undefined) delete process.env.AGENT_RUNTIME;
|
|
else process.env.AGENT_RUNTIME = previousRuntime;
|
|
});
|
|
|
|
describe("createVoiceProvider", () => {
|
|
it("offers only the local voice server", () => {
|
|
process.env.AGENT_RUNTIME = "pi";
|
|
expect(VOICE_CATALOG.map((entry) => entry.id)).toEqual(["kokoro"]);
|
|
expect(listVoiceCatalog().map((entry) => entry.id)).toEqual(["kokoro"]);
|
|
expect(createVoiceProvider("kokoro").describe().id).toBe("kokoro");
|
|
expect(createVoiceProvider("kokoro").describe().capabilities.transcribe).toBe(false);
|
|
expect(isVoiceProviderId("kokoro")).toBe(true);
|
|
expect(isVoiceProviderId("elevenlabs")).toBe(false);
|
|
expect(() => createVoiceProvider("elevenlabs")).toThrow(/unknown voice provider/i);
|
|
expect(isVoiceProviderId("scripted")).toBe(false);
|
|
expect(isVoiceProviderId("piper")).toBe(false);
|
|
expect(() => createVoiceProvider("piper")).toThrow(/unknown voice provider/i);
|
|
expect(() => createVoiceProvider("scripted")).toThrow(/unknown voice provider/i);
|
|
});
|
|
|
|
it("adds the scripted fixture only when the agent runtime is scripted", () => {
|
|
process.env.AGENT_RUNTIME = "scripted";
|
|
expect(listVoiceCatalog().some((entry) => entry.id === "scripted")).toBe(true);
|
|
expect(isVoiceProviderId("scripted")).toBe(true);
|
|
expect(createVoiceProvider("scripted").describe().id).toBe("scripted");
|
|
});
|
|
});
|
|
|
|
describe("KokoroVoiceProvider", () => {
|
|
it("verifies against the local /v1/audio/voices endpoint", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ voices: ["af_heart"] }),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const provider = new KokoroVoiceProvider();
|
|
await expect(provider.verify(KOKORO_LOCAL_TOKEN, ctx)).resolves.toEqual({ ok: true });
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:8880/v1/audio/voices");
|
|
});
|
|
|
|
it("returns a helpful message when the local server is down", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed")));
|
|
const provider = new KokoroVoiceProvider();
|
|
const result = await provider.verify(KOKORO_LOCAL_TOKEN, ctx);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.message).toMatch(/Lokaler Sprachdienst nicht erreichbar/i);
|
|
});
|
|
|
|
it("maps plain-string and object voice payloads", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
voices: ["af_heart", { id: "af_bella", name: "Bella" }],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
const voices = await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx);
|
|
expect(voices.map((voice) => voice.id)).toEqual(["af_heart", "af_bella"]);
|
|
expect(voices[0]?.label).toBe("Heart");
|
|
});
|
|
|
|
it("posts non-streaming speech to /v1/audio/speech", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new Uint8Array([6, 7]).buffer,
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const clip = await new KokoroVoiceProvider().synthesize(
|
|
{ text: "Hallo", voiceId: "af_heart", apiKey: KOKORO_LOCAL_TOKEN },
|
|
ctx,
|
|
);
|
|
expect(clip.mimeType).toBe("audio/mpeg");
|
|
expect([...clip.bytes]).toEqual([6, 7]);
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:8880/v1/audio/speech");
|
|
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
expect(JSON.parse(String(init.body))).toMatchObject({
|
|
model: "kokoro",
|
|
input: "Hallo",
|
|
voice: "af_heart",
|
|
response_format: "mp3",
|
|
stream: false,
|
|
});
|
|
});
|
|
|
|
it("honors KOKORO_BASE_URL", async () => {
|
|
vi.stubEnv("KOKORO_BASE_URL", "http://kokoro.test:9000/");
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ voices: [] }) });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
await new KokoroVoiceProvider().verify(KOKORO_LOCAL_TOKEN, ctx);
|
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://kokoro.test:9000/v1/audio/voices");
|
|
});
|
|
});
|
|
|
|
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",
|
|
async (_name, create, voiceId) => {
|
|
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: new Headers({
|
|
"content-length": String(MAX_SYNTHESIZED_AUDIO_BYTES + 1),
|
|
}),
|
|
body: { cancel },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
create().synthesize({ text: "Hi", voiceId, apiKey: "sk-test" }, ctx),
|
|
).rejects.toThrow("Voice response is too large.");
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("ScriptedVoiceProvider", () => {
|
|
it("verifies, lists, speaks, and transcribes without a network", async () => {
|
|
const provider = new ScriptedVoiceProvider();
|
|
await expect(provider.verify("short", ctx)).resolves.toEqual({
|
|
ok: false,
|
|
message: "That key is too short.",
|
|
});
|
|
await expect(provider.verify("fake-scripted-voice-key", ctx)).resolves.toEqual({ ok: true });
|
|
expect(await provider.listVoices("fake-scripted-voice-key", ctx)).toEqual([
|
|
{ id: SCRIPTED_VOICE_ID, label: "Scripted", description: "Test voice" },
|
|
]);
|
|
const clip = await provider.synthesize(
|
|
{ text: "Hello", voiceId: SCRIPTED_VOICE_ID, apiKey: "fake-scripted-voice-key" },
|
|
ctx,
|
|
);
|
|
expect(clip.mimeType).toBe("audio/mpeg");
|
|
expect([...clip.bytes]).toEqual([...SCRIPTED_MPEG]);
|
|
await expect(
|
|
provider.transcribe(
|
|
{
|
|
audio: new Uint8Array([1]),
|
|
mimeType: "audio/webm",
|
|
apiKey: "fake-scripted-voice-key",
|
|
},
|
|
ctx,
|
|
),
|
|
).resolves.toEqual({ text: SCRIPTED_TRANSCRIPT });
|
|
});
|
|
});
|