diff --git a/scripts/check.mjs b/scripts/check.mjs
index d352257..87b07c0 100644
--- a/scripts/check.mjs
+++ b/scripts/check.mjs
@@ -112,7 +112,7 @@ if (/https:\/\/esm\.sh/i.test(cloud)) {
const piper = await readFile("src/piper-tts.js", "utf8");
for (const marker of [
- 'export const VOICE = "/piper/en_US-lessac-high"',
+ 'export const VOICE = "https://voice.shadow.wang/piper/en_US-ljspeech-medium"',
'export const VOICE_FILES',
]) {
if (!piper.includes(marker)) throw new Error(`piper-tts.js is missing ${marker}`);
diff --git a/src/piper-tts.js b/src/piper-tts.js
index 6a24237..10160b4 100644
--- a/src/piper-tts.js
+++ b/src/piper-tts.js
@@ -1,27 +1,19 @@
/* 本地 Piper 英语语音兜底(无 GMS 的国产 Android)
*
* 系统语音(speechSynthesis)在无 GMS 的国产 Android 上通常不可用,
- * 影伴提供浏览器本地 Piper 合成作为兜底:模型与运行时全部托管在本应用内,
- * 不上传录音,可离线使用。
+ * 影伴提供浏览器本地 Piper 合成作为兜底:运行时托管在本应用内,模型首次从 CDN 下载,
+ * 下载完成后缓存到浏览器,不上传录音,可离线使用。
*
* 致谢(开源项目详见 README 致谢一节):
* - piper-tts-web(MIT)
- * - rhasspy/piper 语音模型 en_US-lessac-high
+ * - rhasspy/piper 语音模型 en_US-ljspeech-medium(模型卡标注训练数据为 public domain)
* - ONNX Runtime Web(MIT)
*/
-export const VOICE = "/piper/en_US-lessac-high";
+export const VOICE = "https://voice.shadow.wang/piper/en_US-ljspeech-medium";
const VOICE_CACHE = "shadow-mate-voice";
const ENGINE_URL = "/piper-tts-web.js";
-export const VOICE_MODEL_PARTS = [VOICE + ".onnx.part-00", VOICE + ".onnx.part-01"];
-export const VOICE_FILES = [...VOICE_MODEL_PARTS, VOICE + ".onnx.json"];
-// Android 某些网络环境会隐藏响应的 Content-Length;这里用当前随包文件大小兜底计算百分比。
-// 替换语音模型文件时,需要同步更新这些数值。
-export const VOICE_FILE_SIZES = {
- [VOICE_MODEL_PARTS[0]]: 62_914_560,
- [VOICE_MODEL_PARTS[1]]: 50_980_641,
- [VOICE + ".onnx.json"]: 4_883,
-};
+export const VOICE_FILES = [VOICE + ".onnx", VOICE + ".onnx.json"];
export const ENGINE_LOAD_TIMEOUT_MS = 60_000;
export const SYNTHESIS_TIMEOUT_MS = 30_000;
@@ -62,9 +54,7 @@ async function getContentLength(url, signal) {
export async function isVoiceCached() {
if (!("caches" in window)) return false;
try {
- const cache = await openVoiceCache();
- const cachedFiles = await Promise.all(VOICE_FILES.map((url) => cache.match(url)));
- return cachedFiles.every(Boolean);
+ return !!(await (await openVoiceCache()).match(VOICE + ".onnx"));
} catch (_) {
return false;
}
@@ -78,10 +68,7 @@ export async function downloadVoice(onProgress, signal) {
}
const lengths = await Promise.all(pendingFiles.map((url) => getContentLength(url, signal)));
- const progressLengths = lengths.map((length, index) => length || VOICE_FILE_SIZES[pendingFiles[index]] || 0);
- const total = progressLengths.every((length) => length > 0)
- ? progressLengths.reduce((sum, length) => sum + length, 0)
- : 0;
+ const total = lengths.every((length) => length > 0) ? lengths.reduce((sum, length) => sum + length, 0) : 0;
let receivedTotal = 0;
for (let fileIndex = 0; fileIndex < pendingFiles.length; fileIndex += 1) {
@@ -89,7 +76,7 @@ export async function downloadVoice(onProgress, signal) {
if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
const res = await fetch(url, { signal });
if (!res.ok) throw new Error("语音包下载失败");
- const fileTotal = Number(res.headers.get("content-length")) || progressLengths[fileIndex] || 0;
+ const fileTotal = Number(res.headers.get("content-length")) || lengths[fileIndex] || 0;
const reader = res.body.getReader();
const chunks = [];
let receivedFile = 0;
@@ -133,7 +120,7 @@ async function loadEngine() {
const mod = await import(/* @vite-ignore */ ENGINE_URL);
const voiceProvider = {
async fetch(voice) {
- const readResponse = async (url) => {
+ const read = async (url) => {
let res = null;
if ("caches" in window) {
try {
@@ -144,24 +131,14 @@ async function loadEngine() {
}
if (!res) res = await fetch(url);
if (!res.ok) throw new Error("语音模型读取失败");
- return res;
+ return url.endsWith(".json") ? res.json() : URL.createObjectURL(await res.blob());
};
- const config = await (await readResponse(voice + ".onnx.json")).json();
- const modelParts = await Promise.all(
- VOICE_MODEL_PARTS.map(async (url) => (await readResponse(url)).blob())
- );
- const modelUrl = URL.createObjectURL(new Blob(modelParts, { type: "application/octet-stream" }));
- return [config, modelUrl];
+ return Promise.all([read(voice + ".onnx.json"), read(voice + ".onnx")]);
},
};
- const useWorkerRuntime = typeof Worker === "function"
- && typeof mod.OnnxWebWorkerRuntime === "function"
- && typeof mod.PhonemizeWebWorkerRuntime === "function";
- const OnnxRuntime = useWorkerRuntime ? mod.OnnxWebWorkerRuntime : mod.OnnxWebRuntime;
- const PhonemizeRuntime = useWorkerRuntime ? mod.PhonemizeWebWorkerRuntime : mod.PhonemizeWebRuntime;
return new mod.PiperWebEngine({
- onnxRuntime: new OnnxRuntime({ basePath: "/onnx/", numThreads: 1 }),
- phonemizeRuntime: new PhonemizeRuntime({ basePath: "/piper/" }),
+ onnxRuntime: new mod.OnnxWebRuntime({ basePath: "/onnx/", numThreads: 1 }),
+ phonemizeRuntime: new mod.PhonemizeWebRuntime({ basePath: "/piper/" }),
voiceProvider,
});
})().catch((err) => {
@@ -190,7 +167,7 @@ function buildDialog() {
dlg.className = "voice-dialog";
dlg.innerHTML =
'
离线英语语音
' +
- '当前设备没有可用的英语发音。影伴内置了约 115MB 的高质量离线英语语音包(一次性下载,之后可离线使用,不上传录音)。是否现在下载?
' +
+ '当前设备没有可用的英语发音。影伴内置了约 90MB 的离线英语语音包(一次性下载,之后可离线使用,不上传录音)。是否现在下载?
' +
'' +
'' +
'' +
diff --git a/tests/e2e/offline-tts-error.spec.js b/tests/e2e/offline-tts-error.spec.js
index bf4b677..e251cd6 100644
--- a/tests/e2e/offline-tts-error.spec.js
+++ b/tests/e2e/offline-tts-error.spec.js
@@ -16,7 +16,7 @@ test.describe("Offline voice download errors", () => {
`,
});
});
- await page.route("**/piper/en_US-lessac-high.onnx.part-00", async (route) => {
+ await page.route("https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx", async (route) => {
await route.fulfill({ status: 503, contentType: "text/plain", body: "unavailable" });
});
await page.goto("/");
diff --git a/tests/e2e/offline-tts-warmup.spec.js b/tests/e2e/offline-tts-warmup.spec.js
index 2b5f5cc..e216b1a 100644
--- a/tests/e2e/offline-tts-warmup.spec.js
+++ b/tests/e2e/offline-tts-warmup.spec.js
@@ -50,7 +50,7 @@ test.describe("Offline voice warmup", () => {
`,
});
});
- await page.route("**/piper/en_US-lessac-high.onnx*", async (route) => {
+ await page.route("https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx*", async (route) => {
const request = route.request();
const isConfig = request.url().endsWith(".json");
if (request.method() === "HEAD") {
@@ -130,9 +130,8 @@ test.describe("Offline voice warmup", () => {
value: function SpeechSynthesisUtterance() {},
});
const cacheStore = new Map([
- ["/piper/en_US-lessac-high.onnx.part-00", new Response(new Blob(["cached model 00"]))],
- ["/piper/en_US-lessac-high.onnx.part-01", new Response(new Blob(["cached model 01"]))],
- ["/piper/en_US-lessac-high.onnx.json", new Response("{}")],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx", new Response(new Blob(["cached model"]))],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx.json", new Response("{}")],
]);
Object.defineProperty(window, "caches", {
configurable: true,
@@ -193,9 +192,8 @@ test.describe("Offline voice warmup", () => {
value: function SpeechSynthesisUtterance() {},
});
const cacheStore = new Map([
- ["/piper/en_US-lessac-high.onnx.part-00", new Response(new Blob(["cached model 00"]))],
- ["/piper/en_US-lessac-high.onnx.part-01", new Response(new Blob(["cached model 01"]))],
- ["/piper/en_US-lessac-high.onnx.json", new Response("{}")],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx", new Response(new Blob(["cached model"]))],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx.json", new Response("{}")],
]);
Object.defineProperty(window, "caches", {
configurable: true,
@@ -229,7 +227,7 @@ test.describe("Offline voice warmup", () => {
await expect(page.locator("audio")).toHaveCount(0);
});
- test("runs local speech inference in worker runtimes when available", async ({ page }) => {
+ test("runs local speech inference on the main thread", async ({ page }) => {
await disableWebAudio(page);
await page.route("**/piper-tts-web.js*", async (route) => {
await route.fulfill({
@@ -276,9 +274,8 @@ test.describe("Offline voice warmup", () => {
value: function SpeechSynthesisUtterance() {},
});
const cacheStore = new Map([
- ["/piper/en_US-lessac-high.onnx.part-00", new Response(new Blob(["cached model 00"]))],
- ["/piper/en_US-lessac-high.onnx.part-01", new Response(new Blob(["cached model 01"]))],
- ["/piper/en_US-lessac-high.onnx.json", new Response("{}")],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx", new Response(new Blob(["cached model"]))],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx.json", new Response("{}")],
]);
Object.defineProperty(window, "caches", {
configurable: true,
@@ -295,7 +292,7 @@ test.describe("Offline voice warmup", () => {
await page.locator("[data-speak]").first().click();
await expect.poll(() => page.evaluate(() => window.__ttsRuntimeKinds), { timeout: 3000 })
- .toEqual(["worker", "worker"]);
+ .toEqual(["main", "main"]);
});
test("plays generated speech through a decoded Web Audio buffer when available", async ({ page }) => {
@@ -338,9 +335,8 @@ test.describe("Offline voice warmup", () => {
value: function SpeechSynthesisUtterance() {},
});
const cacheStore = new Map([
- ["/piper/en_US-lessac-high.onnx.part-00", new Response(new Blob(["cached model 00"]))],
- ["/piper/en_US-lessac-high.onnx.part-01", new Response(new Blob(["cached model 01"]))],
- ["/piper/en_US-lessac-high.onnx.json", new Response("{}")],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx", new Response(new Blob(["cached model"]))],
+ ["https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx.json", new Response("{}")],
]);
Object.defineProperty(window, "caches", {
configurable: true,
diff --git a/tests/unit/piper-tts.test.js b/tests/unit/piper-tts.test.js
index 934ce9e..2fa2f0d 100644
--- a/tests/unit/piper-tts.test.js
+++ b/tests/unit/piper-tts.test.js
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
-import { askDownloadVoice, downloadVoice, isVoiceCached, VOICE, VOICE_FILES, withTimeout } from "../../src/piper-tts.js";
+import { askDownloadVoice, downloadVoice, VOICE, VOICE_FILES, withTimeout } from "../../src/piper-tts.js";
const originalDialogDescriptors = new Map(
["showModal", "close"].map((name) => [name, Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, name)])
@@ -34,27 +34,15 @@ afterEach(() => {
});
describe("offline Piper voice download", () => {
- test("uses the high-quality lessac voice path", () => {
- expect(VOICE).toBe("/piper/en_US-lessac-high");
+ test("uses the commercially reviewed replacement voice path", () => {
+ expect(VOICE).toBe("https://voice.shadow.wang/piper/en_US-ljspeech-medium");
expect(VOICE_FILES).toEqual([
- "/piper/en_US-lessac-high.onnx.part-00",
- "/piper/en_US-lessac-high.onnx.part-01",
- "/piper/en_US-lessac-high.onnx.json",
+ "https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx",
+ "https://voice.shadow.wang/piper/en_US-ljspeech-medium.onnx.json",
]);
});
- test("treats every split model part and config as one cached voice", async () => {
- const cachedFiles = new Set(VOICE_FILES);
- const cache = {
- match: vi.fn((url) => Promise.resolve(cachedFiles.has(url) ? new Response("cached") : undefined)),
- };
- vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue(cache) });
-
- await expect(isVoiceCached()).resolves.toBe(true);
- expect(cache.match.mock.calls.map(([url]) => url)).toEqual(VOICE_FILES);
- });
-
- test("reports aggregate progress using bundled sizes when the response omits Content-Length", async () => {
+ test("reports received bytes when the response omits Content-Length", async () => {
const cache = {
match: vi.fn().mockResolvedValue(undefined),
put: vi.fn().mockResolvedValue(undefined),
@@ -65,7 +53,7 @@ describe("offline Piper voice download", () => {
vi.fn((url, options) => {
if (options?.method === "HEAD") return Promise.resolve(responseWithChunks([]));
return Promise.resolve(
- url.includes(".onnx.part-")
+ url.endsWith(".onnx")
? responseWithChunks([Uint8Array.from([1, 2]), Uint8Array.from([3, 4, 5])])
: responseWithChunks([Uint8Array.from([6])])
);
@@ -75,40 +63,8 @@ describe("offline Piper voice download", () => {
await downloadVoice((received, total) => progress.push([received, total]));
+ expect(progress).toContainEqual([5, 0]);
expect(progress.some(([received]) => received > 0)).toBe(true);
- expect(progress.some(([received, total]) => received > 0 && total > 100_000_000)).toBe(true);
- });
-
- test("shows a percentage in the download dialog when Content-Length is unavailable", async () => {
- Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
- configurable: true,
- value() {
- this.open = true;
- },
- });
- Object.defineProperty(HTMLDialogElement.prototype, "close", {
- configurable: true,
- value() {
- this.open = false;
- },
- });
-
- const cache = {
- match: vi.fn().mockResolvedValue(undefined),
- put: vi.fn().mockResolvedValue(undefined),
- };
- vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue(cache) });
- vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(responseWithChunks([Uint8Array.from([1])]))));
-
- const result = askDownloadVoice();
- const dialog = document.querySelector("#shadow-voice-dialog");
- dialog.querySelector('[data-action="ok"]').click();
-
- await vi.waitFor(() => {
- expect(dialog.querySelector(".voice-dialog-pct").textContent).toMatch(/^\d+%$/);
- });
- expect(dialog.querySelector(".voice-dialog-bar i").classList.contains("indeterminate")).toBe(false);
- await expect(result).resolves.toBe("ok");
});
test("reports aggregate bytes across the model and config files", async () => {
@@ -120,11 +76,11 @@ describe("offline Piper voice download", () => {
vi.stubGlobal(
"fetch",
vi.fn((url) => {
- const isModelPart = url.includes(".onnx.part-");
+ const isModel = url.endsWith(".onnx");
return Promise.resolve(
responseWithChunks(
- isModelPart ? [Uint8Array.from([1, 2, 3]), Uint8Array.from([4, 5])] : [Uint8Array.from([6])],
- { "content-length": isModelPart ? "5" : "1" }
+ isModel ? [Uint8Array.from([1, 2, 3]), Uint8Array.from([4, 5])] : [Uint8Array.from([6])],
+ { "content-length": isModel ? "5" : "1" }
)
);
})
@@ -133,11 +89,9 @@ describe("offline Piper voice download", () => {
await downloadVoice((received, total) => progress.push([received, total]));
- expect(progress).toContainEqual([3, 11]);
- expect(progress).toContainEqual([5, 11]);
- expect(progress).toContainEqual([8, 11]);
- expect(progress).toContainEqual([10, 11]);
- expect(progress.at(-1)).toEqual([11, 11]);
+ expect(progress).toContainEqual([3, 6]);
+ expect(progress).toContainEqual([5, 6]);
+ expect(progress.at(-1)).toEqual([6, 6]);
});
test("rejects when speech synthesis never settles", async () => {
diff --git a/vercel.json b/vercel.json
index 117588e..acbabac 100644
--- a/vercel.json
+++ b/vercel.json
@@ -21,7 +21,7 @@
},
{
"key": "Content-Security-Policy",
- "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self' blob: https://dutepjyocxcvecmsrtfp.supabase.co wss://dutepjyocxcvecmsrtfp.supabase.co; media-src 'self' blob:; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
+ "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self' blob: https://voice.shadow.wang https://dutepjyocxcvecmsrtfp.supabase.co wss://dutepjyocxcvecmsrtfp.supabase.co; media-src 'self' blob:; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
},
{
"key": "Strict-Transport-Security",