* Voice UX: simple speak toggle, onboarding pick, chat voice change Replace Kokoro-branded settings with a Kann sprechen / Spricht nicht toggle, onboarding voice personalization, and local chat commands for voice changes. German voice presets (Eva, Bernd) surface as friendly names; Kokoro stays under the hood via KOKORO_BASE_URL. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Fix import order in voice onboarding and settings Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
489 lines
18 KiB
TypeScript
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(/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("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 });
|
|
});
|
|
});
|