Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- 将旧 `en_US-ljspeech-medium` 与 `zh_CN-chaowen-medium` 标记为停止新增下载,仅保留历史缓存识别和安全迁移元数据。

### Fixed
- 修复 Matcha 离线包在 Android 浏览器下载完成后仍无法发音的问题:官方 WASM 预占 512MB 共享内存,叠加模型展开后可能终止移动端 Worker;移动兼容运行时改为 256MB 起始内存并保留按需增长。已有 149MB 模型数据继续复用,只补下约 12MB WASM。
- 修复中文 Piper 将 `phoneme_type: "pinyin"` 的模型元数据误送入 eSpeak,导致离线中文发音听起来不是普通话或加载超时的问题;中文现在使用固定版本的 `pinyin-pro` 生成声母、韵母和声调音素,英语仍沿用原有 eSpeak 路径。
- 为 CDN 语音下载增加有时限的 `HEAD` / `GET` 请求,以及 `Content-Length`、响应类型、CORS、实际字节数和 SHA-256 的 fail-closed 校验;失败不会写入完成标记,可安全重试。
- 明确语音缓存按浏览器配置文件、域名和资源版本隔离,避免把更换浏览器或环境后的再次下载误判为重复下载。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Shadow Mate is an open-source family learning PWA. It has no advertising and no

## Speech fallback

“Listen” uses the matching system voice when it is reliable, then falls back to one browser-local `matcha-icefall-zh-en` package for both Mandarin and English. The first fallback downloads and validates the 154.6 MB package from `voice.shadow.wang`; after that, synthesis works offline in the same browser profile and origin. Chrome, Quark, Xiaomi Browser, private profiles, and different domains cannot share Cache Storage, so each requires its own download. The package replaced separate Piper voices because the Chinese Piper model mispronounced isolated characters in both browser and native inference, while the Matcha model passed product-owner listening checks for Chinese and English words and sentences. The accepted first version has a noticeably mechanical timbre. Shadow Mate does not collect microphone recordings.
“Listen” uses the matching system voice when it is reliable, then falls back to one browser-local `matcha-icefall-zh-en` package for both Mandarin and English. The first fallback downloads and validates the 154.6 MB package from `voice.shadow.wang`; after that, synthesis works offline in the same browser profile and origin. The Android-compatible runtime starts with a 256 MB WebAssembly heap instead of the upstream build's eager 512 MB reservation; browsers that already verified the 149 MB model data reuse it and replace only the 12 MB runtime. Chrome, Quark, Xiaomi Browser, private profiles, and different domains cannot share Cache Storage, so each requires its own download. The package replaced separate Piper voices because the Chinese Piper model mispronounced isolated characters in both browser and native inference, while the Matcha model passed product-owner listening checks for Chinese and English words and sentences. The accepted first version has a noticeably mechanical timbre. Shadow Mate does not collect microphone recordings.

## Acknowledgements

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ wrapper;当前两个 Shadow Mate Edge Function 不依赖应用级共享 helper

### 中英文发音

“听发音”优先使用与中文或英文匹配的设备系统语音。没有可用系统语音的国产 Android(尤其是无 GMS 设备)会切换到浏览器本地 Matcha,首次使用会从 `voice.shadow.wang` CDN 下载一套约 154.6MB 的 `matcha-icefall-zh-en` 中英双语离线包。下载完成后,同一浏览器配置文件和当前域名中的中文、英文都使用这套模型,无需分别下载。
“听发音”优先使用与中文或英文匹配的设备系统语音。没有可用系统语音的国产 Android(尤其是无 GMS 设备)会切换到浏览器本地 Matcha,首次使用会从 `voice.shadow.wang` CDN 下载一套约 154.6MB 的 `matcha-icefall-zh-en` 中英双语离线包。移动兼容运行时将 WebAssembly 起始内存从官方构建预占的 512MB 降为 256MB,并保留按需增长;已校验过 149MB 模型数据的浏览器只需替换约 12MB 运行时。下载完成后,同一浏览器配置文件和当前域名中的中文、英文都使用这套模型,无需分别下载。

