evOo-local/packages/adapters/src/voice-factory.test.ts
cynarAI 8ae3764b05
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>
2026-10-09 11:55:51 +02:00

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 });
});
});