diff --git a/CHANGELOG.md b/CHANGELOG.md index 515e574..79f432d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 校验;失败不会写入完成标记,可安全重试。 - 明确语音缓存按浏览器配置文件、域名和资源版本隔离,避免把更换浏览器或环境后的再次下载误判为重复下载。 diff --git a/README.md b/README.md index 0db259f..274f3bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 9b05fe1..9d1d040 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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、夸克、小米浏览器、无痕模式及不同域名不能共享,因此需要分别下载。模型已通过“花、雨、风、牛”、对应英文单词及中英文长句试听;当前音色偏机械是已接受、后续再优化的限制。离线合成不会上传录音。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 80e45de..d60be9c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/RELEASE_NOTES.zh-CN.md b/RELEASE_NOTES.zh-CN.md index 286652d..afac8bd 100644 --- a/RELEASE_NOTES.zh-CN.md +++ b/RELEASE_NOTES.zh-CN.md @@ -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、夸克、小米浏览器、无痕模式和不同域名之间受浏览器缓存隔离,不能共享这一次下载。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a8bf898..3564160 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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、夸克、小米浏览器或不同域名之间不能共享该缓存。 @@ -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` | diff --git a/docs/releases/1.3.12/CONFIGURATION_IMPACT.json b/docs/releases/1.3.12/CONFIGURATION_IMPACT.json index 73bbd6e..909cff6 100644 --- a/docs/releases/1.3.12/CONFIGURATION_IMPACT.json +++ b/docs/releases/1.3.12/CONFIGURATION_IMPACT.json @@ -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", diff --git a/docs/superpowers/specs/2026-09-04-unified-offline-voice-design.md b/docs/superpowers/specs/2026-09-04-unified-offline-voice-design.md index a829f76..65ae6f3 100644 --- a/docs/superpowers/specs/2026-09-04-unified-offline-voice-design.md +++ b/docs/superpowers/specs/2026-09-04-unified-offline-voice-design.md @@ -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 diff --git a/docs/user-guide.md b/docs/user-guide.md index 939fa83..c34992f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -52,7 +52,7 @@ 打开“设置 → 无障碍 → 文字转语音输出”,选择可用引擎和 English,然后进入“安装语音数据”下载英语语音。不同品牌的菜单名称可能略有不同。 -> 国产 Android(无 Google 服务)可能没有可用的系统语音。系统发音不可用时,影伴会提供一套约 154.6 MB 的中英双语 Matcha 离线包。下载弹窗会从注册表显示名称、版本、清单大小和当前 Origin;下载只保存在当前浏览器配置文件与当前域名,之后可离线合成,不上传录音。 +> 国产 Android(无 Google 服务)可能没有可用的系统语音。系统发音不可用时,影伴会提供一套约 154.6 MB 的中英双语 Matcha 离线包。移动兼容运行时使用 256MB 起始内存并保留按需增长;已经下载旧运行时的浏览器会复用 149MB 模型数据,只补下约 12MB 新运行时。下载弹窗会从注册表显示名称、版本、清单大小和当前 Origin;下载只保存在当前浏览器配置文件与当前域名,之后可离线合成,不上传录音。 下载离线语音时,弹窗会按注册表总字节数和已接收字节显示百分比;下载过程中可以点击“取消”。已经完整校验的文件会直接复用,缺失文件才重新请求;单个文件中断后仍从该文件开头重下,不提供字节级断点续传。同一浏览器配置文件、同一域名和同一语音版本只保留一份下载任务;多个标签页同时点击时会互斥,等待当前任务结束后刷新状态即可,不会因此产生第二份完整语音包。使用指南中的“离线语音包”可查看当前浏览器的状态与实际已校验大小;部分缓存或校验失败时既可继续/重试,也可明确删除。应用更新时,只有在能安全确认旧语音缓存未被其他标签页使用时才会自动清理;无法确认时会保留旧缓存,避免打断正在使用的语音。 diff --git a/public/sherpa-onnx/sherpa-onnx-tts.worker.js b/public/sherpa-onnx/sherpa-onnx-tts.worker.js index c9ef9fc..d20d0ac 100644 --- a/public/sherpa-onnx/sherpa-onnx-tts.worker.js +++ b/public/sherpa-onnx/sherpa-onnx-tts.worker.js @@ -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: () => {}, diff --git a/scripts/patch-sherpa-wasm-memory.mjs b/scripts/patch-sherpa-wasm-memory.mjs new file mode 100644 index 0000000..de14679 --- /dev/null +++ b/scripts/patch-sherpa-wasm-memory.mjs @@ -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 "); + 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; + }); +} diff --git a/scripts/piper-resource-smoke.mjs b/scripts/piper-resource-smoke.mjs index 9b2e006..361fa32 100644 --- a/scripts/piper-resource-smoke.mjs +++ b/scripts/piper-resource-smoke.mjs @@ -5,6 +5,7 @@ import { getPiperResourcePackage, isActivePiperCdnVoicePackage, listActivePiperCdnVoicePackages, + resolvePiperResourceFileUrl, } from "../src/piper-resource-registry.js"; const REQUIRED_CORS_METHODS = ["GET", "HEAD", "OPTIONS"]; @@ -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); diff --git a/src/piper-resource-download.js b/src/piper-resource-download.js index f57f220..5f0ec00 100644 --- a/src/piper-resource-download.js +++ b/src/piper-resource-download.js @@ -1,5 +1,9 @@ import { createPiperResourceSha256 } from "./piper-resource-hash.js"; -import { getPiperResourcePackage, isActivePiperCdnVoicePackage } from "./piper-resource-registry.js"; +import { + getPiperResourcePackage, + isActivePiperCdnVoicePackage, + resolvePiperResourceFileUrl, +} from "./piper-resource-registry.js"; import { createPiperResourceStore, isPiperResourceCached } from "./piper-resource-store.js"; const DEFAULT_TIMEOUTS = Object.freeze({ head: 10_000, response: 20_000, read: 30_000 }); @@ -31,7 +35,7 @@ function absoluteUrl(url) { } function fileUrl(resourcePackage, file) { - return absoluteUrl(`${resourcePackage.baseUrl}${file.suffix}`); + return absoluteUrl(resolvePiperResourceFileUrl(resourcePackage, file)); } function stagingUrl(url, commitId) { diff --git a/src/piper-resource-registry.js b/src/piper-resource-registry.js index 4069ab6..0c46a16 100644 --- a/src/piper-resource-registry.js +++ b/src/piper-resource-registry.js @@ -225,9 +225,10 @@ const UNIFIED_MATCHA_RESOURCE = { { key: "wasm", suffix: ".wasm", + url: "https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm", contentType: "application/wasm", bytes: 12883754, - sha256: "9554feafc2bf4452c3e1f5d5d4b29b690e6e7db1eb3835478a793e864111f640", + sha256: "ff161b9927ca92164930fe564476ee32edaf8ec460b94df2353af7333754119e", }, ], }; @@ -241,7 +242,7 @@ const BUNDLED_PIPER_RUNTIME = { baseUrl: null, source: "bundled", cachePolicy: "app-shell", - totalBytes: 76786160, + totalBytes: 76786515, releaseApproved: true, licenseStatus: "reviewed", license: { @@ -320,8 +321,8 @@ const BUNDLED_PIPER_RUNTIME = { key: "sherpa-onnx-worker", url: "/sherpa-onnx/sherpa-onnx-tts.worker.js", contentType: "application/javascript", - bytes: 1702, - sha256: "c9c5bac9cb38e3d658ab0d3d68d6adbf09d9c724d2451372af33633d25814a14", + bytes: 2057, + sha256: "e6274d02b08ca502ae910cdbd3084cda557ee52a12bc020a96187af43a0dd4ca", license: "Apache-2.0 with Shadow Mate integration changes", provenance: "docs/superpowers/specs/2026-09-04-unified-offline-voice-design.md", }, @@ -342,6 +343,13 @@ export function isActivePiperCdnVoicePackage(resourcePackage) { && resourcePackage.cachePolicy === "user-download"; } +export function resolvePiperResourceFileUrl(resourcePackage, file) { + return new URL( + file?.url || `${resourcePackage?.baseUrl || ""}${file?.suffix || ""}`, + globalThis.location?.href || "https://shadow-mate.invalid/", + ).href; +} + function validateApprovedCdnMetadata(resourcePackage) { if (resourcePackage.licenseStatus !== "approved" || resourcePackage.license?.status !== "approved" @@ -423,7 +431,9 @@ export function validatePiperResourcePackages(packages) { let totalBytes = 0; const fileKeys = new Set(); for (const file of resourcePackage.files) { - const hasLocation = isBundled ? /^\/.+/.test(file?.url || "") : Boolean(file?.suffix); + const hasLocation = isBundled + ? /^\/.+/.test(file?.url || "") + : Boolean(file?.suffix) && (!file.url || /^https:\/\//.test(file.url)); if (!file?.key || fileKeys.has(file.key) || !hasLocation || !file.contentType) { throw new Error(`Active Piper package ${resourcePackage.id} has an invalid file entry`); } diff --git a/src/piper-resource-store.js b/src/piper-resource-store.js index 033feb3..52af4a8 100644 --- a/src/piper-resource-store.js +++ b/src/piper-resource-store.js @@ -3,6 +3,7 @@ import { createPiperResourceSha256 } from "./piper-resource-hash.js"; import { isActivePiperCdnVoicePackage, listPiperResourcePackages, + resolvePiperResourceFileUrl, } from "./piper-resource-registry.js"; const LEGACY_ENGLISH_CACHE = "shadow-mate-voice"; @@ -52,7 +53,7 @@ function fileMarkerKey(resourcePackage, file, commitId = null) { } function fileUrl(resourcePackage, file) { - return normalizedUrl(`${resourcePackage.baseUrl}${file.suffix}`); + return normalizedUrl(resolvePiperResourceFileUrl(resourcePackage, file)); } async function responseIntegrity(response, { canCommit = null } = {}) { diff --git a/src/piper-tts.js b/src/piper-tts.js index 87648ac..7b09815 100644 --- a/src/piper-tts.js +++ b/src/piper-tts.js @@ -11,6 +11,7 @@ import { formatPiperResourceBytes, getPiperResourcePackage, isActivePiperCdnVoicePackage, + resolvePiperResourceFileUrl, UNIFIED_OFFLINE_VOICE_PACKAGE_ID, } from "./piper-resource-registry.js"; import { deletePiperResource, getPiperResourceStatus } from "./piper-resource-store.js"; @@ -25,7 +26,7 @@ export const SYNTHESIS_TIMEOUT_MS = 30_000; const englishPackage = getPiperResourcePackage(ENGLISH_PIPER_PACKAGE_ID); // Compatibility exports for existing callers. New code must use package IDs. export const VOICE = englishPackage.baseUrl; -export const VOICE_FILES = englishPackage.files.map((file) => `${englishPackage.baseUrl}${file.suffix}`); +export const VOICE_FILES = englishPackage.files.map((file) => resolvePiperResourceFileUrl(englishPackage, file)); export class PiperLocalVoiceError extends Error { constructor(code, message, cause) { @@ -58,8 +59,10 @@ function normalizedUrl(url) { return new URL(url, globalThis.location?.href || "https://shadow-mate.invalid/").href; } -function fileUrl(resourcePackage, suffix) { - return normalizedUrl(`${resourcePackage.baseUrl}${suffix}`); +function fileUrl(resourcePackage, key) { + const file = resourcePackage.files.find((entry) => entry.key === key); + if (!file) throw new PiperLocalVoiceError("invalid", `Offline voice package is missing ${key}`); + return normalizedUrl(resolvePiperResourceFileUrl(resourcePackage, file)); } function packageCacheName(resourcePackage) { @@ -112,8 +115,8 @@ async function createEngineSession() { session.engine = createMatchaTtsRuntime(); await session.engine.prepare({ cacheName: packageCacheName(resourcePackage), - dataUrl: fileUrl(resourcePackage, ".data"), - wasmUrl: fileUrl(resourcePackage, ".wasm"), + dataUrl: fileUrl(resourcePackage, "data"), + wasmUrl: fileUrl(resourcePackage, "wasm"), }); return session; } diff --git a/tests/unit/patch-sherpa-wasm-memory.test.js b/tests/unit/patch-sherpa-wasm-memory.test.js new file mode 100644 index 0000000..448877c --- /dev/null +++ b/tests/unit/patch-sherpa-wasm-memory.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { patchWasmInitialMemory } from "../../scripts/patch-sherpa-wasm-memory.mjs"; + +describe("sherpa-onnx mobile WASM memory patch", () => { + it("replaces the unique shared-memory minimum while preserving the binary length", () => { + const source = Uint8Array.from([0, 97, 3, 0x80, 0x40, 0x80, 0x80, 0x02, 98]); + const patched = patchWasmInitialMemory(source, { fromPages: 8192, toPages: 4096, maximumPages: 32768 }); + + expect([...patched]).toEqual([0, 97, 3, 0x80, 0x20, 0x80, 0x80, 0x02, 98]); + expect(patched).toHaveLength(source.length); + expect([...source]).toEqual([0, 97, 3, 0x80, 0x40, 0x80, 0x80, 0x02, 98]); + }); + + it("fails closed when the expected memory declaration is missing or ambiguous", () => { + expect(() => patchWasmInitialMemory(Uint8Array.from([0, 1, 2]))).toThrow(/exactly once/i); + const duplicated = Uint8Array.from([ + 3, 0x80, 0x40, 0x80, 0x80, 0x02, + 3, 0x80, 0x40, 0x80, 0x80, 0x02, + ]); + expect(() => patchWasmInitialMemory(duplicated)).toThrow(/exactly once/i); + }); +}); diff --git a/tests/unit/piper-resource-download.test.js b/tests/unit/piper-resource-download.test.js index 1ae250d..a8d5cdd 100644 --- a/tests/unit/piper-resource-download.test.js +++ b/tests/unit/piper-resource-download.test.js @@ -96,7 +96,7 @@ function headers(contentLength, contentType = "application/octet-stream") { function createFetch({ package: resourcePackage, streamOptions = {}, getContentLengths = {}, contentTypes = {}, contents = {}, onFetch } = {}) { return vi.fn((url, options = {}) => { onFetch?.(url, options); - const file = resourcePackage.files.find((entry) => url.endsWith(entry.suffix)); + const file = resourcePackage.files.find((entry) => entry.url ? url === entry.url : url.endsWith(entry.suffix)); const contentType = contentTypes[file.key] || file.contentType; if (options.method === "HEAD") return Promise.resolve(new Response(null, { status: 200, headers: headers(file.bytes, contentType) })); const content = contents[file.key] ?? (file.key === "model" ? ["abc"] : ["{}"]); @@ -266,6 +266,24 @@ describe("Piper resource downloader", () => { await expect(setup.store.isPiperResourceCached(setup.resourcePackage.id)).resolves.toBe(true); }); + it("reuses an unchanged large file and fetches only a replacement file at its immutable URL", async () => { + const resourcePackage = createPackage(); + resourcePackage.files[1].url = "https://voice.example.test/mobile-v2/test-voice.onnx.json"; + const setup = createDownloader({ resourcePackage }); + const cache = await packageCache(setup); + await cache.put(`${resourcePackage.baseUrl}.onnx`, new Response("abc")); + await setup.store.writeFileCompletionMarker(resourcePackage, resourcePackage.files[0], { + bytes: 3, + sha256: HASHES.model, + }, { cache }); + + await expect(setup.downloader.downloadPiperResource(resourcePackage.id)).resolves.toMatchObject({ status: "completed" }); + expect(setup.fetch).toHaveBeenCalledTimes(2); + expect(setup.fetch.mock.calls.every(([url]) => url === resourcePackage.files[1].url)).toBe(true); + await expect(cache.match(`${resourcePackage.baseUrl}.onnx`)).resolves.toBeTruthy(); + await expect(cache.match(resourcePackage.files[1].url)).resolves.toBeTruthy(); + }); + it("does not write a completion marker when lease ownership is lost after files cache", async () => { let ownsLease = true; const cacheStorage = createCacheStorage({ diff --git a/tests/unit/piper-resource-registry.test.js b/tests/unit/piper-resource-registry.test.js index 43cc7b9..9b366ac 100644 --- a/tests/unit/piper-resource-registry.test.js +++ b/tests/unit/piper-resource-registry.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getPiperResourcePackage, isActivePiperCdnVoicePackage, + resolvePiperResourceFileUrl, validatePiperResourcePackages, } from "../../src/piper-resource-registry.js"; @@ -12,7 +13,7 @@ const RUNTIME_FILES = [ ["/piper/piper_phonemize.data", 18077249, "a9879123581336fc36ae3706ae81c9e67becc388b80b8a4943cef2a78542e6aa"], ["/sherpa-onnx/sherpa-onnx-wasm-main-tts.js", 143833, "2f7d50fe6991982a4bcc8dd938d63de6edbb3f2b971e383e8986331ca5fcb311"], ["/sherpa-onnx/sherpa-onnx-tts.js", 32010, "d0febb99e78c8322eb7dbda12e90a1b473de8a7d65f016a146576fbdadbf266a"], - ["/sherpa-onnx/sherpa-onnx-tts.worker.js", 1702, "c9c5bac9cb38e3d658ab0d3d68d6adbf09d9c724d2451372af33633d25814a14"], + ["/sherpa-onnx/sherpa-onnx-tts.worker.js", 2057, "e6274d02b08ca502ae910cdbd3084cda557ee52a12bc020a96187af43a0dd4ca"], ]; function bundledRuntimeFixture() { @@ -53,14 +54,23 @@ describe("bundled Piper runtime registry", () => { provenance: { status: "verified" }, distribution: { status: "approved", channel: "public-cdn" }, }); - expect(voice.files.map(({ suffix, bytes, sha256 }) => [suffix, bytes, sha256])).toEqual([ - [".data", 149228019, "3a00cfc82ddf39a2e798e63fad038e2c56f10aa4b7b952a0c98db758d119c14c"], - [".wasm", 12883754, "9554feafc2bf4452c3e1f5d5d4b29b690e6e7db1eb3835478a793e864111f640"], + expect(voice.files.map(({ suffix, url, bytes, sha256 }) => [suffix, url, bytes, sha256])).toEqual([ + [".data", undefined, 149228019, "3a00cfc82ddf39a2e798e63fad038e2c56f10aa4b7b952a0c98db758d119c14c"], + [".wasm", "https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm", 12883754, "ff161b9927ca92164930fe564476ee32edaf8ec460b94df2353af7333754119e"], ]); expect(isActivePiperCdnVoicePackage(getPiperResourcePackage("zh_CN-chaowen-medium"))).toBe(false); expect(isActivePiperCdnVoicePackage(getPiperResourcePackage("en_US-ljspeech-medium"))).toBe(false); }); + it("resolves an immutable per-file URL without moving unchanged package files", () => { + const voice = structuredClone(getPiperResourcePackage("matcha-icefall-zh-en-1.13.2")); + const [data, wasm] = voice.files; + wasm.url = "https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm"; + + expect(resolvePiperResourceFileUrl(voice, data)).toBe(`${voice.baseUrl}.data`); + expect(resolvePiperResourceFileUrl(voice, wasm)).toBe(wasm.url); + }); + it("rejects a bundled runtime component without a fixed source commit", () => { const runtime = bundledRuntimeFixture(); delete runtime.audit?.components?.[0]?.commit; diff --git a/tests/unit/piper-resource-store.test.js b/tests/unit/piper-resource-store.test.js index e84e50b..443d971 100644 --- a/tests/unit/piper-resource-store.test.js +++ b/tests/unit/piper-resource-store.test.js @@ -107,7 +107,7 @@ describe("Piper resource registry", () => { expect(listPiperResourcePackages().find((entry) => entry.id === "piper-browser-runtime")).toMatchObject({ source: "bundled", cachePolicy: "app-shell", - totalBytes: 76786160, + totalBytes: 76786515, files: expect.arrayContaining([ expect.objectContaining({ url: "/piper-tts-web.js", bytes: 46656168, license: expect.any(String) }), expect.objectContaining({ url: "/onnx/ort-wasm-simd-threaded.wasm", bytes: 11246032 }), diff --git a/tests/unit/piper-tts.test.js b/tests/unit/piper-tts.test.js index 663bc0f..b400a37 100644 --- a/tests/unit/piper-tts.test.js +++ b/tests/unit/piper-tts.test.js @@ -20,7 +20,7 @@ describe("offline Piper voice packages", () => { expect(VOICE).toBe("https://voice.shadow.wang/sherpa-onnx/1.13.2/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts"); expect(VOICE_FILES).toEqual([ `${VOICE}.data`, - `${VOICE}.wasm`, + "https://voice.shadow.wang/sherpa-onnx/1.13.2-mobile-256/matcha-icefall-zh-en/sherpa-onnx-wasm-main-tts.wasm", ]); }); @@ -55,6 +55,7 @@ describe("offline Piper voice packages", () => { getPiperResourcePackage: (packageId) => packageId === englishPackage.id ? englishPackage : resourcePackage, listPiperResourcePackages: () => [englishPackage, resourcePackage], isActivePiperCdnVoicePackage: () => true, + resolvePiperResourceFileUrl: (pkg, file) => file.url || `${pkg.baseUrl}${file.suffix}`, formatPiperResourceBytes: () => "1.0 MB", UNIFIED_OFFLINE_VOICE_PACKAGE_ID: englishPackage.id, }));