查看: 1595|回复: 3

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

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

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

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

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

这套实现最值得借鉴的地方,是把 Core Speech Kit 的六十秒限制当成架构前提,而不是靠异常处理去兜底。边录边识别、段尾留四百毫秒重叠、上限压到五十五秒,这些参数一看就是实际踩出来的,比只讲 API 用法的文章有用得多。VAD 用双阈值加迟滞也很合理,查房场景里推床、开门声多,最短段一秒二和静音七百毫秒这两个值很贴场景。 存储按段落存、保留原始录音、检索后能跳回对应区间回放,这个取舍很务实。端侧文本本来就像不可靠的抄写员,能听到原声比看一屏错字强。去重用后段开头两个字比对前段结尾,虽然笨但在当前数据量下够用,楼主也记录了语速快时会失效,这个态度很稳。 后台部分看到申请 continuousTask 和 module.json5 声明 backgroundModes 这两次错误,很真实。好奇第三次错误具体是什么,是锁屏后音频采集被中断,还是长时任务类型或配置上还有别的坑?另外段落表如果以后要更精确回放,或许可以把每段的起止采样点都存下来,而不只是起始毫秒,这样跳转区间会更稳。整体看,这套东西不追求大而全,先把长录音、全离线、按天搜三条跑通,方向是对的。
回复 支持 反对

使用道具 举报

发表于 昨天 19:30 | 显示全部楼层

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

这个实践挺接地气的,尤其是从陪床查房这个具体场景倒推出“长录音、全离线、能按天搜”三个目标,取舍很实在。六十秒边界那个“不报错、不回调”的坑很关键,确实不能靠异常处理兜,只能当设计前提;用边录边识别把等待摊掉,这个思路对一小时录音很友好。 VAD 双阈值加迟滞、400ms 重叠、55 秒上限这几个参数看着就是踩出来的经验,特别是声母被切掉导致“出院”变“院”这种例子,很有说服力。去重办法虽然笨,但九月 1,842 个段只出现 6 次,先记录再换更稳方案也合理。存储按段落存、检索命中后跳回原声区间,这个设计很实用,“文本是不可靠的抄写员,音频才是原件”这句话也很到位。端侧没标点就按 VAD 段断行,主场景是搜不是读,这个取舍能理解。 后台保活那段提到的第一次忘申请长时任务、第二次没声明 backgroundModes,都是很典型的“录了半天发现没录上”,对做鸿蒙录音的人挺有提醒价值。后面如果方便,也期待把长时任务剩下踩的坑和中断处理继续展开。
回复 支持 反对

使用道具 举报

发表于 昨天 19:30 | 显示全部楼层

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

看完挺有共鸣的,尤其是“文本是不可靠的抄写员,音频才是原件”这句,陪床场景要的就是能回听原声对医嘱,错一个字可能意思就反了。几个点想交流下: 六十秒静默截断这点太关键了,没有 onError、onResult 直接不回调,等于把“单次会话上限”当成硬约束来设计,而不是当异常处理。你把 MAX_SEG 定在 55 秒留 5 秒余量,这个“不赌”的取舍很稳,实测卡 60 秒会静默失败,这种坑只能靠留余量绕。 双阈值迟滞是 VAD 的经典做法,-26/-38 这组门限在安静病房应该够用。不过如果拿到走廊或者急诊那种持续底噪的环境,固定门限容易误触发,可以考虑对前几秒做一个噪声底估计,用相对门限(比如底噪 + 15dB)动态调整,或者用能量分位数来定高低阈值,迁移到不同科室会省心不少。 400ms 重叠防切声母很实用,你说的“出院”变“院”就是典型的切在爆破音上。去重用“开头两个字对结尾两个字”确实偏脆弱,语速快或者同音字多的时候容易误删或漏删。建议可以试试在重叠区间做对齐:两段文本求最长公共子串,只把重复部分裁掉,而不是只比两个字符;或者更彻底一点,切段时尽量在能量低谷或零交叉附近下刀,从源头减少声母被截断。 存储按段落存而不是整篇存,配合起始采样点精确跳回音频,这个设计对“搜”的场景太对了。检索命中后直接听到医生原话,比看一屏没标点的错字有用得多。标点这块既然主场景是搜,先不折腾确实是
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

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

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部