evOo-local/apps/mobile/lib/voice.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

139 lines
4.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureApiRequestContext, currentApiBase, rpc } from "./api";
import {
MAX_VOICE_AUDIO_BYTES,
playSpeech,
speakText,
speakUtterance,
VOICE_RESPONSE_TIMEOUT_MS,
} from "./voice";
vi.mock("./ai-consent", () => ({ promptAiConsent: vi.fn() }));
vi.mock("expo-file-system", () => ({ File: class {}, Paths: {} }));
vi.mock("./api", () => ({
authHeaders: vi.fn(),
captureApiRequestContext: vi.fn(),
currentApiBase: vi.fn(() => "https://api.example"),
rpc: vi.fn(),
}));
class FakeAudio {
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
src = "";
async play() {
setTimeout(() => this.onended?.(), 0);
}
pause() {}
}
describe("mobile speech", () => {
beforeEach(() => {
vi.mocked(captureApiRequestContext).mockResolvedValue({
apiBase: "https://support.example",
headers: {
authorization: "Bearer support-token",
"x-rakazo-space-id": "space-support",
},
});
vi.mocked(rpc).mockImplementation(async (proc) => {
if (proc === "aiConsent/status") return { version: "2026-09-14", recipients: [] } as never;
vi.mocked(currentApiBase).mockReturnValue("https://finance.example");
return { ready: true, utterances: ["First", "Second"] } as never;
});
vi.stubGlobal("Audio", FakeAudio);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("keeps every request on the server and space captured before preparation", async () => {
await expect(speakText("Read this", { botId: "bot-1" })).resolves.toBe(true);
const requestContext = {
apiBase: "https://support.example",
headers: {
authorization: "Bearer support-token",
"x-rakazo-space-id": "space-support",
},
};
expect(rpc).toHaveBeenCalledWith(
"voice/prepare",
{ text: "Read this", voiceId: undefined, botId: "bot-1" },
{ requestContext },
);
expect(captureApiRequestContext).toHaveBeenCalledTimes(1);
const fetchMock = vi.mocked(fetch);
expect(fetchMock).toHaveBeenCalledTimes(2);
for (const [url, init] of fetchMock.mock.calls) {
expect(url).toBe("https://support.example/api/voice/speak");
expect(init?.headers).toMatchObject(requestContext.headers);
}
});
it("listens for an HTML audio clip ending before playback starts", async () => {
class ImmediateAudio {
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
async play() {
expect(this.onended).toBeTypeOf("function");
this.onended?.();
}
}
vi.stubGlobal("Audio", ImmediateAudio);
await expect(playSpeech(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
});
it("rejects oversized audio without waiting for response cancellation", async () => {
const cancel = vi.fn(() => new Promise<void>(() => undefined));
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(new ReadableStream({ cancel }), {
headers: { "content-length": String(MAX_VOICE_AUDIO_BYTES + 1) },
}),
),
);
await expect(
speakUtterance("Hello.", { requestContext: await captureApiRequestContext() }),
).rejects.toThrow("Voice response is too large.");
expect(cancel).toHaveBeenCalledOnce();
});
it("times out while a voice response body is stalled", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
new ReadableStream({
pull: () => new Promise<void>(() => undefined),
}),
),
),
);
const pending = speakUtterance("Hello.", {
requestContext: await captureApiRequestContext(),
});
const rejected = expect(pending).rejects.toThrow("Voice request timed out.");
await vi.advanceTimersByTimeAsync(VOICE_RESPONSE_TIMEOUT_MS);
await rejected;
});
});