我把实时字幕翻译搬进浏览器:音频不出本机,踩了 4 个坑

为什么要做这件事

市面上的「AI 字幕」工具,基本都要求把会议音频传到服务器,再收一笔订阅费。开一小时会,既要担心录音去哪了,又要按分钟付费。

我做的事反过来:把模型下载到浏览器里,用 WebAssembly 跑推理。音频从头到尾没有离开这台机器,翻译成本是 0。

技术栈很朴素:

  • transformers.js 2.17.2(其实是 Xenova 版)跑 Whisper 和 opus-mt
  • ONNX Runtime Web 提供 WASM 后端
  • Cloudflare R2 + Worker 分发模型和运行时(约 38MB 的 wasm 运行时 + 85/180MB 的模型)
  • 纯静态页面,没有任何后端推理

下面是我真正踩过的 4 个坑,按坑的隐蔽程度排序。

坑 1:transformers.min.js 是 ESM,<script src> 会静默失败

这是最浪费时间的一个。页面打开一片空白,控制台只报一个 SyntaxError: Unexpected token 'export',而网络面板显示 JS 200 正常加载。

原因:@xenova/transformers@2.17.2 的 dist/transformers.min.js 是 ESM 构建 ,文件尾部是 export{...},没有 UMD 包装。用经典的 <script src> 加载必然报错,而且不会给你一个「库加载失败」的显式错误------只有语法错误。

正确写法:

html 复制代码
<script>
  // 经典脚本先定义引导函数
  window.__bootApp = function () { /* 用 window.__T 启动 */ };
  window.waitAppReady = function (fn, timeout = 20000) {
    const t0 = Date.now();
    (function poll() {
      if (window.__T) return fn();
      if (Date.now() - t0 > timeout) throw new Error('transformers.js not ready');
      setTimeout(poll, 100);
    })();
  };
</script>
<script type="module">
  import * as T from '/vendor/transformers.min.js';
  window.__T = T;
  window.__bootApp();
</script>

两个细节:

  1. 模块脚本是 deferred ,执行时机晚于页面里的经典脚本。所以入口必须放在 window.__bootApp 里,不能直接写在模块顶层去碰 DOM。
  2. 无头自测时我遇到过「自测脚本比模块先跑」的竞态,表现是秒失败。加一个 waitAppReady 轮询就好了。

坑 2:多线程 WASM 需要 COOP/COEP,而 COEP 会顺手打死第三方脚本

单线程 ORT 在一段 17 秒音频上要 9.1 秒。开多线程后 6.5 秒(本机无头环境,机器不同差异很大)。

开启条件是两个响应头:

makefile 复制代码
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

crossOriginIsolated === true 之后才能用 SharedArrayBuffer,numThreads = cores - 1(封顶 8)。

但代价是:

  • 所有跨域资源必须带 Cross-Origin-Resource-Policy: cross-origin,否则被 COEP 拦掉。我在 R2 Worker 上补了这个头。
  • 页面上不能有第三方脚本,比如 AdSense------会被 COEP 直接拦。所以在线版页面和广告是互斥的,别想着两全。
  • 线程版 ORT 还需要额外镜像 ort-wasm-threaded.worker.js 和 ort-wasm-threaded.js,少一个就退化回单线程,且不报错。

坑 3:大文件从 CDN 拉,偶发 ERR_HTTP2_PROTOCOL_ERROR(curl 却完全正常)

模型是 85~180MB 的单个文件。用 curl 压测 53MB 文件 3/3 成功、稳定 10MB/s;但在 Chromium 里偶发 ERR_HTTP2_PROTOCOL_ERROR,概率大概十分之一。

这类问题很难在服务端修,因为 curl 复现不了。我的处理是应用层兜底:整条管线失败自动重试 3 次,已下载部分走 Cache API 不重下。用户体验上就是「第一次慢一点,之后秒开」。

顺带一个缓存相关的坑:snap 版 Chromium 的 /tmp 是私有命名空间 ,--user-data-dir=/tmp/xxx 等于每次全新 profile,Cache API 完全不持久------表现是每次测试都全量重下模型,会让你误判成「缓存没生效」。profile 要放在 $HOME 下面。

坑 4:浏览器能力差异,必须在 UI 里前置声明,而不是等报错

这条不是代码 bug,是产品 bug。

Firefox 用 getDisplayMedia({audio:true}) 采标签页音频根本不支持(Mozilla 的长期 open bug),Linux 上连系统声音也没有。我最初的文案是「请在弹窗里选择标签页」------对 Chrome 是对的,对 Firefox 是彻头彻尾的误导,用户会以为是自己选错了。

改法:

  1. 检测到 Firefox 且用户点了标签页源,直接给三条替代方案(改用 Chrome/Edge、用麦克风外放收音、上传音频文件),不再只报「没有音频轨道」。
  2. 区分两类失败:用户选错源 (window vs tab)和浏览器不支持。前者给操作指引,后者给替代路径。
  3. 把限制写进落地页正文,不要只写在 FAQ 折叠里。

现在的真实边界(不粉饰)

  • 只支持 Chrome / Edge 桌面端;Firefox 采不到标签页音频。
  • 只做英⇄中双向。
  • 没有 SRT 导出,只有屏幕字幕 + 转写文本。
  • 质量不如云端大厂:量化版 Whisper base/small,为延迟和隐私做的取舍。
  • 浏览器版采不到其他桌面 App 的声音(Zoom 客户端不行,浏览器里的 Meet 可以)。

在线试用

浏览器在线版是我做的独立镜像与 Web 化改造,桌面版版权归原作者。

如果这篇帮你少踩一个坑,就算没白写。有想问的实现细节,评论区见。

相关推荐
zzu123zsw13 天前
从 proxy_pass 末尾斜杠到 WebAssembly 预压缩:深挖 Nginx 生产环境四大高频踩坑点
运维·nginx·wasm·webassembly·devops·运维排错
CSharp精选营18 天前
Blazor 里 JWT 过期,用户正下单就被踢
认证·jwt·webassembly·blazor·令牌刷新
前端逗比逗2 个月前
AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染
前端·webassembly
k4m7v2pz2 个月前
用 Rust 重造 Java 的 CLI 分发体验:从 java -jar 到 wasmtime run
rust·跨平台·webassembly·wasmtime·wasi·java-jar
红尘散仙2 个月前
TypeScript WIT Guest:类型约束与 ComponentizeJS 工程化
rust·typescript·webassembly
红尘散仙2 个月前
从 dsh 的插件系统出发:为什么我要探索 WIT 与 Wasm Component Model
rust·typescript·webassembly
红尘散仙2 个月前
从 WIT 到 Wasmtime:构建 Rust Component 工程
rust·typescript·webassembly
Thneonl2 个月前
用 WebAssembly 给不可信插件上镣铐:capability 沙箱模型实践
webassembly
szp20052 个月前
为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了
前端·webassembly