evOo-local/packages/adapters/src/voice-factory.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

489 lines
18 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { CartesiaVoiceProvider } from "./cartesia-voice.js";
import { ElevenLabsVoiceProvider } from "./elevenlabs-voice.js";
import { FishAudioVoiceProvider } from "./fish-audio-voice.js";
import { KOKORO_LOCAL_TOKEN, KokoroVoiceProvider } from "./kokoro-voice.js";
import { OpenAIVoiceProvider } from "./openai-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("exposes the hosted catalog behind one factory", () => {
process.env.AGENT_RUNTIME = "pi";
expect(VOICE_CATALOG.map((entry) => entry.id)).toEqual([
"kokoro",
"elevenlabs",
"openai",
"cartesia",
"fish-audio",
]);
expect(listVoiceCatalog().map((entry) => entry.id)).toEqual([
"kokoro",
"elevenlabs",
"openai",
"cartesia",
"fish-audio",
]);
expect(createVoiceProvider("kokoro").describe().id).toBe("kokoro");
expect(createVoiceProvider("kokoro").describe().capabilities.transcribe).toBe(false);
expect(createVoiceProvider("elevenlabs").describe().id).toBe("elevenlabs");
expect(createVoiceProvider("openai").describe().capabilities.transcribe).toBe(true);
expect(createVoiceProvider("cartesia").describe().capabilities.transcribe).toBe(false);
expect(createVoiceProvider("fish-audio").describe().capabilities.transcribe).toBe(true);
expect(isVoiceProviderId("kokoro")).toBe(true);
expect(isVoiceProviderId("elevenlabs")).toBe(true);
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("ElevenLabsVoiceProvider", () => {
it("verifies against /voices so restricted speech keys still pass", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ voices: [{ voice_id: "abc", name: "Rachel" }] }),
});
vi.stubGlobal("fetch", fetchMock);
const provider = new ElevenLabsVoiceProvider();
await expect(provider.verify("sk_test_key", ctx)).resolves.toEqual({ ok: true });
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/voices");
});
it("synthesizes one utterance as mp3 bytes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const provider = new ElevenLabsVoiceProvider();
const clip = await provider.synthesize(
{ text: "Hello there.", voiceId: "abc", apiKey: "sk_test_key" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect([...clip.bytes]).toEqual([1, 2, 3]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/text-to-speech/abc");
});
it("transcribes through Scribe", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello there" }))),
);
const provider = new ElevenLabsVoiceProvider();
await expect(
provider.transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/webm", apiKey: "sk" },
ctx,
),
).resolves.toEqual({ text: "hello there" });
});
});
describe("OpenAIVoiceProvider", () => {
it("returns a static voice catalog without a network round trip", async () => {
const provider = new OpenAIVoiceProvider();
const voices = await provider.listVoices("sk-test", ctx);
expect(voices.some((voice) => voice.id === "alloy")).toBe(true);
});
it("posts speech to /audio/speech", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([9]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new OpenAIVoiceProvider().synthesize(
{ text: "Hi", voiceId: "alloy", apiKey: "sk-test" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/audio/speech");
});
it("names Firefox ogg recordings from the mime type", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello" })));
vi.stubGlobal("fetch", fetchMock);
await new OpenAIVoiceProvider().transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/ogg;codecs=opus", apiKey: "sk-test" },
ctx,
);
const form = fetchMock.mock.calls[0]?.[1]?.body as FormData;
const file = form.get("file") as File;
expect(file.name).toBe("speech.ogg");
});
});
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(/Kokoro lokal 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("CartesiaVoiceProvider", () => {
it("maps the voices list from either array or { data } payloads", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: "sonic", name: "Katie" }] })),
),
);
const provider = new CartesiaVoiceProvider();
const voices = await provider.listVoices("sk-test", ctx);
expect(voices).toEqual([{ id: "sonic", label: "Katie", description: undefined }]);
});
it("posts bytes to /tts/bytes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([4, 5]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new CartesiaVoiceProvider().synthesize(
{ text: "Hi", voiceId: "sonic", apiKey: "sk-test" },
ctx,
);
expect([...clip.bytes]).toEqual([4, 5]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/tts/bytes");
});
});
describe("FishAudioVoiceProvider", () => {
it("lists own then public voice models without duplicates", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [{ _id: "public-id", title: "Public Voice", languages: ["en", "fr"] }],
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{ _id: "private-id", title: "Private Voice", description: "My clone" },
{ _id: "public-id", title: "Duplicate" },
{ _id: "failed-id", title: "Failed", state: "failed" },
],
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices).toEqual([
{ id: "private-id", label: "Private Voice", description: "My clone" },
{ id: "public-id", label: "Duplicate" },
]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("page_size=100");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("self=true");
});
it("pages through Fish Audio model listings until has_more is false", async () => {
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
const prefix = own ? "own" : "pub";
return new Response(
JSON.stringify({
total: 2,
has_more: page < 2,
items: [{ _id: `${prefix}-${page}`, title: `${own ? "Own" : "Public"} ${page}` }],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices.map((voice) => voice.id)).toEqual(["own-1", "own-2", "pub-1", "pub-2"]);
expect(voices.some((voice) => voice.label === "Public 2")).toBe(true);
expect(voices.some((voice) => voice.label === "Own 2")).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(
fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url.includes("page_number=2")),
).toHaveLength(2);
});
it("keeps paging when total implies more pages without has_more", async () => {
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
if (own) {
return new Response(
JSON.stringify({ total: 1, items: [{ _id: "own-only", title: "Own" }] }),
);
}
return new Response(
JSON.stringify({
total: 101,
items:
page === 1
? Array.from({ length: 100 }, (_, index) => ({
_id: `pub-${index + 1}`,
title: `Public ${index + 1}`,
}))
: [{ _id: "pub-101", title: "Public 101" }],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices.some((voice) => voice.id === "pub-101")).toBe(true);
expect(voices.some((voice) => voice.label === "Public 101")).toBe(true);
expect(
fetchMock.mock.calls.some(
(call) =>
String(call[0]).includes("page_number=2") && !String(call[0]).includes("self=true"),
),
).toBe(true);
});
it("stops catalog crawls at page caps when has_more stays true", async () => {
const publicPages = 5;
const ownPages = 20;
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
const prefix = own ? "own" : "pub";
const start = (page - 1) * 100;
return new Response(
JSON.stringify({
total: 1_000_000,
has_more: true,
items: Array.from({ length: 100 }, (_, index) => ({
_id: `${prefix}-${start + index + 1}`,
title: `${own ? "Own" : "Public"} ${start + index + 1}`,
})),
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices).toHaveLength(ownPages * 100 + publicPages * 100);
expect(voices[0]?.id).toBe("own-1");
expect(voices.at(ownPages * 100)?.id).toBe("pub-1");
expect(
fetchMock.mock.calls.filter((call) => String(call[0]).includes("self=true")),
).toHaveLength(ownPages);
expect(
fetchMock.mock.calls.filter((call) => !String(call[0]).includes("self=true")),
).toHaveLength(publicPages);
});
it("synthesizes with the Fish Audio TTS contract", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([7, 8]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new FishAudioVoiceProvider().synthesize(
{ text: "Hi", voiceId: "voice-id", apiKey: "sk-test" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect([...clip.bytes]).toEqual([7, 8]);
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://api.fish.audio/v1/tts");
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((init.headers as Record<string, string>).model).toBe("s2.1-pro");
expect(JSON.parse(String(init.body))).toMatchObject({
text: "Hi",
reference_id: "voice-id",
format: "mp3",
});
});
it("transcribes through Fish Audio ASR", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello" })));
vi.stubGlobal("fetch", fetchMock);
await expect(
new FishAudioVoiceProvider().transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/ogg;codecs=opus", apiKey: "sk-test" },
ctx,
),
).resolves.toEqual({ text: "hello" });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://api.fish.audio/v1/asr");
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
const form = init.body as FormData;
expect((form.get("audio") as File).name).toBe("speech.ogg");
expect(form.get("ignore_timestamps")).toBe("true");
});
});
describe("hosted voice response limits", () => {
it.each([
["Kokoro", () => new KokoroVoiceProvider(), "af_heart"],
["ElevenLabs", () => new ElevenLabsVoiceProvider(), "voice"],
["OpenAI", () => new OpenAIVoiceProvider(), "alloy"],
["Cartesia", () => new CartesiaVoiceProvider(), "sonic"],
["Fish Audio", () => new FishAudioVoiceProvider(), "voice"],
])(
"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 });
});
});