diff --git a/README.md b/README.md index f170419..5abec99 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest export KOKORO_BASE_URL=http://127.0.0.1:8880 ``` -Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md) +German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md) ## Tech debt (brand / packages) diff --git a/apps/mobile/lib/voice.test.ts b/apps/mobile/lib/voice.test.ts index 4eb0445..314fb45 100644 --- a/apps/mobile/lib/voice.test.ts +++ b/apps/mobile/lib/voice.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureApiRequestContext, currentApiBase, rpc } from "./api"; import { MAX_VOICE_AUDIO_BYTES, - playMpeg, + playSpeech, speakText, speakUtterance, VOICE_RESPONSE_TIMEOUT_MS, @@ -92,7 +92,7 @@ describe("mobile speech", () => { } vi.stubGlobal("Audio", ImmediateAudio); - await expect(playMpeg(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined(); + await expect(playSpeech(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined(); }); it("rejects oversized audio without waiting for response cancellation", async () => { diff --git a/apps/mobile/lib/voice.ts b/apps/mobile/lib/voice.ts index 3a27249..3346df9 100644 --- a/apps/mobile/lib/voice.ts +++ b/apps/mobile/lib/voice.ts @@ -35,7 +35,7 @@ export async function speakText(text: string, opts: SpeechOptions = {}): Promise ); if (!prepared.ready) return false; for (const utterance of prepared.utterances) { - await playMpeg(await renderUtterance(utterance, opts, requestContext)); + await playSpeech(await renderUtterance(utterance, opts, requestContext)); } return true; } @@ -84,7 +84,13 @@ async function renderUtterance( } } -export async function playMpeg(bytes: Uint8Array): Promise { +/** Kokoro answers with MP3, Piper (German voices) with WAV. */ +function clipFormat(bytes: Uint8Array): { type: string; extension: string } { + const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46; + return riff ? { type: "audio/wav", extension: "wav" } : { type: "audio/mpeg", extension: "mp3" }; +} + +export async function playSpeech(bytes: Uint8Array): Promise { const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio; if (typeof AudioCtor === "function") { await playWithHtmlAudio(AudioCtor, bytes); @@ -94,7 +100,7 @@ export async function playMpeg(bytes: Uint8Array): Promise { } async function playWithHtmlAudio(AudioCtor: typeof Audio, bytes: Uint8Array): Promise { - const blob = new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" }); + const blob = new Blob([new Uint8Array(bytes)], { type: clipFormat(bytes).type }); const url = URL.createObjectURL(blob); try { const audio = new AudioCtor(url); @@ -132,7 +138,7 @@ async function playWithNativeAudio(bytes: Uint8Array): Promise { interruptionMode: "mixWithOthers", shouldPlayInBackground: false, }); - const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.mp3`); + const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.${clipFormat(bytes).extension}`); file.create({ overwrite: true }); file.write(bytesToBase64(bytes), { encoding: "base64" }); const player = createAudioPlayer({ uri: file.uri }); diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts index b0e2306..2dfa45f 100644 --- a/apps/web/e2e/first-run-onboarding-ux.spec.ts +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -50,9 +50,12 @@ test("German voice step lists German voices after choosing Deutsch", async ({ pa timeout: 30_000, }); await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("onboarding-voice-dm_bernd")).toBeVisible(); + await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toBeVisible(); await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0); - await expect(page.getByTestId("onboarding-voice-df_eva")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("onboarding-voice-dm_thorsten")).toHaveAttribute( + "aria-pressed", + "true", + ); }); test("main conversation does not show a group subtitle for one Oo", async ({ page }) => { diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts index 3185e6d..9005470 100644 --- a/apps/web/e2e/voice.spec.ts +++ b/apps/web/e2e/voice.spec.ts @@ -122,18 +122,18 @@ test("German lists only German voices and switches the saved voice to one", asyn await expect(settings.getByRole("heading", { name: "Sprache", exact: true })).toBeVisible(); await expect .poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId) - .toBe("df_eva"); + .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: "Bernd" })).toHaveCount(1); + await expect(cards.filter({ hasText: "Thorsten" })).toHaveCount(1); await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0); - await expect(voice.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute( + await expect(voice.getByRole("button", { name: "Thorsten", exact: true })).toHaveAttribute( "aria-pressed", "true", ); - await expect(voice.getByRole("button", { name: "Bernd anhören", exact: true })).toBeVisible(); + await expect(voice.getByRole("button", { name: "Eva anhören", exact: true })).toBeVisible(); for (const colorScheme of ["light", "dark"] as const) { await page.emulateMedia({ colorScheme }); diff --git a/apps/web/src/lib/onboarding-voices.test.ts b/apps/web/src/lib/onboarding-voices.test.ts index 29e053e..3676e37 100644 --- a/apps/web/src/lib/onboarding-voices.test.ts +++ b/apps/web/src/lib/onboarding-voices.test.ts @@ -8,10 +8,11 @@ import { describe("offlineOnboardingVoices", () => { it("offers German voices for de", () => { expect(offlineOnboardingVoices("de").map((voice) => voice.id)).toEqual([ + "dm_thorsten", + "df_kerstin", "df_eva", - "dm_bernd", - "df_victoria", - "dm_martin", + "df_ramona", + "dm_karlsson", ]); }); @@ -26,16 +27,17 @@ describe("onboardingVoicesFromList", () => { it("uses offline German presets when the server only lists English voices", () => { const englishOnly = [{ id: "af_alloy", label: "Alloy", description: "neutral" }]; expect(onboardingVoicesFromList(englishOnly, "de").map((voice) => voice.id)).toEqual([ + "dm_thorsten", + "df_kerstin", "df_eva", - "dm_bernd", - "df_victoria", - "dm_martin", + "df_ramona", + "dm_karlsson", ]); }); }); describe("defaultOnboardingVoiceId", () => { - it("prefers df_eva for German", () => { - expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("df_eva"); + it("prefers Thorsten for German", () => { + expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("dm_thorsten"); }); }); diff --git a/docs/LOCAL-SETUP.md b/docs/LOCAL-SETUP.md index b2a6fe0..179b0b5 100644 --- a/docs/LOCAL-SETUP.md +++ b/docs/LOCAL-SETUP.md @@ -27,7 +27,7 @@ Minimum secrets (use independent long random values): | `SCREEN_PROXY_SECRET` | long random | | `SANDBOX_SUPERVISOR_TOKEN` | required for Docker sandboxes | -Optional: `OPENROUTER_API_KEY`, Composio/Pipedream, `KOKORO_BASE_URL`. +Optional: `OPENROUTER_API_KEY`, Composio/Pipedream, `KOKORO_BASE_URL`, `PIPER_BASE_URL`. ## 2. Postgres @@ -109,7 +109,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest export KOKORO_BASE_URL=http://127.0.0.1:8880 ``` -Then connect **Kokoro** in Voice settings. See [kokoro-voice.md](./kokoro-voice.md). +Then connect **Kokoro** in Voice settings. German voices come from Piper (`PIPER_BASE_URL`). See [kokoro-voice.md](./kokoro-voice.md). ## 7. Antigravity / future tools diff --git a/docs/kokoro-voice.md b/docs/kokoro-voice.md index a30990b..e4b052b 100644 --- a/docs/kokoro-voice.md +++ b/docs/kokoro-voice.md @@ -1,6 +1,6 @@ # Kokoro (lokale Stimme) -Kokoro ist die einzige Stimme in EvOo: **Kokoro-82M** (Apache 2.0) läuft lokal über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), ohne Cloud-Dienst und ohne API-Key. +Kokoro ist die lokale Stimme in EvOo: **Kokoro-82M** (Apache 2.0) läuft über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), ohne Cloud-Dienst und ohne API-Key. Deutsche Stimmen kommen aus Piper (siehe unten). ## Schnellstart @@ -23,12 +23,29 @@ export KOKORO_BASE_URL=http://127.0.0.1:8880 ## Stimmen und Sprache -Kokoro kodiert die Sprache im ersten Buchstaben der Stimm-ID (`a`/`b` Englisch, `d` Deutsch, `e` Spanisch, `f` Französisch, …). EvOo zeigt nur die Stimmen der eingestellten App-Sprache und wechselt die gespeicherte Stimme mit, wenn die Sprache wechselt. Bietet der Server keine Stimme in dieser Sprache an (das Standard-Image von Kokoro-FastAPI hat keine deutschen `df_`/`dm_`-Stimmen), zeigt EvOo alle Stimmen. +Kokoro kodiert die Sprache im ersten Buchstaben der Stimm-ID (`a`/`b` Englisch, `d` Deutsch, `e` Spanisch, `f` Französisch, …). EvOo zeigt nur die Stimmen der eingestellten App-Sprache und wechselt die gespeicherte Stimme mit, wenn die Sprache wechselt. Bietet der Server keine Stimme in dieser Sprache an, zeigt EvOo alle Stimmen. + +## Deutsche Stimmen (Piper) + +Kokoro hat keine deutschen Stimmen. Die kommen aus [Piper](https://github.com/OHF-Voice/piper1-gpl), das ebenfalls lokal und ohne GPU läuft. EvOo führt sie als `dm_thorsten`, `df_kerstin`, `df_eva`, `df_ramona` und `dm_karlsson` und zeigt nur die, deren Modell auf dem Piper-Server liegt. + +```bash +python3 -m venv ~/piper && ~/piper/bin/pip install "piper-tts[http]" +mkdir -p ~/piper/voices && cd ~/piper/voices +for v in de_DE-thorsten-high de_DE-kerstin-low de_DE-eva_k-x_low de_DE-ramona-low de_DE-karlsson-low; do + ~/piper/bin/python -m piper.download_voices "$v" +done +~/piper/bin/python -m piper.http_server -m de_DE-thorsten-high --data-dir ~/piper/voices --host 127.0.0.1 --port 5150 +export PIPER_BASE_URL=http://127.0.0.1:5150 +``` + +Ohne `PIPER_BASE_URL` fragt EvOo Piper gar nicht erst an. ## Fehler | Meldung | Ursache | |---------|---------| | *Kokoro lokal nicht erreichbar…* | Kokoro-FastAPI läuft nicht oder `KOKORO_BASE_URL` stimmt nicht. | +| *Deutsche Stimme nicht erreichbar…* | Piper läuft nicht oder `PIPER_BASE_URL` fehlt. | API-Dokumentation nach Start: `http://127.0.0.1:8880/docs` diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 3c140df..ad7c2a9 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -1169,7 +1169,7 @@ export const builtinAgentTools: ConnectorTool[] = [ { name: "update_bot", description: - "Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, Oo look, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description or look, pick a different voice (e.g. Eva, Bernd), or turn speaking on/off. Do not claim you updated the profile without calling this tool.", + "Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, Oo look, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description or look, pick a different voice (e.g. Eva, Thorsten), or turn speaking on/off. Do not claim you updated the profile without calling this tool.", inputSchema: { type: "object", properties: { @@ -1192,7 +1192,7 @@ export const builtinAgentTools: ConnectorTool[] = [ voice_id: { type: "string", description: - "Voice id from the connected provider (e.g. df_eva, dm_bernd). Match the user's requested voice name.", + "Voice id from the connected provider (e.g. dm_thorsten, af_heart). Match the user's requested voice name.", }, auto_speak: { type: "boolean", diff --git a/packages/adapters/src/kokoro-voice.ts b/packages/adapters/src/kokoro-voice.ts index 7ee2e5a..b3097fa 100644 --- a/packages/adapters/src/kokoro-voice.ts +++ b/packages/adapters/src/kokoro-voice.ts @@ -8,7 +8,8 @@ import type { VoiceSynthesizeRequest, VoiceVerifyResult, } from "@rakazo/adapter-kit"; -import { formatVoiceLabel, GERMAN_VOICE_PRESETS } from "@rakazo/core"; +import { formatVoiceLabel } from "@rakazo/core"; +import { listPiperVoices, synthesizePiper } from "./piper-voice.js"; import { readVoiceAudio, readVoiceJson, @@ -25,7 +26,6 @@ const MODEL = "kokoro"; /** Curated presets when the server is down or returns bare voice ids. */ export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [ - ...GERMAN_VOICE_PRESETS, { id: "af_heart", label: "Heart", description: "warm" }, { id: "af_bella", label: "Bella", description: "expressive" }, { id: "af_sky", label: "Sky", description: "bright" }, @@ -79,6 +79,18 @@ export class KokoroVoiceProvider implements VoiceProvider { } async listVoices(_apiKey: string, context: AdapterContext): Promise { + const [kokoro, piper] = await Promise.all([ + this.listKokoroVoices(context).catch((error: unknown) => error as Error), + listPiperVoices(context.signal), + ]); + if (kokoro instanceof Error) { + if (piper.length > 0) return piper; + throw kokoro; + } + return [...kokoro, ...piper]; + } + + private async listKokoroVoices(context: AdapterContext): Promise { try { const res = await fetch(`${kokoroBaseUrl()}/v1/audio/voices`, { signal: voiceDeadline(context.signal, 20_000), @@ -100,6 +112,8 @@ export class KokoroVoiceProvider implements VoiceProvider { async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise { const signal = voiceDeadline(request.signal ?? context.signal, 120_000); + const piper = await synthesizePiper(request.voiceId, request.text, signal); + if (piper) return piper; let res: Response; try { res = await fetch(`${kokoroBaseUrl()}/v1/audio/speech`, { diff --git a/packages/adapters/src/piper-voice.ts b/packages/adapters/src/piper-voice.ts new file mode 100644 index 0000000..fcea4d1 --- /dev/null +++ b/packages/adapters/src/piper-voice.ts @@ -0,0 +1,65 @@ +import type { SpeechClip, VoiceInfo } from "@rakazo/adapter-kit"; +import { GERMAN_VOICE_PRESETS } from "@rakazo/core"; +import { readVoiceAudio, readVoiceJson, requireOk, voiceDeadline } from "./voice-http.js"; + +/** The Piper model behind each German voice; Kokoro has no German voices of its own. */ +export const PIPER_MODELS: Record = { + dm_thorsten: "de_DE-thorsten-high", + df_kerstin: "de_DE-kerstin-low", + df_eva: "de_DE-eva_k-x_low", + df_ramona: "de_DE-ramona-low", + dm_karlsson: "de_DE-karlsson-low", +}; + +/** Piper runs only where configured, so a server without it skips the request entirely. */ +export function piperBaseUrl(): string | undefined { + const raw = process.env.PIPER_BASE_URL?.trim(); + return raw ? raw.replace(/\/+$/, "") : undefined; +} + +export function piperUnreachable(): string { + return "Deutsche Stimme nicht erreichbar. Prüfe, ob der Piper-Server läuft."; +} + +/** The German voices whose Piper model the server has downloaded. */ +export async function listPiperVoices(signal: AbortSignal): Promise { + const baseUrl = piperBaseUrl(); + if (!baseUrl) return []; + try { + const res = await fetch(`${baseUrl}/voices`, { signal: voiceDeadline(signal, 20_000) }); + if (!res.ok) return []; + const body = await readVoiceJson(res, { requireValid: true }); + const models = new Set(body && typeof body === "object" ? Object.keys(body) : []); + return GERMAN_VOICE_PRESETS.filter((voice) => { + const model = PIPER_MODELS[voice.id]; + return model !== undefined && models.has(model); + }); + } catch { + return []; + } +} + +/** Speaks through Piper when the voice is one of its models, otherwise returns undefined. */ +export async function synthesizePiper( + voiceId: string | undefined, + text: string, + signal: AbortSignal, +): Promise { + const model = voiceId ? PIPER_MODELS[voiceId] : undefined; + if (!model) return undefined; + const baseUrl = piperBaseUrl(); + if (!baseUrl) throw new Error(piperUnreachable()); + let res: Response; + try { + res = await fetch(`${baseUrl}/synthesize`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text, voice: model }), + signal, + }); + } catch { + throw new Error(piperUnreachable()); + } + await requireOk(res, "Local voice", "speaking"); + return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/wav" }; +} diff --git a/packages/adapters/src/voice-factory.test.ts b/packages/adapters/src/voice-factory.test.ts index c99e1d9..8be0b09 100644 --- a/packages/adapters/src/voice-factory.test.ts +++ b/packages/adapters/src/voice-factory.test.ts @@ -123,6 +123,74 @@ describe("KokoroVoiceProvider", () => { }); }); +describe("German voices through Piper", () => { + function routeFetch(routes: Record Response | Promise>) { + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => { + const route = routes[String(url)]; + if (!route) throw new Error("fetch failed"); + return route(); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("lists the German voices whose Piper model is installed next to Kokoro's", async () => { + vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150/"); + routeFetch({ + "http://127.0.0.1:8880/v1/audio/voices": () => + new Response(JSON.stringify({ voices: ["af_heart"] })), + "http://piper.test:5150/voices": () => + new Response(JSON.stringify({ "de_DE-thorsten-high": {}, "en_US-lessac-medium": {} })), + }); + const voices = await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx); + expect(voices.map((voice) => voice.id)).toEqual(["af_heart", "dm_thorsten"]); + expect(voices[1]?.label).toBe("Thorsten"); + }); + + it("keeps German voices when Kokoro is down and skips Piper when it is not configured", async () => { + vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150"); + routeFetch({ + "http://piper.test:5150/voices": () => + new Response(JSON.stringify({ "de_DE-eva_k-x_low": {} })), + }); + const voices = await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx); + expect(voices.map((voice) => voice.id)).toEqual(["df_eva"]); + + vi.stubEnv("PIPER_BASE_URL", ""); + const fetchMock = routeFetch({ + "http://127.0.0.1:8880/v1/audio/voices": () => + new Response(JSON.stringify({ voices: ["af_heart"] })), + }); + await new KokoroVoiceProvider().listVoices(KOKORO_LOCAL_TOKEN, ctx); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("speaks a German voice through Piper's /synthesize as WAV", async () => { + vi.stubEnv("PIPER_BASE_URL", "http://piper.test:5150"); + const fetchMock = routeFetch({ + "http://piper.test:5150/synthesize": () => new Response(new Uint8Array([0x52, 0x49])), + }); + const clip = await new KokoroVoiceProvider().synthesize( + { text: "Hallo", voiceId: "dm_thorsten", apiKey: KOKORO_LOCAL_TOKEN }, + ctx, + ); + expect(clip.mimeType).toBe("audio/wav"); + expect([...clip.bytes]).toEqual([0x52, 0x49]); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(JSON.parse(String(init.body))).toEqual({ text: "Hallo", voice: "de_DE-thorsten-high" }); + }); + + it("names Piper when a German voice is picked but Piper is missing", async () => { + vi.stubGlobal("fetch", vi.fn()); + await expect( + new KokoroVoiceProvider().synthesize( + { text: "Hallo", voiceId: "df_eva", apiKey: KOKORO_LOCAL_TOKEN }, + ctx, + ), + ).rejects.toThrow(/Piper/); + }); +}); + describe("voice response limits", () => { it.each([["Kokoro", () => new KokoroVoiceProvider(), "af_heart"] as const])( "rejects an oversized %s speech response before buffering it", diff --git a/packages/core/src/voice-labels.ts b/packages/core/src/voice-labels.ts index 0461c37..da7929b 100644 --- a/packages/core/src/voice-labels.ts +++ b/packages/core/src/voice-labels.ts @@ -1,11 +1,15 @@ import type { VoiceInfo } from "@rakazo/contracts"; -/** Friendly German presets when Kokoro exposes df_/dm_ voices. */ +/** + * German voices. Kokoro ships none, so the local voice serves these from Piper; their ids + * follow Kokoro's scheme so the language filter treats them like any other voice. + */ export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [ + { id: "dm_thorsten", label: "Thorsten", description: "klar" }, + { id: "df_kerstin", label: "Kerstin", description: "freundlich" }, { id: "df_eva", label: "Eva", description: "warm" }, - { id: "dm_bernd", label: "Bernd", description: "klar" }, - { id: "df_victoria", label: "Victoria", description: "freundlich" }, - { id: "dm_martin", label: "Martin", description: "ruhig" }, + { id: "df_ramona", label: "Ramona", description: "ruhig" }, + { id: "dm_karlsson", label: "Karlsson", description: "tief" }, ]; /** @@ -60,7 +64,7 @@ export function voicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[ export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined { const listed = voicesForLocale(voices, locale); - return listed.find((voice) => voice.id === "df_eva") ?? listed[0]; + return listed.find((voice) => voice.id === GERMAN_VOICE_PRESETS[0]?.id) ?? listed[0]; } /** The voice to keep using for this UI language: the current one when it fits, else the default. */ @@ -74,7 +78,7 @@ export function voiceIdForLocale( return defaultVoiceForLocale(voices, locale)?.id; } -/** Match a user-provided name (Eva, bernd, df_eva) against available voices. */ +/** Match a user-provided name (Eva, thorsten, df_eva) against available voices. */ export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo | undefined { const needle = query.trim().toLowerCase(); if (!needle) return undefined; diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 4c5878a..13be3d6 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -82,6 +82,7 @@ async function main() { process.env.SIGNUP_ALLOWLIST = ""; process.env.CI = "1"; process.env.KOKORO_BASE_URL = kokoro.baseUrl; + process.env.PIPER_BASE_URL = kokoro.baseUrl; execSync("pnpm --filter @rakazo/db generate", { stdio: "inherit", env: process.env }); execSync("pnpm --filter @rakazo/db exec prisma migrate deploy", { diff --git a/packages/testkit/src/fake-kokoro.ts b/packages/testkit/src/fake-kokoro.ts index 908a9c6..7377356 100644 --- a/packages/testkit/src/fake-kokoro.ts +++ b/packages/testkit/src/fake-kokoro.ts @@ -2,8 +2,8 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; /** - * Stands in for the local Kokoro speech server so onboarding's voice step never depends on - * whatever happens to listen on the default port of the machine running the suite. + * Stands in for the local Kokoro and Piper speech servers so onboarding's voice step never + * depends on whatever happens to listen on the default ports of the machine running the suite. */ export async function startFakeKokoro(): Promise<{ baseUrl: string; close: () => Promise }> { const server = createServer((req, res) => { @@ -22,6 +22,19 @@ export async function startFakeKokoro(): Promise<{ baseUrl: string; close: () => }); return; } + if (req.method === "GET" && req.url === "/voices") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ "de_DE-thorsten-high": {}, "de_DE-eva_k-x_low": {} })); + return; + } + if (req.method === "POST" && req.url === "/synthesize") { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "content-type": "audio/wav" }); + res.end(Buffer.from("RIFF\0\0\0\0WAVE")); + }); + return; + } res.writeHead(404).end(); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));