暑假那阵子,突然接到我外婆应旧病复发又住院了的消息,刚好我放那时候高温假马上来了,于是就我主动说起去陪几天床。然后事情起因是这样的:医生来查房,他们速度很快,站十分钟左右讲完就走——医嘱、检查安排、饮食禁忌,全靠脑子记。我在备忘录里记了三天,漏了两次关键的。当时想找个录音转写的 App,应用市场里翻了一圈:能用的全要订阅,按分钟收费,一小时十几块;免费的不给离线,录音要传到不知在哪的服务器。病房里这种录音,我是不太敢往外传的。

我自己写鸿蒙,于是就起了自己写一个的念头。需求收敛到三条:长录音(一场查房加谈话经常一个多小时)、全离线、能按天搜。项目叫「言仓」,名字起得蹩脚,能想到的好名字在应用市场里都被占了。

做到现在一个多月,这三条都成立。代价是:摘要没做成,说话人分离没做成,识别准确率在方言面前基本裸奔。这篇文章想讲清楚两件事:一段一个多小时的录音,是怎么在端侧识别「单次六十秒」的限制下被切碎再拼回去的;以及为什么我最后放弃了那两个功能——后者的价值不比前者小。

 

六十秒,是端侧识别给我上的第一课

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: () => {},
});

当时摆在面前的路有两条。一条是接云端识别,准确率好,还能顺手拿到标点和分段。但一来病房录音我不想出设备,二来算了一笔账:按市场价一小时几毛到一块多,我这种自用强度一个月几十块,而且从那天起我就要维护服务器、密钥、计费——一个人写 App,多一个在线依赖就多一个半夜被叫醒的理由。我选了第二条:切段,全在端侧解决。

做这个决定之前我把两个方案各试了半天。云端方案我拿一段十分钟录音跑了通,识别质量确实好,标点、分段、甚至「嗯」「呃」的过滤都替你做了,但上传等待、断网重试、失败重放,每一样都得自己兜底。端侧方案反过来:识别本身没有网络抖动,分段失败了自己担。想明白一件事之后就不纠结了——云端方案的所有复杂度都在「网络」上,而网络这个东西我控制不了;端侧方案的所有复杂度都在「算法」上,而算法写错了至少能改。

还有个容易忽略的细节:onResult 是边说边回调的,不是等一句话说完才给。这个特性我后来用在了流水线上——切段和识别是并行的,录到第 N 段的时候,第 N-1 段大概率已经识别完了。一小时录完,识别基本同时结束,用户不需要再等一个「转写中」。

把一小时切碎:能量、迟滞和四百毫秒的重叠

思路说穿了不稀奇:先在本地把长录音切成一段一段的短音频,每段控制在六十秒以内,逐段送识别,最后把文本按时间偏移拼回去。难的全在细节里。

音频采集用的是 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 的段直接断行。阅读体验差点意思,但我这个 App 的主场景是「搜」,不是「读」——搜的时候没人需要标点。

 

后台录音:连续任务被杀的三个姿势

录音类应用绕不开后台存活。查房的时候手机必然是锁屏放口袋里的,前台保活没有意义。鸿蒙的方案是申请长时任务(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,自己上聚类等于另起一个项目。我把这两个缺口原样留在了产品里:转写就是转写,摘要请您自己按天整理——反正按天检索已经把「找」这个问题解决得七七八八了。

这个 App 我没有上架,现在装在我和我爸的手机上。界面上没有任何值得炫耀的东西:一个录制页、一个按天检索页,外加桌面上一张 2*2 的小卡片,只显示「昨天录了 47 分钟」这一句话——卡片刷新走的是数据变更主动 updateForm,没有定时器,这一点上我没有犯过别人踩过的配额坑。

实际用下来,最常被打开的反而是一个我一开始没规划的用法:我爸会把复查时医生的每句话都录下来,晚上自己戴着老花镜一段一段对,发现识别错的地方就用内置的手动改字功能修正。修正后的段落我又顺手存了一份「人工校对样本」,到现在攒了两百多段——如果以后端侧模型支持热更新或者自定义词表,这些就是现成的评测集。这也是独立开发的一个小便利:用户就是测试,数据攒在自己手里。

 

 

有朋友问要不要开源或者上架,我目前的答案是先放着。这一个多月最大的收获倒不是这个 App 本身,而是摸到了端侧 AI 真实的能力边界:它比发布会里的窄一档——单次六十秒、没有标点、没有摘要、没有说话人。但窄掉的这一档,恰好是普通开发者自己能补上的:一个分段器、一套偏移合并、几个老老实实调出来的阈值。墙是真的,绕过去的方法也是真的。

我觉得这对端侧 AI 的落地算是个还不错的注脚:不是等平台把所有能力都给全了再做,而是先弄清楚它今天能给到哪,剩下的部分,用工程手段诚实地补上,并且在产品里明明白白地承认哪些没做。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