查看: 253|回复: 0

鸿蒙端侧录音转写实践:六十秒切段、重叠拼接与后台保活

[复制链接]
发表于 3 小时前 | 显示全部楼层 |阅读模式
项目「言仓」来自一个很具体的陪床场景:查房十分钟左右讲完,医嘱、检查安排、饮食禁忌主要靠备忘录记,三天里漏了两次关键信息。应用市场里的录音转写工具要么订阅按分钟计费,一小时十几块;要么免费但不支持离线,录音要传到服务器。因为自己写鸿蒙,需求就收敛成三条:长录音、全离线、能按天搜。一个多月后这三条成立,代价是摘要和说话人分离没做成,方言识别准确率很差。下面按端侧识别限制、切段拼接、后台保活、内存与数据边界,梳理这套实现里真正可复用的部分。

一、Core Speech Kit 的六十秒边界

Core Speech Kit 的语音识别单次会话最长六十秒。原文作者在 Mate 60 上跑官方 demo,对着手机念了半页产品说明,文本停在第五十九秒多一点,后面整个没了;没有报错,没有 onError,onResult 就是不再回调。这个现象不是异常处理能兜住的,而是设计前提。引擎创建和监听大致如下:
  1. import { speechRecognizer } from '@kit.CoreSpeechKit';
  2. const engine = await speechRecognizer.createEngine({
  3.   language: 'zh-CN',
  4.   online: 1, // 端侧离线识别;原文特别说明这个字段名和注释需确认
  5. });
  6. engine.setListener({
  7.   onStart: () => {},
  8.   onResult: (e: speechRecognizer.SpeechResult) => {
  9.     // 结果是边说边回调,不是等一句话说完才给
  10.   },
  11.   onComplete: () => {},
  12.   onError: () => {},
  13. });
复制代码

这里有个容易被忽略、但对架构很关键的细节:onResult 是边说边回调,不是等一句话说完才给。原文把它用在流水线上——录到第 N 段时,第 N-1 段大概率已经识别完;一小时录音结束,识别也基本同时结束,用户不需要再等一个「转写中」。

云端识别准确率更好,还能顺便拿到标点和分段,但病房录音不愿出设备;同时云端方案要维护服务器、密钥、计费,还要处理上传等待、断网重试、失败重放。端侧方案反过来,复杂度落在算法上,算法写错了至少能改。于是最终选择:本地切段,全在端侧解决。

二、VAD 切段:双阈值、迟滞与 400ms 重叠

音频采集使用 AudioCapturer,格式为 16kHz、16bit、单声道 PCM。每拿到一个 buffer 就算一次 RMS 能量,用两道阈值做迟滞比较:能量高于 -26dBFS 判定「有人开始说话」,之后哪怕短暂掉到低值也不切;要连续低于 -38dBFS 持续 700ms,才认定这一段结束。只用一道阈值会把语气词和换气切成碎段,识别引擎会被喂进去大量两三秒的垃圾片段。

核心参数和分段器逻辑如下:
  1. const HIGH = Math.pow(10, -26 / 20); // 开始说话
  2. const LOW = Math.pow(10, -38 / 20); // 确认说完了
  3. const HANGOVER_MS = 700; // 静音多久才算段结束
  4. const OVERLAP_MS = 400; // 段尾向前多保留的重叠
  5. const MIN_SEG_MS = 1200, MAX_SEG_MS = 55000; // 留5s余量给引擎
  6. function feed(pcm: Int16Array, tsMs: number): Seg[] {
  7.   const rms = Math.sqrt(
  8.     pcm.reduce((s, x) => s + x * x, 0) / pcm.length) / 32768;
  9.   const out: Seg[] = [];
  10.   if (rms > HIGH) { speaking = true; lastVoiceMs = tsMs; }
  11.   if (rms > LOW) lastVoiceMs = tsMs;
  12.   if (speaking && tsMs - lastVoiceMs > HANGOVER_MS) {
  13.     const start = Math.max(0, segStart - OVERLAP_MS);
  14.     out.push(cut(start, tsMs)); // 落临时文件,清空缓冲
  15.     speaking = false;
  16.   }
  17.   if (speaking && tsMs - segStart > MAX_SEG_MS) {
  18.     out.push(cut(tsMs - OVERLAP_MS, tsMs)); // 强制切,尾留重叠
  19.   }
  20.   return out;
  21. }
复制代码

三个参数是踩出来的。重叠给 400ms:第一版切得干干净净,拼回来的文本偶尔丢一个字的开头,回听才发现声母被切掉了,比如「出院」变成「院」,意思全反。最短段 1.2 秒:更短的都当环境噪音丢掉,查房时推床、开门的声音特别多。上限 55 秒而不是 60 秒:给引擎留余量,实测有时长刚好卡在 60 秒的段会静默失败,不赌。

三、拼接、去重与本地存储

每段识别完,拿「文本 + 段起始采样点」存下来,按偏移排序拼接。因为段尾有重叠,相邻两段可能重复转出同一句话,原文用了一个很笨但有效的办法去重:后段的开头两个字跟前段的结尾比对,相同就去掉。整个九月 1,842 个段里,这种情况出现了 6 次,全部处理掉了。作者也清楚这个办法在语速快的人面前会失效,先记录,后续再换更稳的方案。

存储层没有用分布式数据库,就是一个本地 SQLite:一张录音表,存文件路径、时长、日期;一张段落表,存起始毫秒、文本、所属录音。转写文本按段落存而不是整篇存,好处是检索命中之后能精确跳回那段音频的对应区间回放。医生说话就那几句,能直接听到原声,比看一屏错字有用。这也是坚持存原始录音的原因:文本是不可靠的抄写员,音频才是原件。

端侧识别返回的文本几乎没有可靠标点,偶尔给一个句号,位置还未必对。端侧没有现成的标点模型,原文没有继续折腾,就直接按 VAD 的段断行。阅读体验差一点,但主场景是「搜」,不是「读」——搜的时候没人需要标点。

四、后台录音:长时任务、配置和中断

查房时手机必然锁屏放口袋里,前台保活没有意义。鸿蒙的方案是申请长时任务 continuousTask,类型选 AUDIO_RECORDING。原文在这条路上踩了三种错误,正好对应三次「录了半天发现没录上」。

第一次是压根忘了申请长时任务。切回桌面十秒钟,进程被挂起,Capturer 一起停。用户视角就是「录着录着就断了」,事后看文件断得毫无规律,其实规律就是当时切出去回了条消息。

第二次是申请了,但 module.json5 里没声明 backgroundModes。这种是硬失败,申请直接被拒,好在有明确报错,日志一眼能看穿:
  1. // module.json5 里的关键三段
  2. "requestPermissions": [
  3.   { "name": "ohos.permission.MICROPHONE" },
  4.   { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
  5. ],
  6. "abilities": [{
  7.   "backgroundModes": ["audioRecording"],
  8.   // ...
  9. }]
复制代码

第三次最阴:录音权限被系统按「仅本次允许」回收了。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 的真实能力边界比发布会里的窄一档:单次六十秒、没有标点、没有摘要、没有说话人。但窄掉的这一档,恰好是普通开发者能补的:一个分段器、一套偏移合并、几个老老实实调出来的阈值,再加上长时任务、中断监听和内存流水线。不是等平台把所有能力都给全了再做,而是先弄清楚它今天能给到哪,剩下的用工程手段诚实补上,并在产品里明明白白承认哪些没做。墙是真的,绕过去的方法也是真的。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-23 11:15 , Processed in 0.030021 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部