evOo-local/apps/web/e2e/voice.spec.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

147 lines
6.3 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
async function turnOnSpeaking(page: Page) {
const settings = await openUserSettings(page, "voice");
const voice = settings.getByTestId("voice-settings");
await expect(voice).toBeVisible();
await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0);
await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0);
await voice.getByRole("switch", { name: "Speaking on or off" }).click();
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
return voice;
}
test("voice settings pick a voice from the carousel, speak a reply, and open a call", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `voice-${stamp}@rakazo.test`, "password12", `Voice ${stamp}`);
await completeOnboarding(page);
await expect(page.getByRole("button", { name: "Call", exact: true })).toHaveCount(0);
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(1);
await captureScreenshot(page, testInfo, "voice-composer");
const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", {
text: "Hello there.",
});
expect(preparedOff.ready).toBe(false);
const voice = await turnOnSpeaking(page);
const cards = voice.getByTestId("voice-card");
// English UI lists only the English voices of the local server.
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(1);
await expect(cards.filter({ hasText: "George" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(0);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await voice.getByRole("button", { name: "Bella", exact: true }).click();
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"false",
);
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("af_bella");
const previewSpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(),
);
await voice.getByRole("button", { name: "Play Sky", exact: true }).click();
await previewSpoken;
// A preview only plays the voice; the chosen one stays.
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.getByRole("button", { name: "Close voice settings" }).click();
await expect(page.getByTestId("user-settings")).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("say hello");
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(0);
await captureScreenshot(page, testInfo, "voice-composer-typing");
await page.keyboard.press("Enter");
// A reply can render more than one text bubble; speak the latest one.
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last();
await expect(speakReply).toBeVisible({ timeout: 30_000 });
const replySpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(),
);
await speakReply.click();
await replySpoken;
await page
.getByTestId("composer-bar")
.getByRole("button", { name: "Voice", exact: true })
.click();
await expect(page.getByTestId("call-view")).toBeVisible();
await expect(page.getByTestId("voice-panel")).toBeVisible();
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
await captureScreenshot(page, testInfo, "voice-stage");
await page.getByRole("button", { name: "Hang up" }).click();
await expect(page.getByTestId("call-view")).toHaveCount(0);
});
test("German lists only German voices and switches the saved voice to one", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `voice-de-${stamp}@rakazo.test`, "password12", `Stimme ${stamp}`);
await completeOnboarding(page);
let voice = await turnOnSpeaking(page);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Close voice settings" }).click();
// Switching the app language moves the saved voice to that language too.
const settings = await openUserSettings(page);
await settings.getByTestId("ui-locale-select").click();
await settings.getByRole("option", { name: "Deutsch", exact: true }).click();
await expect(settings.getByRole("heading", { name: "Sprache", exact: true })).toBeVisible();
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("dm_thorsten");
await settings.getByTestId("settings-nav-voice").click();
voice = settings.getByTestId("voice-settings");
const cards = voice.getByTestId("voice-card");
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Thorsten" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0);
await expect(voice.getByRole("button", { name: "Thorsten", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Eva anhören", exact: true })).toBeVisible();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-de-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
await captureScreenshot(page, testInfo, "voice-settings-carousel-de-phone");
});