为什么要做这件事
市面上的「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>
两个细节:
- 模块脚本是 deferred ,执行时机晚于页面里的经典脚本。所以入口必须放在
window.__bootApp里,不能直接写在模块顶层去碰 DOM。 - 无头自测时我遇到过「自测脚本比模块先跑」的竞态,表现是秒失败。加一个
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 是彻头彻尾的误导,用户会以为是自己选错了。
改法:
- 检测到 Firefox 且用户点了标签页源,直接给三条替代方案(改用 Chrome/Edge、用麦克风外放收音、上传音频文件),不再只报「没有音频轨道」。
- 区分两类失败:用户选错源 (window vs tab)和浏览器不支持。前者给操作指引,后者给替代路径。
- 把限制写进落地页正文,不要只写在 FAQ 折叠里。
现在的真实边界(不粉饰)
- 只支持 Chrome / Edge 桌面端;Firefox 采不到标签页音频。
- 只做英⇄中双向。
- 没有 SRT 导出,只有屏幕字幕 + 转写文本。
- 质量不如云端大厂:量化版 Whisper base/small,为延迟和隐私做的取舍。
- 浏览器版采不到其他桌面 App 的声音(Zoom 客户端不行,浏览器里的 Meet 可以)。
在线试用
- 在线版(免安装,模型在浏览器里跑):translate.cosmocat.dev/online/
- 桌面版(能采系统声音,Win/macOS/Linux):translate.cosmocat.dev/download/
- 上游开源项目(MIT):github.com/niyongsheng...
浏览器在线版是我做的独立镜像与 Web 化改造,桌面版版权归原作者。
如果这篇帮你少踩一个坑,就算没白写。有想问的实现细节,评论区见。