创建日期:2026-10-13 | 最近更新:2026-10-13 事实核对基于 sherpa-onnx 1.13.8。浏览器侧为 Chrome 149 headless 真实执行官方 release 产物(不是"参考写法"),本地服务器 +
--dump-dom驱动;Node 侧为 node v24.19.0、Linux x64。脚本与原始日志在source/sherpa-onnx-lab/,浏览器实测可bash browser/run-browser-test.sh复现。
sherpa-onnx 101(第 5 篇 · 前端):浏览器里怎么跑,代价是多少
前四篇都在 Node 里。这一篇回到前端的主场:浏览器里到底能不能跑、怎么跑、要付多少体积和性能的代价。
结论先摆一句:能跑,而且和 Node 版是同一份 C++ 代码编出来的 WASM,结果逐毫秒一致——但官方给浏览器的构建方式和 npm 上那个 sherpa-onnx 包不是一回事,这是最容易搞混的地方。
0. 先给结论
| # | 结论 | 证据 |
|---|---|---|
| 1 | 官方给浏览器的路是编译期把模型打进 .data:产物是 .wasm + .data + .js + index.html,解包后直接静态托管就能跑 | §1 |
| 2 | .data 里就是模型文件本身:我逐个字节比对过,VAD 的 .data = 1076 字节的说明文件 + 完整的 silero_vad.onnx(偏移 1076,长度完全一致) | §1.2 |
| 3 | 光引擎(VAD 版)就是 .wasm 12.0 MB + .data 0.6 MB;换成识别模型,.data 会跟着涨到几十上百 MB | §1.2 |
| 4 | 真实浏览器实测通过:无头 Chrome 对同一段 10.05 秒音频做 VAD,切出 0.678 / 3.814 / 6.534 秒 三段,与 Node 原生 addon 的结果逐毫秒完全一致 | §2 |
| 5 | WASM 版是单线程,同一模型同一音频解码慢 3.4~3.5 倍(两次独立运行 3.41 / 3.53);文本输出还不完全一致 | §3 |
| 6 | npm 上的 sherpa-onnx 包是给 Node 用的 WASM 包,不是浏览器包;浏览器要另拿 release 里的编译产物 | §4 |
| 7 | 现实里更常见的做法是不把大模型放浏览器:浏览器只做采集与播放,识别放本机服务端用原生 addon(第 0 篇那条路) | §6 |
1. 官方给浏览器的路:编译期把模型焊进去
1.1 构建方式
看官方的 CI 配置(.github/workflows/wasm-simd-hf-space-zh-en-asr-zipformer.yaml,留档在 official-sources/)就清楚它在干什么:
- name: 下载模型
run: |
tar xvf sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2
mv .../encoder-epoch-99-avg-1.int8.onnx encoder.onnx
mv .../decoder-epoch-99-avg-1.onnx decoder.onnx
mv .../joiner-epoch-99-avg-1.int8.onnx joiner.onnx
mv .../tokens.txt ./
- name: Build sherpa-onnx for WebAssembly (ASR)
run: ./build-wasm-simd-asr.sh
- name: collect files
run: |
dst=sherpa-onnx-wasm-simd-${SHERPA_ONNX_VERSION}-zh-en-asr-zipformer
mv build-wasm-simd-asr/install/bin/wasm/asr $dst
tar cjfv ${dst}.tar.bz2 ./${dst}
关键点:模型文件名被固定成 encoder.onnx / decoder.onnx / joiner.onnx / tokens.txt,然后整个目录被 --preload-file 打进 .data。 也就是说——换模型 = 重新编译一次。这不是"加载模型",是"把模型编进程序"。
1.2 产物长什么样
我把官方 release 里的 VAD 产物(sherpa-onnx-wasm-simd-v1.13.8-vad.tar.bz2,3.5 MB)解开看了:
sherpa-onnx-wasm-simd-v1.13.8-vad/
├── sherpa-onnx-wasm-main-vad.wasm 12,568,849 B 引擎
├── sherpa-onnx-wasm-main-vad.data 644,930 B 预载的文件
├── sherpa-onnx-wasm-main-vad.js 82,635 B Emscripten 胶水 + 加载器
├── sherpa-onnx-vad.js 7,772 B 官方封装的 createVad
├── app-vad.js 12,230 B 官方 demo 的业务代码
└── index.html 3,604 B 官方 demo 页面
.data 里装的就是模型。 我逐字节验证过:
.data 大小: 644930 silero_vad.onnx 大小: 643854
模型字节在 .data 中的偏移: 1076
前面 1076 字节是 README.md 和 LICENSE 的文本,从偏移 1076 开始就是一份字节完全相同的 silero_vad.onnx。所以体积账很好算:
| 你要放的能力 | 大概要下多少 |
|---|---|
| 引擎(不变) | .wasm 约 12 MB |
| VAD | .data +0.6 MB |
| 唤醒词 | .data + 几 MB |
| 中英双语流式识别 | .data + 一两百 MB |
| Kokoro TTS | .data + 三四百 MB ⚠️ |
.wasm 那 12 MB 是逃不掉的固定成本(无论你用哪个模型)。这一点和 npm 的 Node WASM 包(单个 15 MB .wasm)量级一致。
2. 真实浏览器实测:和 Node 逐毫秒一致
官方 demo 是配合麦克风用的,我要的是可复现的对照。所以我写了一个自己的页面(browser/index.html):从 fetch 读一段 16 kHz 单声道 wav,手工解析 PCM(不用 AudioContext,避免浏览器偷偷改采样率),按 512 样本一窗喂给官方的 createVad。
用无头 Chrome 跑:
python3 -m http.server 33911 --bind 127.0.0.1 &
google-chrome --headless=new --disable-gpu --no-sandbox \
--virtual-time-budget=60000 --dump-dom \
http://127.0.0.1:33911/index.html
结果:
音频: test-16k.wav 16000 Hz 1 ch 16 bit 10.053 s
windowSize=512 (32 ms)
切出 3 段
段 0: start=0.678s dur=1.606s
段 1: start=3.814s dur=2.118s
段 2: start=6.534s dur=3.366s
同一份 wav、同一份模型,用 Node 的原生 addon:
VAD 切出段数 : 3
段 0: start=0.678s dur=1.606s
段 1: start=3.814s dur=2.118s
段 2: start=6.534s dur=3.366s
三个切点一模一样,连毫秒都不差。 这验证了两件事:
- 浏览器里跑的是同一套 C++ 逻辑,不是某个简化实现;
- 参数不需要为浏览器重新调——
threshold: 0.5、minSilenceDuration: 0.5在两端是同一个语义。
2.1 一个诚实的说明:我没测出浏览器里的耗时
页面上那行 耗时 0.0 ms (RTF 0.0000) 是假的——headless 的 --virtual-time-budget 会冻结虚拟时钟,performance.now() 在纯 CPU 计算期间不推进。我拿不到可信的浏览器端 RTF,所以这一篇里所有性能数字都来自 Node 侧的实测,不能直接当作浏览器里的耗时代入。
不过有一条能间接推断:Chromium 和 Node 跑的是同一个 V8,WASM 那部分的执行效率差异主要来自是否启用 SIMD/多线程,而不是宿主。
3. WASM 版到底慢多少
这个数字在第 0 篇出现过,这里展开说清楚——因为**"浏览器里慢不慢"最终由它决定**:
| 实现 | 加载模型 | 解码 10.05 s 音频 | RTF |
|---|---|---|---|
原生 addon(numThreads: 2) | 0.923 s | 0.852 s | 0.085 |
WebAssembly(sherpa-onnx npm 包) | 1.059 s | 3.010 s | 0.299 |
慢 3.53 倍(这次运行;上一次跑出的是 3.41——同一台机器同一模型,波动来自调度与缓存状态)。三点补充:
- 模型加载时间几乎一样(0.92 s 对 1.06 s)——瓶颈不在 I/O,在解码的算力。
- 这个 WASM 版本是带 SIMD 的:npm 包的 CI 脚本叫
build-wasm-simd-nodejs.sh。所以这三倍多的差距主要来自单线程(WASM 版不支持numThreads,配置里根本没这个字段)。 - 文本输出还不一致:同样是 2023 双语模型,原生版给出
LIBRAR,WASM 版给出LIBR。浮点累加顺序不同会改变 beam search 的胜负,这是正常现象——但也意味着换实现时要做一轮回归,不能假定输出相同。
顺带记住 API 的两处差异(第 0 篇提过,这里给完整对照):
// 原生 addon
stream.acceptWaveform({ sampleRate, samples });
// WASM:位置参数,顺序是 (采样率, 样本)
stream.acceptWaveform(sampleRate, samples);
// WASM 还要手动释放
stream.free();
recognizer.free();
4. 别把 npm 的 sherpa-onnx 当浏览器包
这是最容易踩的认知坑。npm 上那个 sherpa-onnx 包,README 写的是"for examples refer to nodejs-examples"——它是给 Node 用的 WASM 包。
证据在包里的加载器上:sherpa-onnx-wasm-nodejs.js(名字就带着 nodejs)里既有 ENVIRONMENT_IS_NODE 也有 ENVIRONMENT_IS_WEB 分支,并且在 Node 下用 NODEFS 把宿主文件系统挂进来:
$ grep -oE "ENVIRONMENT_IS_[A-Z]+" sherpa-onnx-wasm-nodejs.js | sort -u
ENVIRONMENT_IS_NODE
ENVIRONMENT_IS_WEB
ENVIRONMENT_IS_WORKER
$ grep -o "NODEFS" sherpa-onnx-wasm-nodejs.js | wc -l
(多处)
正因为挂了宿主文件系统,它在 Node 里才能直接 encoder: './encoder.onnx' 这样按路径读模型。 浏览器里没有这个文件系统,模型必须靠 .data 预载(§1)或者你自己写进 MEMFS。
所以选包的口诀:
| 目标 | 用什么 |
|---|---|
| Node / Electron 主进程 | sherpa-onnx-node(原生 addon,多线程) |
| Node 里想避开原生依赖(如某些 Serverless) | sherpa-onnx(WASM,单线程) |
| 浏览器 | 官方 release 里 sherpa-onnx-wasm-simd-* 的编译产物(自己编或直接下现成的) |
关于 npm 那个 WASM 包能不能在浏览器里跑,我没有实测(包里确实带了 web 分支,但模型装载这一步在浏览器里需要额外处理)。所以我不下结论——官方文档给浏览器的路就是 §1 那条:用 Emscripten 编译。
5. 浏览器里的采集链路
模型之外,前端还有一半工作量在音频采集上。要点(这部分是标准 Web Audio 实践,不是 sherpa-onnx 特有的):
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
// 关键:把 AudioContext 的采样率直接定成模型要的 16000,
// 省掉自己重采样这一步(浏览器会用高质量重采样器)
const ctx = new AudioContext({ sampleRate: 16000 });
const source = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, 'capture-processor'); // 自己写,拿 128 帧一包
node.port.onmessage = (e) => {
// e.data 是 Float32Array,长度通常是 128
// 攒够 VAD 的 windowSize(512) 再喂——别一包一包喂
};
source.connect(node);
四个容易卡住的点:
AudioContext({ sampleRate: 16000 })不一定被所有浏览器接受。拿不到就退回默认采样率,再用官方的LinearResampler或自己写线性插值。AudioWorklet必须在addModule()之后才能用,而且它的线程里不能直接用 DOM 或postMessage之外的通信。- 包大小要攒够再喂:AudioWorklet 一包 128 帧(8 ms),而 VAD 要 512(32 ms)、识别模型按 chunk(320/480 ms)算。用小包喂等于把固定开销摊了 4~60 遍,会明显变慢。
getUserMedia只在安全上下文可用(HTTPS 或 localhost),本地调试记得用localhost而不是局域网 IP。
6. 三条落地路线怎么选
| 纯前端(WASM) | 前端 + 本机服务(原生 addon) | 服务端(原生 addon) | |
|---|---|---|---|
| 音频出设备 | 否 | 否(走 localhost) | 是(上传到服务器) |
| 首次加载 | 12 MB 引擎 + 模型 | 0(页面不装模型) | 0 |
| 算力 | 用户 CPU,单线程 | 本机 CPU,多线程 | 服务器 CPU |
| 实时识别(300 MB 级模型) | 勉强(RTF 0.3,还会和页面抢主线程) | 舒服(RTF 0.09) | 舒服 |
| 隐私 | 最好 | 好 | 看部署 |
| 适合 | 小模型(VAD / 唤醒词 / 英文小模型)、离线 demo、隐私硬要求 | 桌面端应用(Electron / Tauri)最实用 | 手机浏览器、低端设备、要跑大模型 |
三条我实测支撑的判断:
- VAD 和唤醒词放浏览器完全没问题:模型只有 0.6 MB / 几 MB,WASM 慢 3.4 倍也就是 RTF 从 0.0085 变 0.03,仍然是 30 倍实时。
- 中英双语流式识别放浏览器要慎重:
.data一两百 MB 起步,单线程 RTF 约 0.3,而且解码会占住主线程(除非放进 Worker)。桌面端更合理的选择是把 Node 原生 addon 打进 Electron/Tauri,浏览器只负责界面。 - TTS 放浏览器基本不现实:Kokoro 的
.data三四百 MB,RTF ≈ 1.0 意味着合成一句话就要卡顿一次——第 4 篇说的"分句合成 + Web Worker"能缓解,但三四百 MB 的下载量在移动端是劝退级的。
6.1 降级策略
真要上纯前端,建议做成能力分层的:
1. 一律加载:VAD(0.6 MB)—— 「静音自动停」「检测到说话才录音」
2. 需要语音命令时加载:KWS(几 MB)—— 本地唤醒词
3. 需要转写时:
├─ 有网络 → 走云端 API(准确率更高、页面不用下载大模型)
└─ 无网络 → 提示需下载离线包(几十上百 MB),装进 Cache Storage / OPFS
4. 识别统一放 Web Worker,别阻塞渲染
这样默认路径只有 12 MB 引擎 + 0.6 MB VAD,用户感受不到负担;重能力按需下载并缓存——和"进来就下 400 MB"是完全不同的产品体验。
7. 这一系列到此为止
五篇下来,从"这是什么"走到"浏览器里能跑成什么样"。最后把整条链路的结论收成一张表:
| 能力 | 最小体积 | 加载后内存 | 速度(Node/原生) | 浏览器可行性 |
|---|---|---|---|---|
| VAD | 0.6 MB | 74 MB | RTF 0.0085 | ✅ 无压力 |
| 唤醒词 | 39 MB | 93 MB | RTF 0.029 | ✅ 可以 |
| 英文识别 | 43 MB | 162 MB | RTF 0.026 | ✅ 可以 |
| 中英流式识别 | 163 MB | 324 MB | RTF 0.09~0.13 | ⚠️ 体积和主线程都是问题 |
| 语音合成 | 384 MB | 496 MB | RTF 1.00 | ❌ 不建议 |
如果只记一句话:sherpa-onnx 在前端真正好用的部分,是那些"小到几百 KB、快到几十倍实时"的能力——VAD 和唤醒词。识别和合成不是不能做,而是它们的成本模型和前端这个环境天生不合。
全部实验脚本:
source/sherpa-onnx-lab/一键复现:bash download-models.sh && bash run.sh | tee run.log(Node 侧,约 1.1 GB 模型) 浏览器侧:bash browser/run-browser-test.sh