缓存受浏览器安全边界限制:Chrome、夸克、小米浏览器、无痕模式及不同域名不能共享,因此需要分别下载。模型已通过“花、雨、风、牛”、对应英文单词及中英文长句试听;当前音色偏机械是已接受、后续再优化的限制。离线合成不会上传录音。

Expand Down
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

## Unreleased — Piper resource release gates

- Fixed the unified package downloading successfully but failing to speak on mobile browsers: the upstream WASM build eagerly reserved a 512 MB shared heap, which could terminate an Android worker after the 149 MB model was unpacked. The mobile-compatible runtime starts at 256 MB and retains memory growth.
- Existing browsers reuse the verified 149 MB model data and fetch only the replacement 12 MB mobile WASM, avoiding a full package download.
- Replaced separate English and Chinese Piper downloads with one 154.6 MB `matcha-icefall-zh-en` offline package powered by the pinned sherpa-onnx v1.13.2 WebAssembly worker.
- Recorded the migration reason: the previous Chinese model mispronounced isolated characters even in native inference, while Matcha passed listening checks for Chinese and English words and sentences. Its mechanical timbre is an accepted current limitation.
- One download serves both languages in the same browser profile and origin; browser security prevents sharing the package across Chrome, Quark, Xiaomi Browser, private profiles, or different domains.
Expand Down
2 changes: 2 additions & 0 deletions RELEASE_NOTES.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

## 未发布 — Piper 语音资源发布门禁

- 修复统一离线语音包在手机浏览器中下载成功却无法发音的问题:官方 WASM 构建会预占 512MB 共享内存,叠加 149MB 模型展开后可能导致 Android Worker 被系统终止;移动兼容构建改为 256MB 起始内存并保留按需增长。
- 已下载的 149MB 模型数据会继续复用,只重新下载约 12MB 的移动兼容 WASM,避免重复消耗流量和空间。
- 将分离的中英文 Piper 下载替换为一套约 154.6MB 的 `matcha-icefall-zh-en` 离线包,并使用固定版本 sherpa-onnx `v1.13.2` WebAssembly Worker;同一浏览器配置文件和域名内一次下载即可朗读两种语言。
- 记录迁移原因:旧中文模型在原生推理中也会错读部分孤立汉字,Kokoro/MeloTTS 未通过试听;Matcha 已通过中英文单字和长句试听,当前接受音色偏机械的限制。
- Chrome、夸克、小米浏览器、无痕模式和不同域名之间受浏览器缓存隔离,不能共享这一次下载。
Expand Down
6 changes: 4 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ active 包 `matcha-icefall-zh-en-1.13.2` 来自 sherpa-onnx `v1.13.2` 的固定

迁移原因不是运输错误:旧 Chaowen 模型即使在原生推理中也会错误朗读部分孤立汉字;Kokoro 出现静音或错误前置音;MeloTTS 未通过孤立汉字试听。`matcha-icefall-zh-en` 已于 2026-09-04 对中文单字“花、雨、风、牛”、对应英文单词及中英文长句完成试听,发音和完整性获确认;音色偏机械是当前明确接受、留待后续优化的限制。

CDN 基础 URL 为 `https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts`:`.data` 为 149,228,019 bytes(SHA-256 `3a00cfc82ddf39a2e798e63fad038e2c56f10aa4b7b952a0c98db758d119c14c`),`.wasm` 为 12,883,754 bytes(SHA-256 `9554feafc2bf4452c3e1f5d5d4b29b690e6e7db1eb3835478a793e864111f640`),清单合计 162,111,773 bytes。发布前运行 `node scripts/piper-resource-smoke.mjs --base-url <上述基础 URL> --package matcha-icefall-zh-en-1.13.2`,逐文件核对响应类型、CORS、字节数和 SHA-256;任一项不匹配都不得写入完成标记。
模型数据 URL 为 `https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.data`,共 149,228,019 bytes(SHA-256 `3a00cfc82ddf39a2e798e63fad038e2c56f10aa4b7b952a0c98db758d119c14c`)。移动兼容 WASM URL 为 `https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm`,共 12,883,754 bytes(SHA-256 `ff161b9927ca92164930fe564476ee32edaf8ec460b94df2353af7333754119e`),清单合计仍为 162,111,773 bytes。发布前运行 `node scripts/piper-resource-smoke.mjs --base-url https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts --package matcha-icefall-zh-en-1.13.2`,逐文件核对响应类型、CORS、字节数和 SHA-256;任一项不匹配都不得写入完成标记。

