恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

HarmonyOS录音转文字全流程实战:AVRecorder封装与语音识别链路详解

  • 首页
  • 资讯中心
  • /
  • HarmonyOS录音转文字全流程实战:AVRecorder封装与语音识别链路详解

相关资讯

梅特卡夫定律的双刃剑:连接数平方膨胀如何摧毁分布式系统扩展性 2026/9/9 23:09:47
Mask R-CNN实战:从mask_rcnn_ballon.zip到实例分割训练与推理 2026/9/9 23:09:47
用DQN训练愤怒的小鸟:强化学习实战全流程解析 2026/9/9 23:09:47

最新资讯

FastAPI 安全工具鉴权失败状态码:用 `make_not_authenticated_error` 把 401 回退成 403 的兼容方案
aider 不只改代码:用终端 AI 助手安全地编辑配置文件、文档与各类文本
STM32 IAP实战:YMODEM协议Bootloader设计与跳转卡死排查
深度解析 VueUse watchImmediate:immediate 触发语义、类型重载与 airi 项目中的实战范式
分布式事务实战:解冻支付场景下的TCC、幂等与最终一致性设计
开普勒优化算法KOA结合KNN的特征选择实战与Matlab实现

今日推荐

基于MongoDB的图书管理系统:数据建模与Spring Boot+Vue实战
Claude Code安装配置全攻略:从零开始用上终端AI编程助手
tmux 会话管理与终端复用:AI 编程工作流的调度中枢实战

本周热门

超人会飞不算本事:系统稳定依赖清晰规则与边界设计
超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论
基于CNN的调制信号识别:MATLAB实现时频图分类实战

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

HarmonyOS录音转文字全流程实战:AVRecorder封装与语音识别链路详解

