做鸿蒙开发的朋友,凡是从“录音”功能开始折腾项目的,大概都有过这种体验:调用麦克风不难,把录音存成文件也不难,真正难的是让录音“长了脑子”——一边录音一边把声音变成看得见的文字。
这次分享的案例,就是一个基于 HarmonyOS 6 环境实现的“鸿蒙录音实时转文字”应用:用手机麦克风采集音频流,在录音进行的同时,通过流式语音识别把说话内容实时转为文字并显示到界面上,录制结束还能导出整段文本。整体方案不依赖特定厂商的识别服务,核心逻辑抽出来以后,录音采集、音频分帧、流式识别、增量拼接这套思路你也能直接复用到会议纪要、采访整理、字幕生成甚至语音搜索的业务里。
这篇文章我会把整个项目的拆解逻辑、技术选型、核心代码思路、踩坑记录都摊开讲,重点讲清楚“实时转写”到底是怎么串起来的。不管你是刚开始碰鸿蒙音频开发,还是已经写过录音功能但始终没跑通“转文字”,都值得对照着走一遍。
1. 需求拆解与技术路线选型
1.1 这个案例到底要解决什么问题
任何技术项目的起点都不是“想写代码”,而是“有个问题很烦”。这个案例要解决的真实问题是:口述内容太多、事后整理效率太低。
比如开会时口头讨论半小时,回头要整理成文字纪要;采访一个人,录了十几分钟音频,最后要逐句听写;上课、培训、头脑风暴时内容稍纵即逝,手写永远跟不上说话速度。这些场景的共同矛盾是:录音容易,但“把录音重新听一遍并整理成文字”的时间成本极高。
所以这个应用的核心需求被我拆成了四块:
- 录音能力:手机麦克风能持续采集音频,不卡顿、不丢数据;
- 实时识别:不能等录音结束再转写,要边说边转,最好几百毫秒内能看到文字露头;
- 文本管理:多轮识别返回的“中间结果”和“最终结果”要无缝拼接,不重复、不遗漏;
- 交互反馈:界面上要能看到录音状态、音量变化、识别结果动态刷新,让人知道“它真的在工作”。
听起来不算复杂,但真上手以后会发现,拦路虎恰恰是“实时”两个字,很多现成的录文件、转文字方案在这里是失效的。
1.2 技术路线为什么这么选
做实时转写,摆在面前有三条技术路线,我一开始也在这三条之间犹豫。
第一条路线是**“先录音保存成文件,再整段识别”**。最简单,录音用系统录制文件能力,结束后把音频文件丢给识别引擎,等结果。但缺陷非常明显:录制和识别完全串行,录音半小时,转写可能还要几分钟,还要临时处理文件传输、格式转换、超长音频切片问题。对于“边说边看文字”的诉求这条路直接淘汰。
第二条路线是**“分段录音,每段转写”**。把录音分成固定时间的小片段,比如每5秒转写一次。比整段识别实时性高,但因为切割粒度太粗、边界时段容易把语义切断,而且每段独立识别没有上下文,错字率明显上升,文字之间还经常出现重复和丢失,体验很差。
第三条路线,也就是我最终采用的,是**“PCM音频流实时采集 + 流式语音识别”**。手机端持续返回原始PCM音频数据,不落盘,直接以数据帧的形式发送给识别引擎;识别引擎采用长连接流式接口,收到音频的同时增量返回已经识别出的文字片段。整个过程音频采集和文字返回是并行的,用户说完一句话差不多隔几百毫秒就能看到对应文字出现,实时性体验最好,而且内存和磁盘开销都小,没有临时文件需要管理。
但这套方案有一个隐藏门槛:它把“录音”从录制文件变成了数据流采编,工程上需要处理好缓存、切片、拼接、网络回包四件事,这也是我写这篇文章想重点展开的部分。
1.3 整体架构是怎么串起来的
整个项目的架构从上到下分四层,每一层都只干一件事情,接口清晰才能保证不出乱子。
- 采集层:使用系统音频采集接口获取麦克风PCM数据流,采样率固定为16kHz、单声道、16bit位深,持续产出原始音频字节;
- 处理层:把字节流按固定时长切片,例如20ms一帧、200ms一个发送包,同时做分贝检测,顺便为UI提供音量波形数据;
- 识别层:建立与云端识别引擎的流式WebSocket长连接,把音频帧流式上传,并接收增量识别结果;
- 展示层:ArkUI状态管理驱动界面,维护“临时识别文本”和“最终确认文本”两个缓冲区,按状态实时刷新展示,录音结束时汇总导出。
这套架构在鸿蒙的落地顺序是:先跑通“采集层”,确定能稳定拿到PCM数据;再连“识别层”,用最朴素的发送方式确认识别引擎可通;最后才做UI和文本拼接。千万不要一上来就四个层一起写,不然后面排错时根本分不清是麦克风的问题、网络的问题还是界面状态的问题。
2. 环境准备与工程配置
2.1 开发环境与创建项目
这个案例是标准应用工程,我用的是鸿蒙官方IDE创建的空UIAbility工程,开发语言选择ArkTS,编译API版本按当前开发工具支持的最新稳定版配置。创建之后建议先跑一遍默认模板,确认本机模拟器或真机连接正常,再做下面的改造,否则容易把“工程环境问题”和“业务代码问题”混在一起。
工程创建完成后,需要确认两件事。第一,工程默认的模块是否支持麦克风接入,也就是权限和服务声明是否完整;第二,当前运行目标设备是模拟器还是真机,模拟器往往没有真实麦克风设备,导致采集层测试根本走不通,这一点后面我会在问题排查里再强调。
2.2 权限声明与动态授权:别等运行时才想起来
鸿蒙对麦克风权限是敏感权限,必须在模块配置文件module.json5里声明,同时在运行时动态申请,缺一不可。配置文件里需要添加:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.MICROPHONE", "reason": "用于实时录音并进行语音转写", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ] } }reason字段是申请弹窗上展示给用户的说明,建议写清楚用途,提高授权通过率。代码里的动态授权参考下面这种写法,核心是要等用户点击允许后,再去初始化录音器,否则会出现权限还没下发,采集已经启动的情况:
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; const permissions: Array<Permissions> = ['ohos.permission.MICROPHONE']; let atManager = abilityAccessCtrl.createAtManager(); try { const result = await atManager.requestPermissionsFromUser(context, permissions); const isGranted = result.authResults.every(code => code === 0); if (isGranted) { // 权限已授予,继续初始化录音 } } catch (err) { console.error(`权限申请失败:${JSON.stringify(err)}`); }这里有一个极其容易踩的坑:如果在模拟器上测试,即使权限返回了授权成功,模拟器也可能没有真实麦克风输入源,拿到的一直是静音数据。所以环境带上真机调试,而且要检查设备的麦克风硬件是否存在,不要被“权限已授予”骗过去。
2.3 音频会话的核心参数配置
录音转文字对音频采集的参数要求,和日常“录个语音备忘录”完全不一样。普通录音为了保真,采样率可能设到44.1kHz甚至48kHz;但绝大多数语音识别引擎模型训练时用的都是电话级别或语音聊天级别的特性,16kHz就能覆盖语音范围内的绝大部分有效信息,码率更小、网络消耗更低。
实际配置音频采集器的时候,我设置了以下这组核心参数:
- 采样率:16000Hz
- 声道数:单声道
- 采样格式:有符号16bit小端序 PCM
- 音频源:麦克风
- 编码类型:原始数据(RAW)
这些参数的作用我在第三节会结合代码详细说明,这里可以先记住一个结论:语音识别场景,16kHz单声道16bit几乎是通用标准,后面接任何识别引擎都不用再改音频格式。
3. 录音模块实现:音频流实时采集
3.1 为什么用音频采集器而不是录音机
鸿蒙上录音能力常见的有两类接口:一类是录制文件的高层接口,能直接产出音频文件;另一类是底层音频采集接口,返回的是原始音频数据流。
很多同学刚开始写这个需求时,会习惯性地找“录音机”接口,录完得到文件再去转文字。但实时转写场景下这套思路有三个致命问题:文件格式和识别引擎要求不一致,比如录音机默认的压缩格式根本不能直接喂给识别引擎;文件录制需要停止以后才能拿完整数据,实时性无从谈起;频繁落盘写文件也增加IO开销和存储压力。
所以我换用底层采集接口AudioCapturer,它有两大优势:直接拿原始PCM数据流,不需要格式转换;读取之后可以立即处理发送,天然适合我们的流式转写链路。
初始化音频采集器的代码思路如下:
import { audio } from '@kit.AudioKit'; let streamInfo: audio.AudioStreamInfo = { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW }; let capturerInfo: audio.AudioCapturerInfo = { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0 }; let capturerOptions: audio.AudioCapturerOptions = { streamInfo: streamInfo, capturerInfo: capturerInfo }; const audioCapturer = await audio.createAudioCapturer(capturerOptions); await audioCapturer.start();初始化完成后,后续就是持续读取缓冲区数据,不断把字节流交给处理线程。这里我用getBufferSize()拿到推荐的缓冲区大小,每次用固定大小的ArrayBuffer去读,读回来的长度以接口返回为准。
3.2 音频缓冲与分帧处理:别把20ms的数据直接扔给网络
实时采集会按非常小的粒度吐出数据,如果你直接把每帧都立即发送给识别引擎,网络请求会被刷爆,识别引擎也会因为音频太碎而无法有效建模。正确做法是做一步**“分帧聚合”**。
先算一笔账。采样率16000Hz、位深16bit、单声道,意味着每秒产生16000 * 2 = 32000字节数据;按最容易识别的20ms切帧,就是32000 * 0.02 = 640字节。这个20ms帧更多是为了音频处理层面的对齐(有很多算法本来就以20ms建模),但对网络传输来说,20ms一包仍然太快了。
我的聚合策略是把5个20ms的帧聚合成一个100ms的识别请求包,当然具体包长还要结合你的网络状况和识别引擎的接口限制调整。比较常见的做法是100ms到200ms之间,太短会让请求过于频繁,太长又会让识别延迟变大,实际测下来我偏向于用200ms作为一次发送包,语义颗粒更完整,也没有明显延迟感。
伪代码思路是这样:
let sendBuffer = new Uint8Array(sendChunkBytes); let sendBufferOffset = 0; while (true) { const readSize = await capturer.read(byteBuffer, true); if (readSize <= 0) continue; const pcmBytes = new Uint8Array(byteBuffer, 0, readSize); // 计算分贝值,供UI展示 const db = calculateRms(pcmBytes); // 把数据写入发送缓冲,凑满一个发送包后发出 sendBuffer.set(pcmBytes, sendBufferOffset); sendBufferOffset += readSize; if (sendBufferOffset >= sendChunkBytes) { wsSend(sendBuffer); sendBufferOffset = 0; } }这套逻辑在采集循环和网络发送之间做了一个解耦,不会因为网络抖动而阻塞采集,也不会因为采集过快而挤爆发送通道。
3.3 分贝检测与录音波形
很多用户下意识想看到“录音是不是在进行”,最常见反馈就是界面上有个波形在跳动。这个功能对实时转写不是核心,但对用户体验确实很重要,而且实现成本极低。
从PCM数据计算分贝,原理就是统计学里的均方根(RMS):取出16bit采样点的数值(范围约-32768到32767),求平方和的平均值,再开根号,最后取对数换算成dBFS。代码可以这样写:
function calculateRms(arrayBuffer: ArrayBuffer): number { const samples = new Int16Array(arrayBuffer); let sumSquares = 0; for (let i = 0; i < samples.length; i++) { sumSquares += samples[i] * samples[i]; } const rms = Math.sqrt(sumSquares / samples.length); const db = 20 * Math.log10(rms / 32768); return Math.max(db, -60); // 下限-60dB,避免负无穷 }把算出来的分贝值通过状态变量传给UI,渲染成一组动态高度的条状柱。注意开根号之前可以先算均值,避免大循环里反复做Math操作,影响采集线程实时性。
4. 语音识别与实时转写逻辑
4.1 识别引擎的接入方式
鸿蒙目前没有内置一个我们项目里可以直接调用的“通用实时转写”能力,所以这个案例走的是接入云端流式识别服务的方案。你可以选择一个提供流式ASR接口的云服务商,或者企业内部自建识别网关,通信方式基本都是WebSocket长连接。鸿蒙平台本身提供了完整的WebSocket能力,用它把音频流推上去、把识别文本拉回来,链路很顺。
接入过程有三个阶段,分别对应三类WebSocket消息:
- 开始帧:发送会话配置,包括采样率、编码格式、声道数、语言,声明“我要开始上传音频流”;
- 音频数据帧:持续发送聚合后的PCM二进制数据,期间识别引擎会不断返回增量识别结果;
- 结束帧:通知服务端音频流结束,等待它返回整段音频的最终识别结果,然后关闭连接。
只要把这三类消息按顺序走通,识别引擎具体是哪家就不重要了,接口协议都大同小异。要注意的是WebSocket数据帧如果既想传二进制又想传文本,建议分开处理:音频用二进制帧,控制指令和返回文本用字符串帧,这样不容易绕晕。
4.2 增量文本的拼接策略:中文场景的重复与乱序
流式识别返回文本时,通常有两种类型:临时结果(表示“我还没听完整,这是当前的猜测”)和最终结果(表示“这一段我说完了,以这个为准”)。如果只是粗暴地把所有返回文本累加到界面上,你会发现文字疯狂重复、语法错乱、甚至会在用户说完之后才突然改写前面的内容。
我用的维护方式,是双缓冲区加提交队列:
finalBuffer:保存所有已经被最终确认的完整句子,展示给用户,不允许反复修改;interimText:保存当前识别引擎正在输出的“活动文本”,会随着新结果频繁覆盖更新;pendingSlice:当音频被切分成多个时间片发送时,每个时间片返回的最终结果先暂存到这里,按时间顺序整理后再合并进finalBuffer。
简单的状态处理看起来像这样:
interface AsrResult { isFinal: boolean; text: string; } function onAsrMessage(result: AsrResult) { if (result.isFinal) { finalBuffer += result.text; interimText = ''; } else { interimText = result.text; } // 更新UI }这里有个很容易忽略的细节:不同音频切片识别出的“最终结果”可能重叠。比如上一片返回“今天天气不错”,下一片可能返回“天气不错适合户外跑步”,“天气不错”就会被重复写入。我的做法是提交前检查两段相邻文本的重叠后缀/前缀,把重合部分去重再拼接。工程上不必追求完美,把高频重叠文本列表维护一组就够了,简单正则也能缓解大部分问题。
4.3 静音停顿与标点处理
流式识别引擎返回的文字通常没有标点,连续说一大段,界面上就是一大串没有断句的文字墙。开始用的时候会觉得“能转出来但跟没标点的机器翻译似的”,可读性很差。
我的标点处理分为两层。第一层放在采集层:通过分贝检测判断静音区间,当检测到持续约600ms以上的静音且当前已识别出文字时,自动在句子末尾补一个句号。第二层放在文本后处理:识别结束时把多个连续标点统一清理,把开头、结尾多余的标点去掉,再把一些常见前缀(如“那”“然后”“就是说”)用规则过滤掉,让成稿看起来自然一些。这个处理不算复杂,但成稿质量会显著提升。
5. 界面实现与交互优化
5.1 录音状态机的设计
实时转写的UI比普通录音复杂的地方,在于状态切换会影响后续动作。我把整个页面收束成四个状态,用ArkTS的状态变量驱动:
- 空闲(IDLE):显示“开始录音”按钮,可配置识别语言;
- 录音中(RECORDING):显示“暂停”“停止”按钮,实时展示音量波形和转写文本;
- 暂停(PAUSED):不采集音频,但保留当前文本,点击“继续”可接着录;
- 结束(FINISHED):停止音频流和WebSocket连接,显示“复制全文”“重新录音”操作。
为什么一定要用状态机而不是几个布尔变量?因为在录音中暂停、结束、网络断开的异常分支很多,如果散落着几个flag,很容易出现“界面显示正在录音但实际采集已经停了”或者“WebSocket还开着但音频早就不传了”的错乱。状态机强制你在任何时刻都只处于一个稳定状态,操作按钮根据状态字段来判断可用性,不会打架。
5.2 转写结果列表展示
转写文本的展示我分成了两个区域:当前活动文本区和已确认历史记录区。活动文本区显示识别引擎正在“打草稿”的临时文字,字体用淡色,在界面上像字幕一样,随时能被上一帧结果覆盖;一旦最终结果确认,就把完整句子添加到历史记录区,用卡片样式列出,并清空活动文本区。
列表底部建议做自动滚动,否则一边说一边往下刷,眼睛要一直手动拉屏幕,体验很割裂。自动滚动可以通过列表滚动控制器实现,在活动文本每次变化时,判断可视区域接近底部才触发滚动,避免用户想回看前面内容的时候被强行拉到底。
5.3 录制完成后的文本处理
我们做的是“实时转文字”,不是“只展示裸转写文本”。在点击停止并关闭所有连接后,最好把收集到的finalBuffer再做一次整体清洗:合并多余空行、清理重复文本、统一标点符号。如果后续想把文本复制到备忘录或发给朋友,这一步几乎是刚需。
此处我还会顺手做一份带时间戳的导出文本,每句前面插入“分:秒”,方便用户事后回录音核对。听起来只是多写几行字符串拼接,但这个细节在开会纪要、访谈整理场景里特别受用。
6. 常见问题与实战排错
6.1 权限申请成功了,但麦克风一直没有数据
这是这个项目里最容易遇到、也最容易半夜让你怀疑人生的故障。检查方向按优先级排序:
- 确认跑在真机上:模拟器就算权限全通过也不一定有真实麦克风输入源;
- 确认应用运行时仍然持有权限:用户可能授权后又到系统设置里关闭了权限,重启应用前先检查权限状态;
- 确认录音通道占用:如果有其他应用正在录音,部分设备策略会让后来者拿不到音频流;
- 确认确实调用了start():初始化不等于开始,忘了调用
start()时接口不会报错,但读到的长度一直为0。
出现这个情况不用急着改代码,先在你自己的应用里加一行日志,把capturer.state和每次read()的返回值打出来,基本一眼定位。
6.2 采集线程读到的数据全是静音/乱码
如果是真机、权限正常但仍然静音,就要检查参数和数据解读了。
- 采样格式配置成16bit,但读回来的数据内部被按浮点型解析,数值必然异常;
- 采样率配置不匹配,设备返回的实际格式和识别引擎预期不一致,识别引擎收到的就是“变速音”;
- 读取缓冲区过小,导致每次只能读到半个采样点,也会乱码。
最直接的排查方式是:把采集到的一段PCM数据保存成wav文件,自己听一遍。如果wav正常播放且语音清晰,说明采集侧没问题,问题在发送环节;如果wav本身就是噪声或断音,问题一定在采集参数。
6.3 识别结果错误率高、频繁丢字
遇到这种问题,占大头的原因往往是音频切片和网络传输不同步。比如发送包切成了200ms,但WebSocket接口实际一次只收100ms的数据,或者在发送音频数据之间又插入了控制文本帧,服务端把控制帧误当成了音频数据。
还有一点很容易忽略:句首句尾的静音不要全删,遇到长空白反而要保留一点。很多识别引擎对句尾静音做端点检测,如果网络中间环节把静音帧全丢光,句子边界就乱了。我用的是“短静音裁剪、长静音保留”的策略,实测识别率比完全裁剪更稳定。
6.4 耗电高、发热快,实时转写怎么控温控流
流式识别场景麦克风、屏幕、网络三路同时高负载,功耗压力确实大。做了几轮优化之后,发热明显降下来:
- 采集线程和UI线程分离,用消息队列或通道传递数据,UI不阻塞采集;
- 分贝检测的循环用在子线程里,算完只投递结果不投递原始数组;
- 音频帧批量发送,禁止每20ms一个小包,大幅降低网络唤醒频率;
- 识别结束立刻释放采集器和关闭WebSocket,不要等到页面销毁。
这些优化不只是体验问题,长时间录音时对设备电池和稳定性都有直接影响。
7. 个人实践经验总结
做完这个案例,我自己最大的感触是:实时录音转文字的难度其实不在“识别”,而在“实时”。音频流采集、分帧聚合、网络传输、增量拼接,这四个环节只要有一个卡住,用户看到的就是“录了半天没字幕”。务实排错的方式是把链路拆开测试,先拿固定音频文件喂识别引擎确认通路,再用实时采集流替换文件输入,最后接入UI,而不是指望一口气调到全绿。
还有两个小提醒。一是市面上很多识别接口在普通话表现不错,但方言、中英混读就需要特殊处理,这类场景尽量选支持热词定制或方言模型的引擎,并在发送开始帧时把热词带过去。二是别贪心想要“完全准确”,实时转写本身就是一个动态纠错的过程,能把中间结果和最终结果处理清楚,用户体验就已经很好了。
如果你接下来想把它做得更像一个完整产品,可以在导出功能里加上录音回放同步滚动高亮,或者把转写文本接入字数统计、关键词提取,甚至在识别层做多会议记录分类。录音和转写的能力底座搭好之后,上层玩法其实非常开放,照着这个思路扩展就行。