移动兼容 WASM 由固定的官方 SHA-256 `9554feafc2bf4452c3e1f5d5d4b29b690e6e7db1eb3835478a793e864111f640` 通过 `scripts/patch-sherpa-wasm-memory.mjs` 可复现生成。该脚本只把唯一的 shared-memory import 最低值从 8192 页(512MB)调整为 4096 页(256MB),不改模型、代码段、最大内存或按需增长能力;产物哈希固定为上述 `ff161b...`。原因是官方构建预占 512MB,再叠加 149MB 数据包与模型加载会使部分 Android 浏览器终止 Worker。受限堆桌面原型已完成中英文单字和长句合成;真实手机仍需 Preview 验收。

浏览器缓存受浏览器配置文件和 Origin 隔离。同一浏览器与同一 Shadow Mate 域名下只需下载一次;Chrome、夸克、小米浏览器或不同域名之间不能共享该缓存。

Expand All @@ -66,5 +68,5 @@ CDN 基础 URL 为 `https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-
| `public/piper/piper_phonemize.wasm` | `2189e43490744c95445e251c38a47063f2ca266bcc30bbb18f692c47ff2bfd23` |
| `public/sherpa-onnx/sherpa-onnx-wasm-main-tts.js` | `2f7d50fe6991982a4bcc8dd938d63de6edbb3f2b971e383e8986331ca5fcb311` |
| `public/sherpa-onnx/sherpa-onnx-tts.js` | `d0febb99e78c8322eb7dbda12e90a1b473de8a7d65f016a146576fbdadbf266a` |
| `public/sherpa-onnx/sherpa-onnx-tts.worker.js` | `c9c5bac9cb38e3d658ab0d3d68d6adbf09d9c724d2451372af33633d25814a14` |
| `public/sherpa-onnx/sherpa-onnx-tts.worker.js` | `e6274d02b08ca502ae910cdbd3084cda557ee52a12bc020a96187af43a0dd4ca` |
| `public/brand_assets/shadow-mate-writing-hand.ttf` | `38e6473bcbe4e3d2dba2218eb7235b4b5f7f3f080d75528f8e4d1fc8bc1c4a10` |
20 changes: 20 additions & 0 deletions docs/releases/1.3.12/CONFIGURATION_IMPACT.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@
"validation": "node scripts/piper-resource-smoke.mjs --base-url https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts --package matcha-icefall-zh-en-1.13.2",
"rollback": "Revert the application commit before promoting to production; the versioned CDN objects are immutable and may remain unused."
},
{
"id": "mobile-offline-voice-memory-runtime",
"type": "external_cdn_asset",
"reason": "The upstream WASM eagerly reserves a 512 MB shared heap; together with the 149 MB package expansion this can terminate Android browser workers after a successful download.",
"test": {
"status": "deployed_and_verified",
"url": "https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm",
"bytes": 12883754,
"sha256": "ff161b9927ca92164930fe564476ee32edaf8ec460b94df2353af7333754119e",
"verified_at": "2026-09-04T13:36:00Z",
"initial_memory": "256MB",
"reuse": "The existing verified 149,228,019-byte data file remains unchanged and is reused from the current cache."
},
"live": {
"status": "not_enabled",
"reason": "Production deployment is outside this Preview-only fix and requires separate authorization and mobile acceptance."
},
"validation": "Run the CDN smoke command, restricted-heap Chinese/English word and sentence synthesis, then real-device Preview acceptance.",
"rollback": "Revert the application commit; the original immutable WASM remains at its existing URL."
},
{
"id": "cross-origin-isolation",
"type": "http_response_headers",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ Kokoro was rejected after audition: its quantized build produced silent output i

- Runtime and prebuilt browser package: sherpa-onnx `v1.13.2`, fixed release asset `sherpa-onnx-wasm-simd-1.13.2-matcha-icefall-zh-en.tar.bz2`.
- Model: `matcha-icefall-zh-en`, one speaker, 16 kHz, Chinese and English.
- Package payload: the release's `sherpa-onnx-wasm-main-tts.data` and `sherpa-onnx-wasm-main-tts.wasm`; the JavaScript adapter and worker are vendored from the same fixed release.
- Package payload: the release's unchanged `sherpa-onnx-wasm-main-tts.data` plus a reproducible mobile-compatible derivative of its WASM. The derivative lowers only the shared-memory import minimum from 512 MB to 256 MB; the JavaScript adapter and worker remain pinned to the same release lineage.
- Distribution: large immutable payloads are served from `https://voice.shadow.wang/`; small JavaScript adapters are served with the application.
- Integrity: every CDN payload has an exact byte size and SHA-256 in the resource registry. A package is `completed` only after all required files pass validation and the completion manifest is persisted.