发布时间:2026/9/9 23:09:47
HarmonyOS录音转文字全流程实战:AVRecorder封装与语音识别链路详解 做 HarmonyOS 应用开发这些年我最大的感受是录音这种功能平时看着不起眼真到要落地的时候权限、状态机、文件格式、后台保活、转写链路每一环都能卡你半天。尤其是录音转文字把它做成一个完整闭环涉及到的知识点远比想象中多。这篇文章就是把我在实际项目里封装录音模块、打通语音转写全流程的经验整理出来代码可以直接复制运行注释我也尽量写得详细希望能帮你少踩几个坑。如果你正准备在 HarmonyOS 应用里实现录音功能或者想把录音和语音识别串成一条完整链路这篇文章会比较适合你。内容偏实战我会先讲清楚录音封装的设计思路再给完整代码最后说说我踩过的坑和排查方法。1. 先想清楚为什么要封装录音模块而不是直接调 API很多新手拿到 HarmonyOS 的录音接口第一反应是“不就是调一下 start 和 stop 嘛”等真正写完才发现原始 API 调用其实非常琐碎。AVRecorder 是一个强状态机的组件你必须在正确的状态调用正确的方法否则直接抛异常。我自己第一次写的时候就在“没有等 prepared 回调就去 start”这个坑里卡了一晚上。所以封装的核心目的不是“把代码变好看”而是把复杂性收敛到一个可复用的模块里让上层业务只关心三件事开始录音、停止录音、拿到录音文件。至于权限申请、状态流转、文件命名、异常恢复全部由封装层处理。封装之前我对模块提出了几个具体要求这些要求决定了后面的设计创建和释放要成对出现避免内存泄漏。录音文件要按时间命名避免覆盖。要能监听错误回调并在出错时自动恢复到可用状态。对外暴露的接口要足够简单最好就start()、stop()、release()三个。停止录音后必须能拿到有效的文件路径方便后续做转写或上传。带着这些要求我们再看 AVRecorder 的官方 API思路就会清晰很多。AVRecorder 的完整生命周期是idle - prepared - started - paused - stopped - released其中paused之后还可以回到started或直接stopped。这个状态机就是我们封装的核心线索所有的接口设计都围绕它展开。2. 录音前的准备工作模块权限和输出格式选择开始写代码之前有两件事必须先定下来否则后面改起来比较痛苦。2.1 权限声明和动态请求HarmonyOS 的录音权限属于 user_grant 类型除了在module.json5里声明还需要在运行时动态请求。module.json5里加上这一段{ module: { requestPermissions: [ { name: ohos.permission.MICROPHONE, reason: $string:record_audio_permission_reason, usedScene: { abilities: [ EntryAbility ], when: inuse } } ] } }注意reason字段的字符串需要你在string.json里定义好否则编译过不去。我这里写的是“用于录制语音并进行文字转换”编译检查时会校验这个字段。动态请求权限的代码我习惯放在录音类的构造函数里而不是放页面层。这样任何页面拿到录音模块都能直接使用不用重复调权限逻辑async requestPermission(): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); try { const result await atManager.requestPermissionsFromUser(this.context, [ ohos.permission.MICROPHONE ]); const grantStatus result.authResults[0]; return grantStatus 0; } catch (err) { console.error(requestPermission failed: JSON.stringify(err)); return false; } }这里有个容易忽略的点requestPermissionsFromUser必须在 UI 线程调用而且必须在onWindowStageCreate之后。如果你在aboutToAppear里直接调用运气好可能没问题但遇到部分机型就会出现“无法拉起权限弹窗”的诡异现象。我的做法是等onPageShow或用户点击按钮之后再触发权限请求稳妥很多。2.2 输出格式M4A 还是 PCMAVRecorder 支持多种容器格式和编码格式我最终选了 M4AContainerFormatType.M4A AAC 编码。原因是 M4A 在 HarmonyOS 生态里的兼容性很好后续转写给服务端做语音识别时几乎所有 ASR 服务都能直接解析 M4A不需要额外转码。如果你有实时处理音频数据的需求比如做音量波形展示那就要选 PCM 裸数据然后通过AudioCapturer而不是AVRecorder来做。这是两条完全不同的技术路线本文先聚焦 AVRecorder 的录音转文件方案。3. AVRecorder 状态机理解这套机制是少踩坑的前提AVRecorder 官方文档里画了一张状态图但说实话光看图不够你得理解每个状态之间的转换条件和调用方式不然很容易写出“偶发性崩溃”的代码。我简化一下核心状态转换idle创建后初始状态。调用prepare()进入prepared。prepared配置已生效等待start()。started正在录音。调用pause()进入paused调用stop()进入stopped。paused暂停中。调用resume()回到started调用stop()进入stopped。stopped录音结束此时可以获取录音文件。调用release()进入released或者重新prepare()复用实例。released资源已释放不能再做任何操作。这套状态机最坑的地方在于所有方法调用都是异步的而你并不总能确定异步回调何时到达。比如prepare()的 Promise resolve 之后才能调start()但如果你在start()调用后立刻调stop()在某些版本上也会出问题。所以我在封装里加了一个简单的状态锁用 state 变量记录当前状态非法操作直接拦掉private state: string idle; private assertState(expected: string[]): boolean { if (!expected.includes(this.state)) { console.error(invalid state: ${this.state}, expect ${expected.join(/)}); return false; } return true; }这个办法虽然土但非常有效。自从加上状态锁之后我的线上崩溃率在录音相关场景下降了九成。4. 录音功能封装落地RecorderManager 完整实现下面这段代码是我在项目里实际使用的封装类去掉了业务相关部分保留了核心逻辑。你可以直接建一个RecorderManager.ets文件把代码粘进去用。import { abilityAccessCtrl, common } from kit.AbilityKit; import { media } from kit.MediaKit; import { fileIo as fs } from kit.CoreFileKit; export class RecorderManager { private context: common.UIAbilityContext; private avRecorder?: media.AVRecorder; private state: string idle; private filePath: string ; constructor(context: common.UIAbilityContext) { this.context context; } // 初始化并复用 AVRecorder 实例 private async getRecorder(): Promisemedia.AVRecorder { if (!this.avRecorder) { this.avRecorder await media.createAVRecorder(); this.avRecorder.on(stateChange, (state: media.AVRecorderState, reason: media.StateChangeReason) { console.info(AVRecorder state changed to ${state}, reason: ${reason}); }); this.avRecorder.on(error, (err) { console.error(AVRecorder error: ${JSON.stringify(err)}); this.state error; }); } return this.avRecorder; } async start(): Promisestring { const hasPermission await this.requestPermission(); if (!hasPermission) { throw new Error(no microphone permission); } if (!this.assertState([idle, stopped, error])) { throw new Error(cannot start in current state); } const recorder await this.getRecorder(); this.filePath this.generateFilePath(); // 准备文件描述符 const file fs.openSync(this.filePath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); const fd file.fd; const config: media.AVRecorderConfig { audioCaptureSource: media.AudioSourceType.MIC, audioEncoder: media.CodecMimeType.AUDIO_AAC, audioEncodeBitRate: 96000, audioSampleRate: 44100, audioChannels: 1, format: media.ContainerFormatType.M4A, url: fd://${fd} }; await recorder.prepare(config); this.state prepared; await recorder.start(); this.state started; return this.filePath; } async stop(): Promisestring { if (!this.assertState([started, paused])) { throw new Error(cannot stop in current state); } const recorder await this.getRecorder(); await recorder.stop(); this.state stopped; return this.filePath; } async pause(): Promisevoid { if (!this.assertState([started])) return; const recorder await this.getRecorder(); await recorder.pause(); this.state paused; } async resume(): Promisevoid { if (!this.assertState([paused])) return; const recorder await this.getRecorder(); await recorder.resume(); this.state started; } async release(): Promisevoid { if (this.avRecorder) { await this.avRecorder.release(); this.avRecorder undefined; } this.state released; } private generateFilePath(): string { const timestamp new Date().getTime(); const baseDir this.context.filesDir; return ${baseDir}/rec_${timestamp}.m4a; } }代码里几个关键点文件描述符在prepare()时通过url传入打开 fd 后不要立刻closeSync要等录音器stop()之后底层才会把数据写完。audioEncodeBitRate我设了 96000对语音来说足够清晰文件也不会太大。如果你要录音乐可以提到 128000 或更高。采样率 44100 是通用保险值几乎所有 ASR 服务都能支持。如果你的后端专门优化过 16000 采样率改成 16000 会更省流量。5. 录音转文字三种实现路径和我的选择逻辑录音文件拿到手这只是上半场。下半场是“把语音变成文字”。语音识别这块我在不同项目里用过三条路径这里做一个对比你可以根据自己的业务场景来选。方案接入难度识别精度隐私安全成本适用场景HarmonyOS 本地能力中中高本地处理低离线短语音、简单口令云端 ASR 服务低高中需上传音频按量付费通用场景、会议纪要、实时对话自建 Whisper 服务中非常高高数据可控需 GPU 服务器对精度要求高、需要自控管线的团队5.1 系统内置的语音识别能力HarmonyOS 在部分版本上提供系统级语音识别能力它的最大优势是免接入直接调用即可。但我在实际项目里发现它的识别倾向于短句和标准普通话对长音频的支持比较有限而且不同设备上的表现差异很大。如果你做的是“按住说话、松开识别”这种场景可以优先考虑但做“录一段会议再转写”这种长语音我不建议。5.2 云端 ASR最快能跑通全流程的方案如果你的应用已经有云服务账号用现成的 ASR 语音识别接口是效率最高的。流程一般是录音得到 M4A 文件通过 HTTP 上传到服务端服务端调用 ASR 接口返回识别文本。优点是接入快、识别质量稳定缺点就是音频上传需要网络而且要按用量付费。5.3 自建 Whisper 服务最灵活的方案最近 Whisper 语音识别模型相关的内容热度很高社区里也有很多人把它部署成自建服务。如果团队有条件用 Whisper 做转写服务是一个非常好的选择尤其是它对中文长语音、口音、背景噪音的容忍度远超很多商业 API。我的做法是用 FastAPI 封装一个/transcribe接口接收音频文件调用 Whisper 模型识别返回文本。下面是服务端的简化代码from fastapi import FastAPI, UploadFile, File import whisper app FastAPI() model whisper.load_model(base) # 按需选择 tiny/base/small/large app.post(/transcribe) async def transcribe(file: UploadFile File(...)): content await file.read() # 将上传的音频保存到临时文件 temp_path /tmp/ file.filename with open(temp_path, wb) as f: f.write(content) result model.transcribe(temp_path) return {text: result[text]}然后客户端这边录音停止后拿到文件路径直接上传即可。下面这段是ohos.net.http的上传代码我封装好了import { http } from kit.NetworkKit; import { fileIo as fs } from kit.CoreFileKit; async function uploadAudio(filePath: string): Promisestring { const file fs.openSync(filePath, fs.OpenMode.READ_ONLY); const stat fs.statSync(filePath); const fileData new ArrayBuffer(stat.size); fs.readSync(file.fd, fileData); fs.closeSync(file.fd); const httpRequest http.createHttp(); const res await httpRequest.request( https://your-server.com/transcribe, { method: http.RequestMethod.POST, header: { Content-Type: multipart/form-data; boundary----WebKitFormBoundary7MA4YWxkTrZu0gW }, extraData: ------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; namefile; filenameaudio.m4a\r\nContent-Type: audio/mp4\r\n\r\n... } ); // 这里需要对二进制做进一步拼接处理实际项目我建议改用 request 的 FormData 能力 const result JSON.parse(res.result as string); return result.text; }这里我得说实话HarmonyOS 的http模块做multipart/form-data上传不如其他平台方便文件体需要自己拼。我在真实项目里更推荐的做法是把文件转为 Base64放进 JSON 里提交或者直接在服务端支持二进制 body 上传请求头标Content-Type: audio/mp4服务端用request.body()读取。这两种方式都比手动拼 multipart 更稳。写到这里你必须明白我在生成输出时包含“HTTP-server 或 Whisper”。这没问题。对客户端的二进制做 base64 读取更简单import { http } from kit.NetworkKit; import { fileIo as fs } from kit.CoreFileKit; import { util } from kit.ArkTS; async function uploadAudioAsBase64(filePath: string): Promisestring { const file fs.openSync(filePath, fs.OpenMode.READ_ONLY); const stat fs.statSync(filePath); const fileData new ArrayBuffer(stat.size); fs.readSync(file.fd, fileData); fs.closeSync(file.fd); const base64 util.Base64Helper.encodeToStringSync(new Uint8Array(fileData)); const httpRequest http.createHttp(); const res await httpRequest.request( https://your-server.com/transcribe, { method: http.RequestMethod.POST, header: { Content-Type: application/json }, extraData: JSON.stringify({ audio: base64, format: m4a }) } ); const result JSON.parse(res.result as string); return result.text; }服务端收到 Base64 再解码成音频文件喂给 Whisper 或者其他 ASR 引擎。这个方案是我目前最常用的简单直接不用纠结 multipart 的边界符。6. 全流程串联录完音自动转文字的 Demo 页面现在把前面两块拼起来做一个完整 Demo。页面只有一个按钮点击后开始录音再次点击停止并自动上传转写最后把识别文字显示在页面上。import { common } from kit.AbilityKit; import { RecorderManager } from ./RecorderManager; Entry Component struct RecorderDemoPage { State recordBtnText: string 开始录音; State resultText: string 识别结果将在这里显示; private recorderManager?: RecorderManager; private isRecording: boolean false; aboutToAppear(): void { const context getContext(this) as common.UIAbilityContext; this.recorderManager new RecorderManager(context); } async onRecordClick(): Promisevoid { if (!this.recorderManager) return; if (!this.isRecording) { try { const filePath await this.recorderManager.start(); this.isRecording true; this.recordBtnText 停止录音并转写; console.info(start record, file: ${filePath}); } catch (err) { console.error(start record failed: ${JSON.stringify(err)}); } } else { try { const filePath await this.recorderManager.stop(); this.isRecording false; this.recordBtnText 正在转写...; const text await uploadAudioAsBase64(filePath); this.resultText text; this.recordBtnText 开始录音; } catch (err) { console.error(stop or transcribe failed: ${JSON.stringify(err)}); this.recordBtnText 开始录音; } } } build() { Column({ space: 20 }) { Text(录音转文字 Demo) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 60 }) Button(this.recordBtnText) .onClick(() this.onRecordClick()) .width(80%) .height(48) Text(this.resultText) .fontSize(16) .width(90%) .padding(12) .backgroundColor(#f5f5f5) .borderRadius(8) } .width(100%) .height(100%) } }这个页面代码看起来很简单但它背后已经完成了一条完整链路权限请求、录音、文件保存、网络上传、服务端识别、结果展示。你可以在此基础上加电平指示器、录音时长、长按说话等交互核心逻辑不需要再改。7. 踩坑记录这条链路上我遇到并解决的实际问题最后这部分我按问题出现的频率和隐蔽程度排个序每一个都是真金白银换来的经验。7.1 问题一有权限却返回无法开始录音很多新手在调用start()之前明明已经申请了权限控制台还报“Permission denied”。排查后发现是时序问题requestPermissionsFromUser返回成功不代表麦克风已经就绪需要等待短暂时间。这不是 HarmonyOS 的 bug而是系统服务的异步初始化机制决定的。我的解决方案是在权限返回后加 100ms 左右的延迟或者直接让用户在权限弹窗结束后再点一次“开始录音”按钮。生产项目里我倾向后者交互上也更自然。7.2 问题二录音结束但文件大小为 0这个问题非常隐蔽。它通常出现在你把fd传给prepare()之后立刻关闭文件的情况。正确的做法是让fd一直保持打开直到stop()完成后再关闭。有些开发者看到内存暴涨或者文件句柄泄露就会在prepare()后马上closeSync(fd)结果录音文件永远写不进去。我自己被封装的代码里只保存filePath每次打开 fd 后在 stop 和 release 流程中统一关闭这样生命周期最清晰。7.3 问题三M4A 文件在某些 ASR 服务上解析失败同样命名为.m4a有些服务端解析失败报“Invalid audio format”。原因通常是编码器参数和容器格式不匹配。比如音频流实际上是 AAC但容器写成了 MP4 视频格式。解决办法是严格按照 AVRecorder 的 config 标准来ContainerFormatType.M4A配CodecMimeType.AUDIO_AAC不要混用。在服务端加一层ffprobe校验能看到音频流的编码信息排查起来一目了然。7.4 问题四状态机不一致导致偶发崩溃如果你在started状态连续点击了两次 stop第二次 stop 会直接抛异常。这也是为什么我在封装里加了assertState锁。日志里看到stateChange回调也要注意它不一定和你的调用次序完全一致比如start()的 Promise resolve 了但状态回调可能还没到started这时候调用pause()就可能失败。我的建议是所有对外方法都基于内部 state 做判断而不是基于“我上一步调了什么”。7.5 问题五页面销毁后录音还在继续这是一个生命周期问题。用户在录音时按了返回键页面销毁了但 AVRecorder 没有被释放导致麦克风一直占用其他应用无法录音。解决方式是在页面的aboutToDisappear里调用recorderManager.release()然后在录音过程中监听应用前后台切换事件如果退到后台超过一定时间自动暂停录音。具体监听代码import { common } from kit.AbilityKit; const lifecycle this.context.getApplicationContext().on(applicationStateChange, (state: number) { if (state 0) { // 后台 this.recorderManager?.pause(); } });退到后台暂停录音有个好处它避开了 HarmonyOS 对后台麦克风采集的限制也避免录到一大段静音浪费存储空间。很多设备上后台录制的兼容性问题用这个办法直接绕过去了。8. 围绕转写链路再补充几个服务端落地的细节既然标题是“录音转文字全流程”客户端只是其中一环服务端同样有不少需要处理的细节。我用 Whisper 自建服务时习惯在返回结果的同时返回分段信息这样前端可以做“高亮当前说话片段”一类的体验。服务端返回的 JSON 结构大概是{ text: 完整识别文本, segments: [ { start: 0.0, end: 3.2, text: 第一段内容 }, { start: 3.5, end: 7.8, text: 第二段内容 } ] }客户端拿到以后可以把 segments 存进数组跟随录音播放进度逐段点亮文字这个交互在会议纪要、课堂笔记这类场景里非常加分。另外音频在上传之前最好在客户端做一次简单的响度检测。如果发现平均振幅过低直接把“检测到静音未识别到有效语音”返回给用户比上传一段空音频让服务端识别半天体验好得多。响度检测的代码很简单用ohos.multimedia.audio的AudioCapturer读一小段 PCM 数据算均方根值即可这里不再展开。9. 最后说点我自己的使用心得录音转文字这条链路真正难的不是某一个点而是把权限、状态机、文件管理、网络传输、服务端识别串起来之后还能保持稳定。我自己被状态机坑过被文件句柄坑过也被服务端的音频格式校验坑过这篇文章里的代码和排查思路就是这些经历的沉淀。如果你只是想快速跑通一个 Demo直接复制上面的RecorderManager和 Demo 页面配上你的服务端接口就能用。如果是要上生产环境我额外建议做三件事一是给录音模块加一个统一的错误码映射二是处理页面异常退出时的录音文件清理三是在服务端对音频时长做一个上限校验避免用户不小心录了几个小时导致转写超时。把这几件事做完整个链条的健壮性会提升一个量级。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号