HarmonyOS APP《画伴梦工厂》开发第31篇-语音识别实战——SpeechRecognitionEngine+AudioCapturer

第4.5篇:语音识别实战------SpeechRecognitionEngine + AudioCapturer

难度:⭐⭐⭐ 高级

前置知识 :第 4.1 篇 canIUse 系统能力检测

涉及源文件products/default/src/main/ets/services/VoiceRecognitionService.ets


一、概述

语音识别是"画伴梦工厂"中用户与 AI 交互的核心入口之一。在创作首页的聊天界面中,用户可以通过语音说出想创作的角色和场景,系统将其转化为文本后发送给 AI 模型生成内容。

HarmonyOS 提供了完整的语音识别能力栈------@kit.CoreSpeechKit 中的 speechRecognizer 模块负责语音到文本的转换,@kit.AudioKit 中的 audio.AudioCapturer 负责音频数据采集,二者协同工作,构成了从物理麦克风到文本输出的完整链路。

本文将深入解析 VoiceRecognitionService 的实现,涵盖 SpeechRecognitionEngine 的创建与配置、AudioCapturer 的音频采集参数、回调解读、音频循环读取机制以及离线/在线模式的双引擎 fallback 策略。


二、服务接口设计:回调驱动的异步架构

在深入技术细节之前,先来看 VoiceRecognitionService 对外暴露的接口设计。整个服务采用回调模式 ,调用方通过传入一个 VoiceRecognitionCallbacks 对象来接收识别过程中的各种事件:

