Add multilingual Piper voices with end-to-end coverage (#3)

* Add browser Piper Chinese voices and fix timeline scale

* Add multilingual Piper voices with end-to-end tests

---------

Co-authored-by: haixin.yang <haixin.yang@weimob.com>
This commit is contained in:
MartinDelophy
2026-07-15 11:12:38 +08:00
committed by GitHub
co-authored by haixin.yang
parent 46ec49bd52
commit 681da02cb3
10 changed files with 186 additions and 5 deletions
+3
View File
@@ -4,6 +4,9 @@ node_modules/
# build output
dist/
coverage/
test-results/
playwright-report/
blob-report/
# local QA captures and generated media
qa/
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import { readFile } from "node:fs/promises";
const VOICE_CASES = [
{ language: "中文", voice: "小雅", text: "你好,这是一次完整的中文语音合成测试。" },
{ language: "English", voice: "Heart", text: "Hello. This is a complete English voice synthesis test." },
{ language: "Deutsch", voice: "Thorsten", text: "Hallo! Dies ist ein vollständiger Sprachtest." },
{ language: "Español", voice: "DaveFX", text: "Hola. Esta es una prueba completa de síntesis de voz." },
{ language: "Français", voice: "Siwis", text: "Bonjour. Ceci est un test complet de synthèse vocale." },
{ language: "Italiano", voice: "Riccardo", text: "Ciao. Questo è un test completo di sintesi vocale." },
{ language: "Português", voice: "Faber", text: "Olá. Este é um teste completo de síntese de voz." },
];
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ai-voiceover-ui-language", "zh"));
});
for (const voiceCase of VOICE_CASES) {
test(`${voiceCase.language} ${voiceCase.voice} generates a valid WAV through the editor`, async ({ page }) => {
await page.goto("/");
await page.locator("#script-input").fill(voiceCase.text);
await page.locator(".voice-filter").click();
await page.locator(".menu-list button").getByText(voiceCase.language, { exact: true }).click();
await expect(page.locator(".voice-card.is-selected strong")).toHaveText(voiceCase.voice);
await page.locator(".generate-button").click();
const clipDownload = page.getByRole("button", { name: "下载片段", exact: true });
await expect(clipDownload).toBeEnabled({ timeout: 4 * 60 * 1000 });
const downloadPromise = page.waitForEvent("download");
await clipDownload.click();
const download = await downloadPromise;
const path = await download.path();
const wav = await readFile(path);
expect(wav.byteLength).toBeGreaterThan(44);
expect(wav.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(wav.subarray(8, 12).toString("ascii")).toBe("WAVE");
});
}
+64
View File
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.56.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
@@ -1629,6 +1630,22 @@
"react-dom": ">= 16.8"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -4038,6 +4055,53 @@
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz",
+2
View File
@@ -21,6 +21,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:e2e:voices": "playwright test e2e/piper-languages.spec.js",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
@@ -42,6 +43,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.56.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
timeout: 5 * 60 * 1000,
use: {
baseURL: "http://127.0.0.1:5173",
headless: true,
},
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:5173",
reuseExistingServer: true,
timeout: 30 * 1000,
},
});
+7 -1
View File
@@ -1023,6 +1023,8 @@ export function VoiceSynthesisPanel({
setFavoriteVoiceIds,
t,
}) {
const voiceLanguages = useMemo(() => [...new Set(VOICES.map((voice) => voice.language))], []);
return (
<>
<label className="field-label" htmlFor="script-input">
@@ -1048,13 +1050,17 @@ export function VoiceSynthesisPanel({
{showVoiceFilter ? (
<Popover onClose={() => setShowVoiceFilter(false)}>
<div className="menu-list">
{["all", "中文", "English", "piper", "kokoro"].map((filter) => (
{["all", ...voiceLanguages].map((filter) => (
<button
type="button"
className={voiceFilter === filter ? "is-selected" : ""}
key={filter}
onClick={() => {
setVoiceFilter(filter);
if (filter !== "all") {
const firstVoiceForLanguage = VOICES.find((voice) => voice.language === filter);
if (firstVoiceForLanguage) setSelectedVoiceId(firstVoiceForLanguage.id);
}
setShowVoiceFilter(false);
}}
>
+45
View File
@@ -104,6 +104,51 @@ export const VOICES = [
engine: "kokoro",
badge: "ONNX",
},
{
id: "de_DE-thorsten-medium",
name: "Thorsten",
language: "Deutsch",
detail: "Piper ONNX · Deutsch",
gender: "Natural male",
engine: "piper",
badge: "ONNX",
},
{
id: "es_ES-davefx-medium",
name: "DaveFX",
language: "Español",
detail: "Piper ONNX · Español",
gender: "Natural male",
engine: "piper",
badge: "ONNX",
},
{
id: "fr_FR-siwis-medium",
name: "Siwis",
language: "Français",
detail: "Piper ONNX · Français",
gender: "Natural voice",
engine: "piper",
badge: "ONNX",
},
{
id: "it_IT-riccardo-x_low",
name: "Riccardo",
language: "Italiano",
detail: "Piper ONNX · Italiano · x_low",
gender: "Natural male",
engine: "piper",
badge: "ONNX",
},
{
id: "pt_BR-faber-medium",
name: "Faber",
language: "Português",
detail: "Piper ONNX · Português (Brasil)",
gender: "Natural male",
engine: "piper",
badge: "ONNX",
},
];
export const TOOL_RAIL = [
+1 -3
View File
@@ -4,9 +4,7 @@ import { SAMPLE_IMAGE, VOICES } from "../config/editor.js";
export function useEditorCatalog(voiceFilter) {
const filteredVoices = useMemo(() => VOICES.filter((voice) => {
if (voiceFilter === "all") return true;
if (voiceFilter === "中文") return voice.language === "中文";
if (voiceFilter === "English") return voice.language === "English";
return voice.engine === voiceFilter;
return voice.language === voiceFilter;
}), [voiceFilter]);
const builtInAssets = useMemo(() => [
{ id: "sample", type: "image", src: SAMPLE_IMAGE, name: "sample-portrait.png", meta: "1920 x 1080", width: 1920, height: 1080 },
+3 -1
View File
@@ -20,7 +20,9 @@ export function useVoiceGeneration(d) {
if (d.selectedVoice.engine === "piper") {
const tts = await import("@diffusionstudio/vits-web");
if (await clearPiperCacheIfStorageTight(tts)) d.notify(d.t("ttsNoticePiperCacheCleared"));
d.setStatusText(d.t("ttsStatusLoadingChineseModel"));
d.setStatusText(d.selectedVoice.language === "中文"
? d.t("ttsStatusLoadingChineseModel")
: `Loading ${d.selectedVoice.language} Piper model…`);
const progress = (event) => { if (event?.total) d.setProgress((current) => Math.max(current, Math.min(88, Math.max(12, Math.round((event.loaded / event.total) * 76))))); };
const input = { text: prepared.text, voiceId: d.selectedVoice.id };
try { blob = await predictPiperVoice(tts, input, progress); }
+3
View File
@@ -2,6 +2,9 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
test: {
include: ["src/**/*.test.{js,jsx}"],
},
optimizeDeps: {
include: ["react", "react-dom/client"],
},