HarmonyOS 6 SDK对接实战:从原生ASR到Copilot SDK(上)- 手写语音识别实现
-
- 社区资料陈旧,我选择自己造轮子
- 一、框架选型复盘
- 二、ASR模块架构设计
- 三、实战记录
- 踩坑记录
-
- 坑一:识别结果不准
- [坑二:writeAudio 报错](#坑二:writeAudio 报错)
- 坑三:快速点击导致引擎异常
- 关于"造轮子"的一些思考
社区资料陈旧,我选择自己造轮子
去年下半年,我开始做「旅行回忆盲盒」------一款鸿蒙原生应用。玩法其实挺简单:在地图上标记你的旅行足迹,每到一个地方就能创建一个「回忆盲盒」,把当时的照片、心情和故事都装进去。
其中一个核心功能是 AI 助手,用户可以直接问它路线、查攻略、回忆旅行点滴。

第一个版本只支持文本输入,但一个体验好的 AI 助手,应该支持多模态交互------ASR(语音识别)、TTS(语音合成)这些都是标配。所以在设计之初,我翻遍了官网文档和各大社区的教程。
当时发现一个尴尬的问题:几乎所有能找到的资料,都指向自己集成官方的 Core Speech Kit。没有现成的封装组件,没有开箱即用的方案,只有一堆零散的 API 调用示例。(很多三方社区的文章,要么是时效性太低了,要么就是来回借鉴只堆API不给你解决方案,导致实际开发过程效率很低。)

没有合适的其他资料:我就动手鸿蒙官方现成的语音识别 API ,翻了下文档speechRecognizer 模块,看着挺简单,几个 API 就能搞定。
估摸着半天就能搞定的事,结果硬生生干了一周。
一、框架选型复盘
在复盘前,先进入一个小插曲,介绍几个常见的概念:
-
ASR(Automatic Speech Recognition,自动语音识别):把用户说的话转成文字,也就是我们常说的"语音转文字"
-
TTS(Text-to-Speech,文本转语音):把 AI 回复的文字读出来,让助手能"开口说话"
-
VAD(Voice Activity Detection,语音活动检测):检测用户什么时候开始说话、什么时候说完。这个功能看似简单,但做不好就会出现"我说完了它还在等"或者"我刚开口就被截断了"的尴尬
说到 VAD,这周在跟公司项目方对接时还闹了个笑话。对方提了一个需求:要做一个定制的 VAD 功能,能根据说话内容自动判断结束点。我解释了半小时,对方还是不太理解 VAD 到底是什么。
后来我给他打了个比方:VAD 就像地铁自动门------你走到门口,它感应到你来了,就开门等你进去;你进去了,它等几秒,确认没人要上了,再关门。如果这个感应做不好,要么你还没到门口它就把门关了,要么你进去了它还一直开着,等半天才关。 这么一说,对方瞬间就懂了。

回归正题,当时选技术方案的时候其实没太多纠结:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 鸿蒙原生 ASR | 系统级支持、免费、文档齐全 | 功能相对基础 |
| 第三方服务(讯飞/百度) | 功能强大、识别率高 | 需要集成 SDK、有费用 |
既然是鸿蒙原生应用,用系统自带的 API 最自然。官方文档写得那么详细,照着抄总不会错吧?
于是我就这么翻开了 HarmonyOS 官方文档,开始了"抄作业之旅"。
现在回头看 ,这个选择让我深刻体会到了"造轮子"和"用轮子"的区别。如果当时知道华为还有个 @hw-agconnect/copilot SDK,我可能就不会花这一周时间去手搓代码了。
但话说回来,正是这次经历,让我把语音识别的工作原理摸了个透。后来迁移到 SDK 的时候,我能清楚地知道它在背后帮我做了什么。
💡 阅读建议 :如果你需要做的是一个AI相关的助手 App,需要完整的AI助手语音输入功能,可以直接跳到下篇看 SDK 集成方案。但如果你想知道语音识别到底是怎么工作的,或者将来可能需要深度定制,那这篇手写实现的经历应该对你有帮助。
二、ASR模块架构设计
动手之前,我先理清了语音识别要解决的几个核心问题:
- 权限:麦克风权限怎么拿?
- 采集:音频数据怎么从麦克风取?
- 识别:音频怎么变成文字?
这三点理清楚了,剩下的就是怎么把它们串起来,塞进我的 AI 助手聊天界面里。
我理想中的交互是这样的:用户在地图上点开 AI 助手,点击麦克风按钮,对着手机说"帮我规划一条青岛三日游的路线",然后屏幕上实时显示出这行文字,点击发送,AI 开始思考,最后把答案读出来。
所以整个流程应该是:用户说话 → 采集音频 → 识别成文字 → 显示在输入框 → 发送给 AI → AI 回复 → TTS 读出来。
基于 HarmonyOS 的 API,我画了这样一个架构图:
音频采集层
语音识别引擎层
业务控制层
UI层
麦克风按钮
识别结果显示
引擎生命周期管理
识别流程控制
结果处理
createEngine
setListener
startListening
writeAudio
finish/shutdown
麦克风权限检查
音频参数配置
实时数据采集
代码模块划分如下:
| 模块 | 文件 | 职责 |
|---|---|---|
| 应用入口 | EntryAbility.ts |
动态申请麦克风权限 |
| 主界面 | Index.ets |
UI 交互、引擎管理、流程控制 |
| 实时录音 | AudioCapturer.ts |
封装音频采集 API,实时获取麦克风数据 |
| 文件识别 | FileCapturer.ts |
从本地 PCM 文件读取音频(调试用) |
| 采集接口 | ICapturerInterface.ts |
定义采集器统一接口 |
| 常量定义 | AsrConstants.ts |
VAD 参数、事件码等常量 |
| 工具类 | Util.ts |
延时等工具方法 |
规划看起来挺清晰,但真正写代码的时候才发现------文档里写的"简单几步"背后,藏着无数细节。
三、实战记录

第一步:麦克风权限
先按文档在 module.json5 里声明权限:
json5
// entry/src/main/module.json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:microphone_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
然后在 EntryAbility.ts 里动态申请:
typescript
// entry/src/main/ets/entryability/EntryAbility.ts
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
const atManager = abilityAccessCtrl.createAtManager();
atManager.requestPermissionsFromUser(this.context, ['ohos.permission.MICROPHONE'])
.then((data) => {
if (data.authResults[0] === 0) {
console.info('麦克风权限申请成功');
}
})
.catch((err) => {
console.error(`权限申请失败: ${err.message}`);
});
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
console.error(`加载页面失败: ${err.message}`);
}
});
}
}
这一步还算顺利,权限弹窗正常弹出来了。
第二步:创建语音识别引擎
在 Index.ets 里创建引擎。这里有两种写法,我习惯用 Promise:
typescript
// entry/src/main/ets/pages/Index.ets
import { speechRecognizer } from '@kit.CoreSpeechKit';
@Entry
@Component
struct Index {
@State generatedText: string = '';
@State isRecording: boolean = false;
private asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null;
private sessionId: string = 'session_' + Date.now();
// 创建引擎
private async createEngine(): Promise<boolean> {
let extraParam: Record<string, Object> = {
"locate": "CN", // 地区设置
"recognizerMode": "short" // 短语音模式
};
let initParamsInfo: speechRecognizer.CreateEngineParams = {
language: 'zh-CN', // 中文识别
online: 1, // 在线模式(需要网络)
extraParams: extraParam
};
try {
this.asrEngine = await speechRecognizer.createEngine(initParamsInfo);
console.info('语音识别引擎创建成功');
return true;
} catch (err) {
console.error(`创建引擎失败: ${err.message}`);
return false;
}
}
}
参数说明:
language: 'zh-CN':中文识别,改'en-US'就是英文online: 1:在线模式,识别率更高,但需要网络recognizerMode: 'short':短语音模式,适合单次对话
引擎有了,还得给它加监听器,让它拿到结果后告诉我:
typescript
// 设置识别监听器
private setListener() {
if (!this.asrEngine) return;
let listener: speechRecognizer.RecognitionListener = {
// 识别开始
onStart: (sessionId: string, eventMessage: string) => {
console.info(`识别开始`);
this.generatedText = ''; // 清空上次结果
},
// 事件回调(音量变化、检测到人声等)
onEvent: (sessionId: string, eventCode: number, eventMessage: string) => {
// 可以根据 eventCode 更新 UI,比如显示音量波形
},
// 识别结果回调(核心)
onResult: (sessionId: string, res: speechRecognizer.SpeechRecognitionResult) => {
let isFinal: boolean = res.isFinal; // 是否为最终结果
let result: string = res.result; // 识别文本
console.info(`识别结果: ${result}`);
// 更新 UI 显示
this.generatedText = result;
},
// 识别完成
onComplete: (sessionId: string, eventMessage: string) => {
console.info(`识别完成`);
this.isRecording = false;
},
// 错误回调
onError: (sessionId: string, errorCode: number, errorMessage: string) => {
console.error(`识别错误: ${errorMessage}`);
this.isRecording = false;
promptAction.showToast({
message: `识别失败: ${errorMessage}`
});
}
};
this.asrEngine.setListener(listener);
}
第三步:音频采集(最折腾的部分)
新建 AudioCapturer.ts,从麦克风拿音频数据:
typescript
// entry/src/main/ets/pages/AudioCapturer.ts
import audio from '@ohos.multimedia.audio';
export default class AudioCapturer {
private mAudioCapturer: audio.AudioCapturer | null = null;
private mDataCallBack: ((data: ArrayBuffer) => void) | null = null;
private mCanWrite: boolean = false;
// 音频参数配置(这个不能错!)
private audioStreamInfo: audio.AudioStreamInfo = {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, // 16kHz
channels: audio.AudioChannel.CHANNEL_1, // 单声道
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, // 16bit 小端
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW // PCM 原始格式
};
private audioCapturerInfo: audio.AudioCapturerInfo = {
source: audio.SourceType.SOURCE_TYPE_MIC, // 麦克风源
capturerFlags: 0
};
private audioCapturerOptions: audio.AudioCapturerOptions = {
streamInfo: this.audioStreamInfo,
capturerInfo: this.audioCapturerInfo
};
public async init(dataCallBack: (data: ArrayBuffer) => void): Promise<void> {
this.mDataCallBack = dataCallBack;
this.mAudioCapturer = await audio.createAudioCapturer(this.audioCapturerOptions);
}
public async start(): Promise<void> {
if (!this.mAudioCapturer) return;
this.mCanWrite = true;
await this.mAudioCapturer.start();
// 循环读取音频数据
while (this.mCanWrite && this.mAudioCapturer) {
try {
let bufferSize = await this.mAudioCapturer.getBufferSize();
let buffer = await this.mAudioCapturer.read(bufferSize, true);
if (buffer && this.mDataCallBack) {
this.mDataCallBack(buffer); // 回调传递音频数据
}
} catch (err) {
console.error(`读取音频数据失败: ${err}`);
break;
}
}
}
public async stop(): Promise<void> {
this.mCanWrite = false;
if (this.mAudioCapturer) {
await this.mAudioCapturer.stop();
}
}
public async release(): Promise<void> {
if (this.mAudioCapturer) {
await this.mAudioCapturer.release();
this.mAudioCapturer = null;
}
}
}
⚠️ 重要提醒 :音频参数必须跟引擎要求的格式完全一致(16kHz、单声道、16bit),否则
writeAudio会报错。我一开始没注意这个,折腾了好半天才发现问题。
第四步:把音频喂给引擎

