HarmonyOS 6 SDK对接实战:从原生ASR到Copilot SDK(上)- 手写语音识别实现

HarmonyOS 6 SDK对接实战:从原生ASR到Copilot SDK(上)- 手写语音识别实现

社区资料陈旧,我选择自己造轮子

去年下半年,我开始做「旅行回忆盲盒」------一款鸿蒙原生应用。玩法其实挺简单:在地图上标记你的旅行足迹,每到一个地方就能创建一个「回忆盲盒」,把当时的照片、心情和故事都装进去。

其中一个核心功能是 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模块架构设计

动手之前,我先理清了语音识别要解决的几个核心问题:

  1. 权限:麦克风权限怎么拿?
  2. 采集:音频数据怎么从麦克风取?
  3. 识别:音频怎么变成文字?

这三点理清楚了,剩下的就是怎么把它们串起来,塞进我的 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();
}

这里有两个关键参数:vadBeginvadEnd。前者是等待用户开始说话的时长,后者是检测到静音后等待结束的时长。这两个参数调好了,用户体验会好很多。我反复测试了好几次,才定下 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 改造这套代码。

相关推荐
讯方洋哥4 小时前
HarmonyOS App开发——鸿蒙ArkTS端云一体化的云函数实现机制
harmonyos
木斯佳17 小时前
HarmonyOS 6 三方SDK对接:从半接模式看Share Kit原理——系统分享的运行机制与设计理念
设计模式·harmonyos·架构设计·分享·半接模式
被温水煮的青蛙18 小时前
HarmonyOS openCustomDialog 实战:从入门到理解原理
harmonyos
高一学习c++会秃头吗18 小时前
鸿蒙适应式布局和响应式布局零基础
harmonyos
HwJack2019 小时前
HarmonyOS应用开发中EmbeddedUIExtensionAbility:跨进程 UI 嵌入的“幕后导演“
ui·华为·harmonyos
早點睡39021 小时前
ReactNative项目鸿蒙化三方库集成实战:react-native-calendars(日历展开和日程模块存在兼容性问题)
react native·react.js·harmonyos
嗡嗡嗡qwq1 天前
【如何使用vscode+github copilot会更加省额度】
vscode·github·copilot
云和数据.ChenGuang1 天前
鸿蒙 + ChromaDB:端侧向量检索,打造全场景智能应用新范式
华为·harmonyos·鸿蒙
前端不太难1 天前
AI + 鸿蒙游戏,会不会是下一个爆点?
人工智能·游戏·harmonyos