The first Preview build used the upstream WASM's eager 512 MB shared heap. On Android, that reservation plus the 149 MB package expansion could terminate the worker after a successful download. The replacement is generated by `scripts/patch-sherpa-wasm-memory.mjs`, has a separate immutable URL and fingerprint, starts at 256 MB, and retains Emscripten memory growth. Existing verified model data remains at its original URL so affected browsers only replace the 12 MB runtime.

The UI describes this as one "离线中英文语音包" even though the implementation contains a model data bundle, a WASM runtime, and adapter files. Its displayed size comes from the registry and must not use an outdated estimate.

## Browser storage boundary
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@

打开“设置 → 无障碍 → 文字转语音输出”,选择可用引擎和 English,然后进入“安装语音数据”下载英语语音。不同品牌的菜单名称可能略有不同。

> 国产 Android(无 Google 服务)可能没有可用的系统语音。系统发音不可用时,影伴会提供一套约 154.6 MB 的中英双语 Matcha 离线包。下载弹窗会从注册表显示名称、版本、清单大小和当前 Origin;下载只保存在当前浏览器配置文件与当前域名,之后可离线合成,不上传录音。
> 国产 Android(无 Google 服务)可能没有可用的系统语音。系统发音不可用时,影伴会提供一套约 154.6 MB 的中英双语 Matcha 离线包。移动兼容运行时使用 256MB 起始内存并保留按需增长;已经下载旧运行时的浏览器会复用 149MB 模型数据,只补下约 12MB 新运行时。下载弹窗会从注册表显示名称、版本、清单大小和当前 Origin;下载只保存在当前浏览器配置文件与当前域名,之后可离线合成,不上传录音。

下载离线语音时,弹窗会按注册表总字节数和已接收字节显示百分比;下载过程中可以点击“取消”。已经完整校验的文件会直接复用,缺失文件才重新请求;单个文件中断后仍从该文件开头重下,不提供字节级断点续传。同一浏览器配置文件、同一域名和同一语音版本只保留一份下载任务;多个标签页同时点击时会互斥,等待当前任务结束后刷新状态即可,不会因此产生第二份完整语音包。使用指南中的“离线语音包”可查看当前浏览器的状态与实际已校验大小;部分缓存或校验失败时既可继续/重试,也可明确删除。应用更新时,只有在能安全确认旧语音缓存未被其他标签页使用时才会自动清理;无法确认时会保留旧缓存,避免打断正在使用的语音。