typescript 复制代码
export interface VoiceRecognitionCallbacks {
  onText: (text: string, isFinal: boolean) => void;
    onStatus: (text: string) => void;
      onError: (message: string) => void;
        onListeningChange: (listening: boolean) => void;
        }
        ```
| 回调 | 触发时机 | 说明 |
|------|----------|------|
| `onText` | 收到识别结果时 | `isFinal` 标记是否为最终结果,可用于实时显示中间过程 |
| `onStatus` | 状态变更时 | 用于显示"正在聆听""检测到语音"等提示文字 |
| `onError` | 发生错误时 | 携带格式化后的错误信息,由 UI 层展示 |
| `onListeningChange` | 录音启动/停止时 | 驱动 UI 层录音按钮的显隐和状态切换 |

这种回调驱动的设计让 `VoiceRecognitionService` 成为一个纯逻辑服务,与 UI 完全解耦------它不需要知道调用方是 ArkUI 组件还是其他服务,只需按照约定通知即可。

---

## 三、SpeechRecognitionEngine 创建与双模式 Fallback

### 3.1 创建引擎

语音识别的第一步是创建 `SpeechRecognitionEngine` 实例。HarmonyOS 通过 `speechRecognizer.createEngine` 方法创建引擎,并支持两种运行模式:

```typescript
const SPEECH_MODE_ONLINE: number = 0;
const SPEECH_MODE_OFFLINE: number = 1;
  • 在线模式(online: 0:音频数据上传到云端处理,识别精度更高、词汇量更大,但需要网络连接。
    • 离线模式(online: 1:本地设备端处理,响应更快、无需网络,但识别精度和词汇量受限于本地模型。

3.2 双模式 Fallback 策略

项目中的 createSpeechEngine 方法实现了一个精巧的 fallback 策略------优先尝试离线模式,失败后自动切换到在线模式

typescript 复制代码
private async createSpeechEngine(
  callbacks: VoiceRecognitionCallbacks
  ): Promise<speechRecognizer.SpeechRecognitionEngine> {
    const modes: SpeechEngineMode[] = [
        { mode: SPEECH_MODE_OFFLINE, label: '离线' },
            { mode: SPEECH_MODE_ONLINE,  label: '在线' }
              ];
                let lastErrorMessage: string = '';
                  for (let i = 0; i < modes.length; i++) {
                      callbacks.onStatus('正在启动' + modes[i].label + '语音识别');
                          try {
                                return await speechRecognizer.createEngine({
                                        language: 'zh-CN',
                                                online: modes[i].mode
                                                      });
                                                          } catch (error) {
                                                                lastErrorMessage = modes[i].label + '模式' +
                                                                        this.formatError(error as BusinessError, '创建失败');
                                                                            }
                                                                              }
                                                                                throw new Error('语音识别引擎创建失败,' + lastErrorMessage);
                                                                                }
                                                                                ```
**设计考量**:

| 策略 | 原因 |
|------|------|
| **离线优先** | 用户体验优先------离线模式响应更快,无需等待网络请求,适合儿童互动场景 |
| **在线兜底** | 当设备未预置离线语音模型时,自动降级到在线模式,保证功能可用 |
| **用户透明** | 两种模式的切换对调用方完全透明,通过 `onStatus` 通知 UI 层当前模式 |

### 3.3 setListener 注册回调

引擎创建成功后,需要通过 `setListener` 注册事件监听器,接收引擎返回的识别结果和状态:

```typescript
this.engine = await this.createSpeechEngine(callbacks);
this.engine.setListener(this.createListener(callbacks));

这一行代码建立了引擎到服务层的单向通信通道------引擎在识别过程中产生的所有事件都会通过 RecognitionListener 转发给 VoiceRecognitionCallbacks,进而驱动 UI 更新。


四、AudioCapturer 音频采集配置

AudioCapturer(音频采集器)负责从设备麦克风采集原始音频数据。语音识别对音频格式有严格要求,createCapturerOptions 方法精确配置了这些参数:

typescript 复制代码
private createCapturerOptions(): audio.AudioCapturerOptions {
  return {
      streamInfo: {
            samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000,
                  channels: audio.AudioChannel.CHANNEL_1,
                        sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
                              encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
                                  },
                                      capturerInfo: {
                                            source: audio.SourceType.SOURCE_TYPE_VOICE_RECOGNITION,
                                                  capturerFlags: 0
                                                      }
                                                        };
                                                        }
                                                        ```
### 参数详解

| 参数 | 值 | 说明 |
|------|-----|------|
| `samplingRate` | `SAMPLE_RATE_16000`(16kHz) | 语音识别领域的标准采样率,16kHz 在人声频段(300Hz~3400Hz)之上留有充足余量,同时数据量仅为 44.1kHz 的 1/3 |
| `channels` | `CHANNEL_1`(单声道) | 人声定位无关紧要,单声道即可满足识别需求,数据量减半 |
| `sampleFormat` | `SAMPLE_FORMAT_S16LE`(16位有符号小端) | 每个采样点用 16 位整数表示,这也是 PCM 音频最常见的格式 |
| `encodingType` | `ENCODING_TYPE_RAW`(原始 PCM) | 不压缩、不封装,直接输出裸 PCM 数据流 |
| `source` | `SOURCE_TYPE_VOICE_RECOGNITION` | 使用语音识别专用的音频源,系统会对音频链路做针对性优化(如降噪、回声消除) |

**为什么要用 16kHz / S16LE / 单声道**?

回顾音频数据量公式:

数据率 = 采样率 × 声道数 × 位深度

复制代码
代入参数:`16000 × 1 × 16 = 256,000 bps ≈ 32 KB/s`。每 20ms 的数据块仅为 640 字节------这正是代码中 `AUDIO_CHUNK_SIZE = 640` 的由来。这个小巧的数据块大小非常适合实时流式传输,既不会因为太大而增加延迟,也不会因为太小而引入过多系统调用开销。

---

## 五、音频循环读取:从麦克风到引擎

### 5.1 启动流程

在 `start` 方法中,各个组件的启动顺序是精心设计的:

```typescript
// 1. 创建引擎
this.engine = await this.createSpeechEngine(callbacks);
// 2. 注册监听器
this.engine.setListener(this.createListener(callbacks));
// 3. 创建音频采集器
this.capturer = await audio.createAudioCapturer(this.createCapturerOptions());
// 4. 通知引擎准备接收音频
this.engine.startListening({
  sessionId: this.sessionId,
    audioInfo: { audioType: 'pcm', sampleRate: SAMPLE_RATE, soundChannel: 1, sampleBit: 16 }
    });
    // 5. 启动麦克风采集
    await this.capturer.start();
    // 6. 进入循环读取
    this.readAudioLoop(callbacks);
    ```
关键点在于:**必须先调用 `engine.startListening` 通知引擎准备接收数据,然后才启动麦克风**。如果顺序颠倒,会出现引擎尚未就绪、音频数据已经到达的情况,导致丢帧。

### 5.2 循环读取实现

`readAudioLoop` 是整个语音识别的主动脉------它持续从麦克风读取 PCM 数据块,并实时写入引擎:

```typescript
private async readAudioLoop(callbacks: VoiceRecognitionCallbacks): Promise<void> {
  while (this.recording && this.capturer !== null && this.engine !== null) {
      try {
            const buffer: ArrayBuffer = await this.capturer.read(AUDIO_CHUNK_SIZE, true);
                  if (this.recording && this.engine !== null && buffer.byteLength === AUDIO_CHUNK_SIZE) {
                          this.engine.writeAudio(this.sessionId, new Uint8Array(buffer));
                                }
                                    } catch {
                                          if (this.recording) {
                                                  callbacks.onError('麦克风采集失败,请重新尝试');
                                                          callbacks.onListeningChange(false);
                                                                  await this.destroy();
                                                                        }
                                                                              return;
                                                                                  }
                                                                                    }
                                                                                    }
                                                                                    ```
**循环的三重安全检查**:

while 条件:this.recording && this.capturer !== null && this.engine !== null

if 内部 :this.recording && this.engine !== null && buffer.byteLength === AUDIO_CHUNK_SIZE

```

每一层都在确认"我还在录音、硬件还没释放、数据也是完整的"。这种"双重校验"模式在异步并发场景下尤为关键------stop()destroy() 可能在任意时刻被外部调用,循环体必须随时能够安全退出。

5.3 read 方法的参数

typescript 复制代码
this.capturer.read(AUDIO_CHUNK_SIZE, true);

read 方法接受两个参数:

  • 第一个参数(size:期望读取的字节数,这里是 640 字节。
    • 第二个参数(isBlocking :是否阻塞等待。true 表示如果没有足够数据就阻塞等待直到数据到达。在语音场景中,音频数据是持续产生的,使用阻塞模式可以避免 CPU 空转轮询。

5.4 writeAudio:数据投喂

typescript 复制代码
this.engine.writeAudio(this.sessionId, new Uint8Array(buffer));

ArrayBuffer 需要转换为 Uint8Array 才能被引擎识别。writeAudio 将 PCM 数据块按会话 ID 送入引擎内部的识别流水线,引擎会在内部进行端点检测(VAD)、特征提取和解码。


六、RecognitionListener 回调解读

RecognitionListener 是引擎向服务层发送事件的通道。项目中实现的 createListener 方法将引擎原生事件映射为业务友好的回调:

typescript 复制代码
private createListener(
  callbacks: VoiceRecognitionCallbacks
  ): speechRecognizer.RecognitionListener {
    const service: VoiceRecognitionService = this;
      return {
          onStart(sessionId: string, eventMessage: string): void {
                callbacks.onStatus('正在聆听');
                    },
                        onEvent(sessionId: string, eventCode: number, eventMessage: string): void {
                              if (eventCode === 1) {
                                      callbacks.onStatus('检测到语音');
                                            } else if (eventCode === 3) {
                                                    callbacks.onStatus('正在整理识别结果');
                                                          }
                                                              },
                                                                  onResult(sessionId: string, result: speechRecognizer.SpeechRecognitionResult): void {
                                                                        if (result.result !== '') {
                                                                                callbacks.onText(result.result, result.isFinal || result.isLast);
                                                                                      }
                                                                                            if (result.isLast) {
                                                                                                    service.recording = false;
                                                                                                            callbacks.onListeningChange(false);
                                                                                                                  }
                                                                                                                      },
                                                                                                                          onComplete(sessionId: string, eventMessage: string): void {
                                                                                                                                service.recording = false;
                                                                                                                                      callbacks.onStatus('识别完成');
                                                                                                                                            callbacks.onListeningChange(false);
                                                                                                                                                  service.releaseEngine();
                                                                                                                                                      },
                                                                                                                                                          onError(sessionId: string, errorCode: number, errorMessage: string): void {
                                                                                                                                                                service.recording = false;
                                                                                                                                                                      callbacks.onError('语音识别失败,请重新尝试');
                                                                                                                                                                            callbacks.onListeningChange(false);
                                                                                                                                                                                  service.releaseCapturer();
                                                                                                                                                                                        service.releaseEngine();
                                                                                                                                                                                            }
                                                                                                                                                                                              };
                                                                                                                                                                                              }
                                                                                                                                                                                              ```
### 回调生命周期

时间线 →

│ onStart │ onEvent │ onResult │ onComplete

│ (聆听中) │ (检测到语音)│ (识别结果) │ (识别完成)

用户说话 → 静音 ──┬──→ 引擎启动 ──→ VAD 检测 ──→ 解码输出 ──→ 结束

│ │ │ │

│ eventCode=1 │ eventCode=3│ │

│ "检测到语音" │ "整理结果" │ │

```

回调 触发时机 业务处理
onStart 引擎开始监听音频 通知 UI 显示"正在聆听"状态
onEvent 引擎内部状态变化 eventCode=1 表示检测到语音开始(VAD 触发),eventCode=3 表示用户停止说话、开始整理结果
onResult 产生识别文本 将文本通过 onText 回调逐字输出,isFinal/isLast 标记最终结果
onComplete 完整会话结束 释放引擎资源,通知 UI 识别完成
onError 识别过程出错 释放采集器和引擎资源,通知 UI 错误信息

关于 isFinal 与 isLast

onResult 中可以看到这样一行代码:

typescript 复制代码
callbacks.onText(result.result, result.isFinal || result.isLast);

引擎可能会在识别过程中返回多个中间结果(partial result),每个中间结果都带有 isFinal = false,最终结果则标记为 true。不同版本的 SDK 可能使用 isFinalisLast 字段,代码中同时检查两者以保证兼容性。


七、资源管理与生命周期

语音识别涉及多个系统级资源,必须精确管理其生命周期。

7.1 资源层级

复制代码
VoiceRecognitionService
  ├── SpeechRecognitionEngine  ← 系统 AI 服务,需要 shutdown()
    └── AudioCapturer            ← 硬件麦克风,需要 stop() + release()
    ```
无论是正常结束还是异常退出,两条资源的释放路径分别是:

**正常结束(onComplete)**:

onComplete → releaseEngine()(shutdown)

capturer 由服务 stop() 方法统一释放

复制代码
**异常退出(onError)**:

onError → releaseCapturer()(stop + release)

→ releaseEngine()(shutdown)

```

7.2 stop 与 destroy

服务提供两种结束方式:

typescript 复制代码
async stop(): Promise<void> {
  if (!this.recording) return;
    this.recording = false;
      await this.releaseCapturer();
        if (this.engine !== null && this.sessionId !== '') {
            try {
                  this.engine.finish(this.sessionId);  // 正常结束会话
                      } catch {
                            this.releaseEngine();
                                }
                                  }
                                  }
async destroy(): Promise<void> {
  this.recording = false;
    await this.releaseCapturer();
      if (this.engine !== null && this.sessionId !== '') {
          try {
                this.engine.cancel(this.sessionId);  // 强制取消会话
                    } catch {}
                      }
                        this.releaseEngine();
                          this.sessionId = '';
                          }
                          ```
| 方法 | 语义 | 适用场景 |
|------|------|----------|
| `stop()` | 优雅停止 | 用户主动停止录音------调用 `finish` 让引擎处理完已收到的音频并返回最终结果 |
| `destroy()` | 强制销毁 | 页面退出或发生错误------调用 `cancel` 立即终止,不等待结果 |

### 7.3 releaseCapturer 与 releaseEngine 的防御性编程

两个 release 方法都采用了典型的"防御性释放"模式:

```typescript
private async releaseCapturer(): Promise<void> {
  const currentCapturer: audio.AudioCapturer | null = this.capturer;
    this.capturer = null;                // 先置空,防止并发重复释放
      if (currentCapturer === null) return;
        try { await currentCapturer.stop(); } catch {}    // 忽略已停止的错误
          try { await currentCapturer.release(); } catch {} // 忽略已释放的错误
          }
          ```
关键技巧:**先置空成员变量,再操作局部引用**。这样即使 `releaseCapturer` 被并发调用两次,第二次调用时 `this.capturer` 已经为 `null`,会直接返回,不会重复释放。

---

## 八、错误格式化与用户反馈

项目中实现的 `formatError` 方法用于统一格式化 BusinessError:

```typescript
private formatError(error: BusinessError, fallback: string): string {
  let message: string = fallback;
    if (error.message !== '') {
        message = error.message;
          }
            if (error.code > 0) {
                return message + ',错误码:' + error.code.toString();
                  }
                    return message;
                    }
                    ```
它处理了三种情况:
- **有错误码 + 有错误消息**:如"麦克风被占用,错误码:202"
- - **仅有错误消息**:直接使用系统返回的消息
- - **无任何信息**:使用调用方提供的 fallback 文案兜底
这种设计确保用户永远不会看到空白的错误提示或原始的 Error stack。

---

## 九、完整数据流时序

下面是语音识别从启动到结束的完整数据流:

用户点击"语音"按钮

VoiceRecognitionService.start()

├── canIUse('SystemCapability.AI.SpeechRecognizer') ─── 不支持 → 抛异常

├── createSpeechEngine()

│ ├── 离线模式尝试 ── 成功 → 引擎就绪

│ └── 离线失败 ──→ 在线模式尝试 ── 成功 → 引擎就绪

├── engine.setListener(listener) ← 注册回调

├── audio.createAudioCapturer() ← 创建采集器

├── engine.startListening() ← 引擎就绪

├── capturer.start() ← 麦克风启动

└── readAudioLoop() ← 进入循环

├── capturer.read(640) ──→ buffer ← 从麦克风读取 640 字节 PCM

│ │

│ └── engine.writeAudio(sessionId, uint8Array) ← 写入引擎

├── 循环 ← 持续到 recording = false

├── engine.onResult ──→ onText(partial, false) ← 中间结果

├── engine.onResult ──→ onText("我要画一只猫", true) ← 最终结果

├── engine.onComplete → onStatus("识别完成")

│ onListeningChange(false)

│ releaseEngine()

└── capturer.stop() + release()

```

关键数据路径

复制代码
麦克风模拟信号
    │ audio capturer(16kHz / S16LE / 单声道)
        ▼
        640 字节 PCM 数据块(每 ~20ms 一个)
            │ capturer.read()
                ▼
                Uint8Array
                    │ engine.writeAudio()
                        ▼
                        SpeechRecognitionEngine(VAD + 特征提取 + 解码)
                            │ onResult 回调
                                ▼
                                识别文本 → onText(text, isFinal)
                                ```
---

## 十、与项目中的 canIUse 集成

本文的实现与第 4.1 篇介绍的 canIUse 能力检测紧密关联。在 `start` 方法的第一行关键检测:

```typescript
if (!canIUse(SPEECH_RECOGNIZER_CAPABILITY)) {
  throw new Error('当前设备不支持系统语音识别能力');
  }

SPEECH_RECOGNIZER_CAPABILITY 常量值为 'SystemCapability.AI.SpeechRecognizer',表示语音识别系统能力。canIUse 会在启动全部资源之前就完成检测,如果设备不支持,直接抛异常,避免后续创建引擎和采集器的无效开销。

这种"先检测、后操作"的模式与服务的错误处理机制无缝衔接------canIUse 失败和引擎创建失败最终都会走到同一个 catch 分支,统一释放资源并通知 UI。


总结

本文深入解析了"画伴梦工厂"中语音识别功能的完整实现,覆盖了从麦克风到文本输出的全链路:

知识点 实现方式
引擎创建与配置 speechRecognizer.createEngine 支持离线/在线双模式
模式 fallback 离线优先,在线兜底,用户无感切换
音频采集配置 AudioCapturer 16kHz / S16LE / 单声道 / 语音识别源
音频循环读取 capturer.read(640, true) 阻塞读取 + engine.writeAudio 投喂
回调处理 RecognitionListener → VoiceRecognitionCallbacks 逐层转发
资源管理 stop 优雅结束 / destroy 强制取消,防御性释放模式
能力检测 canIUse 前置检查,避免无效资源创建
错误处理 BusinessError 格式化封装,提供友好的用户反馈

语音识别是典型的"管道式"系统能力------从硬件(麦克风)到系统服务(AudioCapturer),再到 AI 服务(SpeechRecognitionEngine),最终输出结构化文本。理解这条管道的每个节点,是掌握 HarmonyOS 多媒体与 AI 能力集成的关键。

下一篇: 第 4.6 篇将介绍语音识别的状态管理------如何使用异步状态机管理空闲、录音中、识别中、错误四种状态,以及 stop/destroy 的完整资源释放策略。


参考源码

本文所有代码均来自项目文件:

  • products/default/src/main/ets/services/VoiceRecognitionService.ets --- 语音识别服务完整实现,包含引擎创建、音频采集、循环读取、回调映射和资源管理
相关推荐
TrisighT3 小时前
Electron 鸿蒙 PC 上点外链唤醒应用,我试了 6 种写法只有 1 种能跑
前端·electron·harmonyos
TrisighT4 小时前
Electron 跑鸿蒙 PC 上,这 4 个 API 的行为跟 Windows 完全不一样——但文档一行都没写
windows·electron·harmonyos
蓝速科技6 小时前
蓝速科技 RISC-V 鸿蒙信创工控终端深度评测
科技·harmonyos·risc-v
TrisighT1 天前
DevEco Code 写鸿蒙 ArkTS 确实快,但我试了三天后把默认引擎换成了 Cursor
ai编程·harmonyos·cursor
liz7up1 天前
鸿蒙原生流程图 & 审批流组件 hmflowkit
harmonyos
网易云信2 天前
全框架覆盖!网易智企IM鸿蒙生态适配再进一步
人工智能·aigc·harmonyos
TrisighT2 天前
我用 AI 逆向了 ArkTS @Builder 的编译产物,看完再也不敢乱写嵌套了
ai编程·harmonyos·arkts
ONEDAY3 天前
HarmonyOS 深色模式适配实践:从资源、WebView 到网络图统一处理
harmonyos
鸿蒙开发4 天前
鸿蒙(HarmonyOS NEXT)表单校验别再手撸正则了 —— 我写了个 ArkTS 版 zod
harmonyos