需求很朴素:一通电话结束后,把录音转成文字,交给后面的流程去写工单。
云端转写 API 能用,但有一道推不动的门槛:每个用户要自己申请 API key。在一个几十人的内部工具里,这道门槛等于功能不存在。
所以做了第二条路:在用户自己的机器上跑一个本地转写宿主,扩展通过 Chrome 的 Native Messaging 调它。安装一次宿主,替代「每人申请一个 key」。
两条路并存,不是替换:
通道选择 = 'local' 且宿主可用 → 本机宿主宿主没装 / 没注册 → 静默回落云端(用户无感)回落这一条是整个设计的前提。有了它,「默认走本地」才不要求先给所有人装宿主。
先否掉的方案:浏览器内 WASM
最自然的想法是不装任何东西,在浏览器里用 WASM 跑模型。实测:
| 同一模型、同一段音频 | |
|---|---|
| 原生(本机宿主) | 3.7s |
| 浏览器内 WASM | 31.7s |
而且官方的 WASM 包编不了多线程(threads=2 直接崩),单线程是硬天花板。用户的机器比开发机弱,再乘两三倍就是一分多钟。不可用。
宿主侧的两条实测约束
宿主是一个用 sherpa-onnx 跑 transducer 模型的小程序。两条约束都是测出来的:
① 必须分段解码。 212 秒的音频整段喂进去:17.6 秒、峰值内存 1.2GB;切成 15 秒一段:3.7 秒、546MB。
这不是调参,是非流式模型的注意力开销随序列长度超线性增长。而且多语言混杂的音频整段解码还会塌缩到单一语言——237 秒只出了 86 个字。
② 别指望热词修正专名。 实测证伪:这个架构下热词修正不了任何一个错字,权重拉很高也没用,强加反而劣化(某个字被反复替换成近音字,一个变两个、两个变三个)。
专名准确率靠选模型,不靠热词。 热词表还是有用的——但用法换成了「写进给大模型的转写说明里」,让后面那一步去纠正近音错字。
消息通道的三道墙
真正花时间的,是把音频从页面送到宿主这一路。
墙一:AudioContext 在 Service Worker 里不存在
录音是 8kHz 的 mp3,宿主只吃 16kHz 单声道 wav。转格式要用 AudioContext 解码——那是 DOM API,MV3 的 Service Worker 里没有。
所以转格式只能在 content script 里做(实测 466ms)。这一步绝不能挪进 background。
墙二:chrome.runtime.sendMessage 单条 64 MiB,提不了
音频转完要从 content 发给 background,再由 background 交给宿主。
一通 27 分钟的电话:mp3 1.6MB → 16k wav 49.19MB(×30)→ base64 65.58MB(×1.33)→ 炸了。
这个 64 MiB 是 Chromium 进程间通信层写死的,没有任何扩展 API 能放宽它。 别去找「怎么调大限制」,只能减小单条传输量。
修法是把分段前移到 content:background 收到音频后第一件事本来就是切段,那个 49MB 的整段在进程间通道上只活一瞬间,纯属白传。前移后每段 45 秒,约 1.35MB,base64 后 1.79MB。
⚠ 并发数要跟着搬过去。原来 background 那侧是 4 路并发,这是「6 分钟通话 40 秒出结果」的关键——前移后改成串行,就会退化成几十次顺序往返。
墙三:别用 ArrayBuffer 省那 33%
base64 让体积涨三分之一,很自然会想:直接传 ArrayBuffer 不就好了?结构化克隆是支持它的。
我在页面里用 structuredClone 测了三种形状,全部通过。然后改了代码——音频静默丢光了。
因为那测的是错的东西:扩展的消息通道默认走 JSON 序列化,ArrayBuffer 会变成 {}。不报错,只是数据没了。
要真走结构化克隆,得在 manifest 里显式开启对应的序列化选项,而且只有较新版本的 Chrome 才认。我改完又回退了。
验证手段要和真实路径一致。 在页面里测
structuredClone通过,证明的是structuredClone能克隆它——不是扩展消息通道会用structuredClone。
最后一跳:扩展没有写磁盘的权限
Native Messaging 本身也有尺寸约束:宿主发给扩展的单条消息上限是 1MB(反方向的上限大得多)。
更根本的问题是,一段几 MB 到几十 MB 的音频,塞进一条 JSON、两端各做一次整段序列化,本来就不划算。理想做法是扩展把 wav 写到临时文件,只把路径发给宿主——
但 MV3 扩展没有任意写磁盘的权限。(chrome.downloads 能写,但会污染下载记录、还需要用户交互。)
所以落盘只能由宿主做:扩展把音频分片(512KB 一片)发给宿主的 writeChunk,宿主拼成临时文件、转写、转写完即删。实测上传 145ms。
这是整个设计里最别扭的一段。但在这三道墙之间,没有更顺的路。
最贵的一课:搬代码要搬判据
分段前移上线当天,就出了第二个 bug:
第 9/38 段:转写结果为空 → 整通失败一段静音,毁掉了整通电话。
根因不在静音检测。转写客户端里原本有一条判据:「一个字都没识别出来 → 判失败」。这条判据当初看的是整通电话——整通没声音,确实该报错。
分段前移之后,background 一次只看得见一段。于是「这 45 秒里没人说话」被当成了「这通电话没有内容」。而电话里的静音段极其常见——每一通长电话都必挂。
修法是协议里加一个 segment 标记:分段调用时空结果返回 ok: true, text: '',由 content 汇总所有段之后,再判整通有没有内容。
搬移代码时必须问一句:这条判据的观察对象变了吗?
代码本身没改一个字,但它看到的东西从「一整通」变成了「一段」。判据的粒度跟着变了,而判据本身不会告诉你这件事。
实测性能,和它的代价
| 音频 | 本机宿主耗时 |
|---|---|
| 真实通话 404s | 11.0s |
| 2s 静音(含冷启动) | 2.4s |
实时率约 0.027——转写时长大约是通话时长的 2.7%。用户的机器更弱,按两三倍估。
这是选本地通道必须知道的代价:通话越长等得越久;云端则基本和时长无关(网络好时两三秒)。短通话本地更稳,长通话云端体验更好。
冷启动约 2 秒(模型加载),之后常驻,10 分钟空闲才释放——所以连续处理的第二通起会明显更快。
回落要区分两种失败
interface NativeAsrResult { ok: boolean; text?: string; error?: string; /** * 宿主不可用(没装 / 没注册 / 被安全软件拦了)。 * 调用方据此回落云端,而不是把失败抛给用户。 */ hostUnavailable?: boolean;}「宿主没装」和「装了但转写失败」必须分开:前者该静默降级(用户本来就没选择装它),后者该让用户看见(他装了,它坏了,他需要知道)。
混成一种的话,要么没装的人一直看到报错,要么装了的人永远不知道它坏了。
这个功能的核心逻辑——调模型、拿文字——大概只占代码的十分之一。其余全在处理一段音频怎么从页面走到本机进程:哪个上下文有 AudioContext、哪条通道有多大、哪种序列化会吞数据、谁有权限写磁盘。
这也是扩展开发的常态:难的很少是「做什么」,而是「在哪做」。 每一个 API 都只在某些上下文里存在,每一条通道都有自己的尺寸和形状。