用 Flutter 与 whisper.cpp 实现离线转写
开源应用 Extracter 通过 FFmpeg 解码音视频,再调用 whisper.cpp 在设备端转写,模型下载后无需联网。文章介绍跨平台 C ABI、原生工作线程及向 Dart 流式回传事件的设计,目前支持 Android、iOS 和 macOS。
AI 深度解读
把录音交给云端转写很方便,但客户通话、课堂录音和私人语音未必适合上传。作者做了开源应用 Extracter,用 Flutter 做界面,FFmpeg 解码,whisper.cpp 在设备上识别,模型下载后无需联网,也不需要账号或分析服务。
这套方案值得看的地方,是文件处理、原生线程和 Dart 生命周期怎样衔接。尤其是一个退出应用才触发的崩溃,说明了跨语言调用中最容易漏掉的问题:界面已经结束,后台任务可能还活着。
离线转写怎样串成一条处理流水线
Extracter 的数据路径很直接:音视频文件经过 FFmpeg,转换为 16 kHz 单声道 PCM,再交给 whisper.cpp,识别结果分段送回 Flutter UI。

重活放在 FFI 插件 extracter_native 中,各平台共用一套 C ABI。FFI 可以理解为 Dart 调用原生代码的桥梁,C ABI 则约定桥两端如何传参数、调用函数。
ex_job_start(media, model, language, translate, threads) 在原生工作线程启动任务并返回 id;ex_job_cancel(id) 支持幂等取消,重复调用不会产生额外效果。
工作线程回传进度、带起止时间的文本片段和日志,最后必须发送且只发送一次 DONE。Dart 用 repository 和 controller 包装这些事件,UI 只订阅片段流,所以整个文件尚未处理完,文字就能逐行出现。
目前作者明确运行过的平台是 Android、iOS 和 macOS。Windows、Linux 虽然有构建脚本,但尚未测试,不能当成已经支持。
要支持多种格式,为什么还要裁剪 FFmpeg
作者要求接受各种音视频文件,因此选择 FFmpeg,但交付时只保留解码能力,以动态链接方式使用 LGPL 构建,排除编码器、网络协议和 GPL 代码。
下面这组构建参数用于限制 FFmpeg 的能力范围,最后一行是作者表达解码器集合的占位写法:
--disable-programs --disable-encoders --disable-muxers
--disable-network --disable-protocols --enable-protocol=file
--disable-gpl --disable-nonfree --disable-hwaccels
--disable-decoders --enable-decoder=<all ~200 audio decoders>
要看的是 --disable-protocols --enable-protocol=file:只允许本地文件协议。不过,仅有这行还不够。作者另外禁用了 HLS、concat、DASH 这类播放列表式解复用器,避免输入文件引导应用继续打开其他文件或 URL。
对于视频,应用选取最佳音频流处理。这里的设计取舍很清楚:复用 FFmpeg 的格式兼容能力,同时裁掉转写不需要的功能。「任何格式」是产品目标,原文没有给出完整格式测试矩阵。
长音频还有另一层切分。Whisper 内部按 30 秒窗口工作,Extracter 则每次交给它约 3 分钟音频,并在这段音频最后 15 秒里寻找 RMS 能量最低的位置切开。RMS 在这里用来衡量声音强弱,尽量选安静处断开,减少一句话被截成两段的机会。
跨块时间戳保持单调递增,长静音直接推进时间,不继续浪费识别计算。Apple 设备使用 Metal 和 Accelerate,其他平台走优化后的 CPU 路径。原文没有提供耗时、耗电或识别准确率对照。
为什么退出 Flutter 后,原生线程还能把应用弄崩
最初,作者通过 NativeCallable.listener 给原生代码一个 C 函数指针,由工作线程调用它向 Dart 发事件。常规测试都能通过,直到生成 Android 商店截图时出现 SIGSEGV。
触发顺序是:转写还在运行,用户按 Back,Android 结束 activity,Flutter engine 随后关闭 Dart isolate。随 isolate 一起消失的,还有 NativeCallable 的回调跳板,但原生工作线程仍在执行。下一条事件到来时,它跳进了已经失效的内存。
重新打开应用也有问题:新 isolate 再次调用 ex_init,原生侧却拒绝替换回调,导致新任务收不到结果。
跨语言回调的有效期,必须覆盖所有可能调用它的后台任务。
作者最终改成通过端口投递消息。Dart 把 NativeApi.postCObject 和 RawReceivePort 的 native port 交给 ex_init,工作线程把事件地址作为 int64 发过去:
ex_cobject message{};
message.type = kDartCObjectInt64;
message.value.as_int64 = reinterpret_cast<intptr_t>(ev);
if (!post(job.port, &message)) ex_event_free(ev); // dead isolate: native frees it
关键是最后一行:目标 isolate 已退出时,投递返回 false,原生侧立即释放事件。每个任务保存启动它的 isolate 端口,新一轮 ex_init 还会取消旧 isolate 遗留的任务。
作者用一个小型 flutter run 目标复现问题:启动任务,调用 SystemNavigator.pop(),再重新进入应用。修改后,同一进程连续经历三个 isolate,未再触发该崩溃。这是有明确复现路径的修复验证,但还不能覆盖所有生命周期组合。
我的判断
我更认可这篇文章对资源边界的处理:模型留在设备上、FFmpeg 限制输入能力、后台事件明确处理接收方消失。它们共同支撑了离线应用的可用性。
模型管理也考虑到了实际使用。模型来自 ggerganov/whisper.cpp 的固定 Hugging Face revision,安装前核对大小和 SHA-256;作者推荐 Base(60 MB),并称 Large v3 Turbo(574 MB)最准确,内存需求超过设备容量的模型会在 UI 中锁定。不过,准确率排名没有测试集或对照数据,仍应视为作者经验。
历史记录使用 SQLite(Drift),通过 FTS5 搜索全部转写内容,还支持 TXT、SRT、VTT 导出,以及带或不带 [00:00:12 → 00:00:18] 时间戳的段落复制。这让它具备日常工具的完整流程。
和云端转写相比,本地方案减少了上传环节,却把模型存储和计算负担交给设备。原文没有给出不同设备上的速度、峰值内存和中文识别表现,国内开发者选型时还得补测。离线也只解决数据传输问题,本地历史记录如何保护,文章没说清。
能拿来做什么
- 做 Flutter 音视频工具时,可以借鉴「原生任务产出事件、Dart 控制层封装、UI 订阅片段流」的分层,并明确取消操作和结束事件的约定。
- 使用 FFI 后台线程时,把退出页面、关闭 engine、重新进入应用列入验证场景,同时检查回调失效和投递失败后的内存释放。
- 开发本地转写功能时,可以先评估约 3 分钟分块、末尾 15 秒寻找低能量切点的策略,再用中文长录音检查断句、时间戳和实际耗时。
- 为工具生成演示素材时,可以参考作者用
flutter drive在模拟器执行真实转写、再用 Python 脚本合成截图和 GIF 的流程,让展示内容来自可重复运行的功能。