跳到主要内容

创建日期: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
5WASM 版是单线程,同一模型同一音频解码慢 3.4~3.5 倍(两次独立运行 3.41 / 3.53);文本输出还不完全一致§3
6npm 上的 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

三个切点一模一样,连毫秒都不差。 这验证了两件事:

  1. 浏览器里跑的是同一套 C++ 逻辑,不是某个简化实现;
  2. 参数不需要为浏览器重新调——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 s0.852 s0.085
WebAssembly(sherpa-onnx npm 包)1.059 s3.010 s0.299

慢 3.53 倍(这次运行;上一次跑出的是 3.41——同一台机器同一模型,波动来自调度与缓存状态)。三点补充:

  1. 模型加载时间几乎一样(0.92 s 对 1.06 s)——瓶颈不在 I/O,在解码的算力。
  2. 这个 WASM 版本是带 SIMD 的:npm 包的 CI 脚本叫 build-wasm-simd-nodejs.sh。所以这三倍多的差距主要来自单线程(WASM 版不支持 numThreads,配置里根本没这个字段)。
  3. 文本输出还不一致:同样是 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);

四个容易卡住的点:

  1. AudioContext({ sampleRate: 16000 }) 不一定被所有浏览器接受。拿不到就退回默认采样率,再用官方的 LinearResampler 或自己写线性插值。
  2. AudioWorklet 必须在 addModule() 之后才能用,而且它的线程里不能直接用 DOM 或 postMessage 之外的通信。
  3. 包大小要攒够再喂:AudioWorklet 一包 128 帧(8 ms),而 VAD 要 512(32 ms)、识别模型按 chunk(320/480 ms)算。用小包喂等于把固定开销摊了 4~60 遍,会明显变慢。
  4. 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/原生)浏览器可行性
VAD0.6 MB74 MBRTF 0.0085✅ 无压力
唤醒词39 MB93 MBRTF 0.029✅ 可以
英文识别43 MB162 MBRTF 0.026✅ 可以
中英流式识别163 MB324 MBRTF 0.09~0.13⚠️ 体积和主线程都是问题
语音合成384 MB496 MBRTF 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