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:
committed by
GitHub
co-authored by
haixin.yang
parent
46ec49bd52
commit
681da02cb3
@@ -4,6 +4,9 @@ node_modules/
|
||||
# build output
|
||||
dist/
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# local QA captures and generated media
|
||||
qa/
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user