Expand Down
5 changes: 5 additions & 0 deletions public/sherpa-onnx/sherpa-onnx-tts.worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ self.onmessage = async ({ data: message }) => {
wasmResponse.arrayBuffer(),
]);
const Module = await createModule({
// The upstream browser bundle defaults to a 512 MiB shared heap. That
// eager allocation can terminate Android browser workers before the
// model is initialized. Start smaller and let Emscripten's configured
// ALLOW_MEMORY_GROWTH expand the heap only when inference needs it.
INITIAL_MEMORY: 256 * 1024 * 1024,
getPreloadedPackage: () => dataBuffer,
wasmBinary: new Uint8Array(wasmBuffer),
setStatus: () => {},
Expand Down
66 changes: 66 additions & 0 deletions scripts/patch-sherpa-wasm-memory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

const SOURCE_SHA256 = "9554feafc2bf4452c3e1f5d5d4b29b690e6e7db1eb3835478a793e864111f640";
const OUTPUT_SHA256 = "ff161b9927ca92164930fe564476ee32edaf8ec460b94df2353af7333754119e";

function encodeUnsignedLeb128(value) {
const bytes = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining = Math.floor(remaining / 128);
if (remaining) byte |= 0x80;
bytes.push(byte);
} while (remaining);
return bytes;
}

function indexOfAll(bytes, pattern) {
const matches = [];
for (let index = 0; index <= bytes.length - pattern.length; index += 1) {
if (pattern.every((byte, offset) => bytes[index + offset] === byte)) matches.push(index);
}
return matches;
}

export function patchWasmInitialMemory(source, {
fromPages = 8192,
toPages = 4096,
maximumPages = 32768,
} = {}) {
const input = source instanceof Uint8Array ? source : new Uint8Array(source);
const from = Uint8Array.from([3, ...encodeUnsignedLeb128(fromPages), ...encodeUnsignedLeb128(maximumPages)]);
const to = Uint8Array.from([3, ...encodeUnsignedLeb128(toPages), ...encodeUnsignedLeb128(maximumPages)]);
if (from.length !== to.length) throw new Error("WASM memory patch must preserve the binary length");
const matches = indexOfAll(input, from);
if (matches.length !== 1) throw new Error(`Expected the WASM shared-memory declaration exactly once; found ${matches.length}`);
const output = new Uint8Array(input);
output.set(to, matches[0]);
return output;
}

function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}

async function main([sourcePath, outputPath]) {
if (!sourcePath || !outputPath) throw new Error("Usage: node scripts/patch-sherpa-wasm-memory.mjs <official.wasm> <mobile.wasm>");
const source = new Uint8Array(await readFile(sourcePath));
const sourceHash = sha256(source);
if (sourceHash !== SOURCE_SHA256) throw new Error(`Official WASM SHA-256 mismatch: ${sourceHash}`);
const output = patchWasmInitialMemory(source);
const outputHash = sha256(output);
if (outputHash !== OUTPUT_SHA256) throw new Error(`Patched WASM SHA-256 mismatch: ${outputHash}`);
await writeFile(outputPath, output);
console.log(JSON.stringify({ source: sourceHash, output: outputHash, bytes: output.byteLength }));
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
3 changes: 2 additions & 1 deletion scripts/piper-resource-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getPiperResourcePackage,
isActivePiperCdnVoicePackage,
listActivePiperCdnVoicePackages,
resolvePiperResourceFileUrl,
} from "../src/piper-resource-registry.js";

const REQUIRED_CORS_METHODS = ["GET", "HEAD", "OPTIONS"];
Expand Down Expand Up @@ -79,7 +80,7 @@ export function assertCors(response, phase, url) {
}

async function inspectFile(resourcePackage, file) {
const url = `${resourcePackage.baseUrl}${file.suffix}`;
const url = resolvePiperResourceFileUrl(resourcePackage, file);
const requestOptions = { headers: { Origin: "https://preview-sm.shadow.wang" } };
const head = await fetch(url, { ...requestOptions, method: "HEAD" });
assertSuccess(head, "HEAD", url);
Expand Down
Loading
Loading