evOo-local/packages/adapters/src/voice-factory.test.ts
Claude bc7353f1f2
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
2026-10-05 14:44:03 +00:00

179 lines
6.5 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("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 });
});
});