项目「言仓」来自一个很具体的陪床场景:查房十分钟左右讲完,医嘱、检查安排、饮食禁忌主要靠备忘录记,三天里漏了两次关键信息。应用市场里的录音转写工具要么订阅按分钟计费,一小时十几块;要么免费但不支持离线,录音要传到服务器。因为自己写鸿蒙,需求就收敛成三条:长录音、全离线、能按天搜。一个多月后这三条成立,代价是摘要和说话人分离没做成,方言识别准确率很差。下面按端侧识别限制、切段拼接、后台保活、内存与数据边界,梳理这套实现里真正可复用的部分。
一、Core Speech Kit 的六十秒边界
Core Speech Kit 的语音识别单次会话最长六十秒。原文作者在 Mate 60 上跑官方 demo,对着手机念了半页产品说明,文本停在第五十九秒多一点,后面整个没了;没有报错,没有 onError,onResult 就是不再回调。这个现象不是异常处理能兜住的,而是设计前提。引擎创建和监听大致如下:
- import { speechRecognizer } from '@kit.CoreSpeechKit';
- const engine = await speechRecognizer.createEngine({
- language: 'zh-CN',
- online: 1, // 端侧离线识别;原文特别说明这个字段名和注释需确认
- });
- engine.setListener({
- onStart: () => {},
- onResult: (e: speechRecognizer.SpeechResult) => {
- // 结果是边说边回调,不是等一句话说完才给
- },
- onComplete: () => {},
- onError: () => {},
- });
复制代码
这里有个容易被忽略、但对架构很关键的细节:onResult 是边说边回调,不是等一句话说完才给。原文把它用在流水线上——录到第 N 段时,第 N-1 段大概率已经识别完;一小时录音结束,识别也基本同时结束,用户不需要再等一个「转写中」。
云端识别准确率更好,还能顺便拿到标点和分段,但病房录音不愿出设备;同时云端方案要维护服务器、密钥、计费,还要处理上传等待、断网重试、失败重放。端侧方案反过来,复杂度落在算法上,算法写错了至少能改。于是最终选择:本地切段,全在端侧解决。
二、VAD 切段:双阈值、迟滞与 400ms 重叠
音频采集使用 AudioCapturer,格式为 16kHz、16bit、单声道 PCM。每拿到一个 buffer 就算一次 RMS 能量,用两道阈值做迟滞比较:能量高于 -26dBFS 判定「有人开始说话」,之后哪怕短暂掉到低值也不切;要连续低于 -38dBFS 持续 700ms,才认定这一段结束。只用一道阈值会把语气词和换气切成碎段,识别引擎会被喂进去大量两三秒的垃圾片段。
核心参数和分段器逻辑如下:
- const HIGH = Math.pow(10, -26 / 20); // 开始说话
- const LOW = Math.pow(10, -38 / 20); // 确认说完了
- const HANGOVER_MS = 700; // 静音多久才算段结束
- const OVERLAP_MS = 400; // 段尾向前多保留的重叠
- const MIN_SEG_MS = 1200, MAX_SEG_MS = 55000; // 留5s余量给引擎
- function feed(pcm: Int16Array, tsMs: number): Seg[] {
- const rms = Math.sqrt(
- pcm.reduce((s, x) => s + x * x, 0) / pcm.length) / 32768;
- const out: Seg[] = [];
- if (rms > HIGH) { speaking = true; lastVoiceMs = tsMs; }
- if (rms > LOW) lastVoiceMs = tsMs;
- if (speaking && tsMs - lastVoiceMs > HANGOVER_MS) {
- const start = Math.max(0, segStart - OVERLAP_MS);
- out.push(cut(start, tsMs)); // 落临时文件,清空缓冲
- speaking = false;
- }
- if (speaking && tsMs - segStart > MAX_SEG_MS) {
- out.push(cut(tsMs - OVERLAP_MS, tsMs)); // 强制切,尾留重叠
- }
- return out;
- }
复制代码
三个参数是踩出来的。重叠给 400ms:第一版切得干干净净,拼回来的文本偶尔丢一个字的开头,回听才发现声母被切掉了,比如「出院」变成「院」,意思全反。最短段 1.2 秒:更短的都当环境噪音丢掉,查房时推床、开门的声音特别多。上限 55 秒而不是 60 秒:给引擎留余量,实测有时长刚好卡在 60 秒的段会静默失败,不赌。
三、拼接、去重与本地存储
每段识别完,拿「文本 + 段起始采样点」存下来,按偏移排序拼接。因为段尾有重叠,相邻两段可能重复转出同一句话,原文用了一个很笨但有效的办法去重:后段的开头两个字跟前段的结尾比对,相同就去掉。整个九月 1,842 个段里,这种情况出现了 6 次,全部处理掉了。作者也清楚这个办法在语速快的人面前会失效,先记录,后续再换更稳的方案。
存储层没有用分布式数据库,就是一个本地 SQLite:一张录音表,存文件路径、时长、日期;一张段落表,存起始毫秒、文本、所属录音。转写文本按段落存而不是整篇存,好处是检索命中之后能精确跳回那段音频的对应区间回放。医生说话就那几句,能直接听到原声,比看一屏错字有用。这也是坚持存原始录音的原因:文本是不可靠的抄写员,音频才是原件。
端侧识别返回的文本几乎没有可靠标点,偶尔给一个句号,位置还未必对。端侧没有现成的标点模型,原文没有继续折腾,就直接按 VAD 的段断行。阅读体验差一点,但主场景是「搜」,不是「读」——搜的时候没人需要标点。
四、后台录音:长时任务、配置和中断
查房时手机必然锁屏放口袋里,前台保活没有意义。鸿蒙的方案是申请长时任务 continuousTask,类型选 AUDIO_RECORDING。原文在这条路上踩了三种错误,正好对应三次「录了半天发现没录上」。
第一次是压根忘了申请长时任务。切回桌面十秒钟,进程被挂起,Capturer 一起停。用户视角就是「录着录着就断了」,事后看文件断得毫无规律,其实规律就是当时切出去回了条消息。
第二次是申请了,但 module.json5 里没声明 backgroundModes。这种是硬失败,申请直接被拒,好在有明确报错,日志一眼能看穿:
- // module.json5 里的关键三段
- "requestPermissions": [
- { "name": "ohos.permission.MICROPHONE" },
- { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
- ],
- "abilities": [{
- "backgroundModes": ["audioRecording"],
- // ...
- }]
复制代码
第三次最阴:录音权限被系统按「仅本次允许」回收了。Capturer 静默停止,不抛错,长时任务还在,状态栏的录音指示还亮着。原文作者一开始没有监听音频中断事件,父亲拿着录了四十分钟,回来一放,空的。修法是在 Capturer 上挂 on('interrupt'),权限或焦点一丢,立刻在界面上把「录制中」改红并弹通知。状态指示这种东西,宁可误报,不可静默。
五、内存:一小时 380MB 的教训
第一版把所有 PCM 段都攒在内存里,等录音结束再统一送识别。一小时 16kHz、16bit、单声道是 115MB 原始数据,加上为了画波形顺手留的副本和几层临时 buffer,Profiler 里看着内存一路爬到 380MB,然后系统开始杀后台。这个 bug 在半小时的测试录音里永远复现不出来,第一次暴露是录到第五十七分钟。
修复不复杂:边录边把段落到临时文件,内存里只留一个十秒的滑动窗口供能量计算和波形显示;识别在录音进行中就流水线地跑,反正段是一段一段出的,识别引擎闲着也是闲着。改完之后长录音的内存稳定在 130MB 上下,不再随时长增长。由此得到一个规矩:凡是「等结束再统一处理」的写法,先想想「结束」可能是一小时之后。
六、数据、未做功能和产品取舍
一个月自用,原文给出的数据是:录音 41 次,总时长 19 小时 22 分,VAD 切出 1,842 段,端侧识别总耗时 2 小时 07 分,比实时快九倍左右,基本都是插着充电线让它后台跑完。准确率没有条件做科学评测,抽了 10 段人工比对:安静的普通话对话段落大概九成五;医生语速快加上专业词,掉到八成;人名几乎全错,药名错得也多;方言更没法看,父亲的口音下识别结果接近抽象画。
摘要和说话人分离是作者最初最想要、最后放弃的功能。端侧目前没有可用的文本生成模型;调云端 API 的话,一个月 19 小时的量折算下来三四十块,钱倒不是大事,问题是那样就得维护一个服务端。说话人分离同理,端侧没有声纹或说话人日志的 API,自己上聚类等于另起一个项目。这两个缺口被原样留在产品里:转写就是转写,摘要让用户自己按天整理,反正按天检索已经把「找」这个问题解决得七七八八。
界面没有多余东西:一个录制页、一个按天检索页,外加桌面上一张 2×2 的小卡片,只显示「昨天录了 47 分钟」这一句话。卡片刷新走的是数据变更主动 updateForm,没有定时器,原文明确说这一点上没有犯别人踩过的配额坑。父亲会把复查时医生的每句话录下来,晚上一段一段对,发现识别错的地方就用内置手动改字功能修正;修正后的段落又顺手存了一份「人工校对样本」,攒了两百多段。如果以后端侧模型支持热更新或者自定义词表,这些就是现成的评测集。App 没有上架,装在自己和父亲手机上。
从这套实现看,端侧 AI 的真实能力边界比发布会里的窄一档:单次六十秒、没有标点、没有摘要、没有说话人。但窄掉的这一档,恰好是普通开发者能补的:一个分段器、一套偏移合并、几个老老实实调出来的阈值,再加上长时任务、中断监听和内存流水线。不是等平台把所有能力都给全了再做,而是先弄清楚它今天能给到哪,剩下的用工程手段诚实补上,并在产品里明明白白承认哪些没做。墙是真的,绕过去的方法也是真的。 |