需求很朴素:一通电话结束后,把录音转成文字,交给后面的流程去写工单。

云端转写 API 能用,但有一道推不动的门槛:每个用户要自己申请 API key。在一个几十人的内部工具里,这道门槛等于功能不存在。

所以做了第二条路:在用户自己的机器上跑一个本地转写宿主,扩展通过 Chrome 的 Native Messaging 调它。安装一次宿主,替代「每人申请一个 key」。

两条路并存,不是替换:

通道选择 = 'local' 且宿主可用 → 本机宿主
宿主没装 / 没注册 → 静默回落云端(用户无感)

回落这一条是整个设计的前提。有了它,「默认走本地」才不要求先给所有人装宿主。

先否掉的方案:浏览器内 WASM

最自然的想法是不装任何东西,在浏览器里用 WASM 跑模型。实测:

同一模型、同一段音频
原生(本机宿主)3.7s
浏览器内 WASM31.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 汇总所有段之后,再判整通有没有内容。

搬移代码时必须问一句:这条判据的观察对象变了吗?

代码本身没改一个字,但它看到的东西从「一整通」变成了「一段」。判据的粒度跟着变了,而判据本身不会告诉你这件事。

实测性能,和它的代价

音频本机宿主耗时
真实通话 404s11.0s
2s 静音(含冷启动)2.4s

实时率约 0.027——转写时长大约是通话时长的 2.7%。用户的机器更弱,按两三倍估。

这是选本地通道必须知道的代价:通话越长等得越久;云端则基本和时长无关(网络好时两三秒)。短通话本地更稳,长通话云端体验更好。

冷启动约 2 秒(模型加载),之后常驻,10 分钟空闲才释放——所以连续处理的第二通起会明显更快。

回落要区分两种失败

interface NativeAsrResult {
ok: boolean;
text?: string;
error?: string;
/**
* 宿主不可用(没装 / 没注册 / 被安全软件拦了)。
* 调用方据此回落云端,而不是把失败抛给用户。
*/
hostUnavailable?: boolean;
}

「宿主没装」和「装了但转写失败」必须分开:前者该静默降级(用户本来就没选择装它),后者该让用户看见(他装了,它坏了,他需要知道)。

混成一种的话,要么没装的人一直看到报错,要么装了的人永远不知道它坏了。


这个功能的核心逻辑——调模型、拿文字——大概只占代码的十分之一。其余全在处理一段音频怎么从页面走到本机进程:哪个上下文有 AudioContext、哪条通道有多大、哪种序列化会吞数据、谁有权限写磁盘。

这也是扩展开发的常态:难的很少是「做什么」,而是「在哪做」。 每一个 API 都只在某些上下文里存在,每一条通道都有自己的尺寸和形状。