模拟器调试,已经可以监听到设备了。
现在把采集器和引擎连起来:
typescript
// Index.ets 中的录音方法
private async startRecording() {
if (!this.asrEngine) return;
// 设置监听器
this.setListener();
// 配置识别参数
let audioParam: speechRecognizer.AudioInfo = {
audioType: 'pcm',
sampleRate: 16000,
soundChannel: 1,
sampleBit: 16
};
let extraParam: Record<string, Object> = {
"recognitionMode": 0,
"vadBegin": 2000, // 等待 2 秒开始说话
"vadEnd": 3000, // 静音 3 秒后结束
"maxAudioDuration": 20000 // 最长 20 秒
};
let recognizerParams: speechRecognizer.StartParams = {
sessionId: this.sessionId,
audioInfo: audioParam,
extraParams: extraParam
};
// 启动识别引擎
this.asrEngine.startListening(recognizerParams);
this.isRecording = true;
// 启动音频采集
this.mAudioCapturer = new AudioCapturer();
await this.mAudioCapturer.init((dataBuffer: ArrayBuffer) => {
// 把音频数据写入引擎
let uint8Array: Uint8Array = new Uint8Array(dataBuffer);
this.asrEngine?.writeAudio(this.sessionId, uint8Array);
});
await this.mAudioCapturer.start();
}
这里有两个关键参数:vadBegin 和 vadEnd。前者是等待用户开始说话的时长,后者是检测到静音后等待结束的时长。这两个参数调好了,用户体验会好很多。我反复测试了好几次,才定下 2000ms 和 3000ms 这个组合。
录音结束后别忘记释放资源:
typescript
private async stopRecording() {
if (!this.asrEngine) return;
this.isRecording = false;
// 停止音频采集
if (this.mAudioCapturer) {
await this.mAudioCapturer.stop();
await this.mAudioCapturer.release();
this.mAudioCapturer = null;
}
// 结束识别会话
this.asrEngine.finish(this.sessionId);
}
// 页面销毁时关闭引擎
private shutdownEngine() {
if (this.asrEngine) {
this.asrEngine.shutdown();
this.asrEngine = null;
}
}
扩展:文件识别(调试神器)
为了方便调试,不用每次测试都对着手机说话,我加了一个从本地 PCM 文件读取音频的功能:
typescript
// entry/src/main/ets/pages/FileCapturer.ts
import fileIo from '@ohos.file.fs';
export default class FileCapturer {
private mIsWriting: boolean = false;
private mFilePath: string = '';
private mFile: fileIo.File | null = null;
private mDataCallBack: ((data: ArrayBuffer) => void) | null = null;
async start() {
if (!this.mFilePath) return;
this.mIsWriting = true;
this.mFile = fileIo.openSync(this.mFilePath, fileIo.OpenMode.READ_ONLY);
const CHUNK_SIZE = 1280; // 40ms 音频数据大小
let buf: ArrayBuffer = new ArrayBuffer(CHUNK_SIZE);
let offset: number = 0;
while (CHUNK_SIZE == fileIo.readSync(this.mFile.fd, buf, { offset: offset })
&& this.mIsWriting) {
if (this.mDataCallBack) {
this.mDataCallBack(buf);
}
// 延时 40ms,模拟实时发送速率
await this.sleep(40);
offset += CHUNK_SIZE;
}
fileIo.closeSync(this.mFile);
this.mIsWriting = false;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
🤔 为什么每次只读 1280 字节,还要延时 40ms?
因为语音识别引擎期望以接近实时的速率接收音频数据。如果一次性把所有数据都塞给它,引擎可能缓冲不过来,导致识别失败。1280 字节刚好是 40ms 的音频数据量,加上 40ms 延时,就能完美模拟实时录音的效果。
踩坑记录
坑一:识别结果不准
现象:我说"青岛三日游",识别成"青岛三人游"。
原因:VAD 参数没调好,前端点太短,只采集到了后半段语音。
解决 :把 vadBegin 从 1000ms 调整到 2000ms,给用户更充裕的准备时间。
坑二:writeAudio 报错
现象 :调用 writeAudio 时提示格式错误。
原因:采集器的音频参数和引擎期待的参数不一致。
解决:统一参数配置,确保两边的采样率、声道、位深完全一致。
坑三:快速点击导致引擎异常
现象 :快速点击开始/停止,控制台报 Engine is busy。
原因:上一次识别还没完全结束,新的识别又开始了。
解决 :增加状态检查,用 isBusy() 判断引擎是否空闲。
typescript
if (this.asrEngine.isBusy()) {
promptAction.showToast({ message: '请稍后再试' });
return;
}
关于"造轮子"的一些思考
代码跑通的那一刻,说不爽是假的。但冷静下来想想,这套实现其实有不少问题:
代码量大。近千行代码分布在 多 个文件里,每次修改都要在几个文件间跳转,维护成本不低。
边界情况多。权限拒绝、网络异常、引擎状态异常......每个都要单独处理,测试工作量很大。
与业务耦合。ASR 逻辑和 UI 混在一起,代码可读性一般。
扩展性差。如果要加新功能,比如支持多语言、长语音识别,得改不少代码。
就在我觉得这套实现还行的时候,偶然发现了华为新出的 @hw-agconnect/copilot SDK。看了一眼文档,我愣住了------语音输入功能已经内置了,而且只需要几行配置。
当时我的第一反应是:这一周白干了?
但仔细想想,其实也不是。正因为有了这周的手写经历,我对语音识别的工作流程、参数含义、常见问题都有了深刻理解。后来迁移到 SDK 时,我知道每个配置项是干什么的,遇到问题也能快速定位。
更重要的是,这次经历让我明白了一个道理:如果你想快速实现功能,直接用现成的 SDK 是最香的。但如果你有时间、有好奇心,从底层自己实现一遍,会让你对技术的理解上一个台阶。(另外就是现在鸿蒙相关资料的搜索引擎权重太低了,没法快速找到想要的资料,希望生态也会越来越好,这样我们开发者也可以从中受益。)
那么话不多说,在下一篇文章中,我会分享如何用 Copilot SDK 改造这套代码。