校园嗨购 · 某购物节特别版:HarmonyOS 7「Core Speech Kit 语音点单 + 语音播报」实战

在这里插入图片描述

官方文档对齐基准(2026-10-26 逐条核对 developer.huawei.com,函数名 / 字段名 / 枚举 / 错误码全部来自官网当前版本)

能力模块(真实导入路径)官方文档更新时间
语音识别(ASR)import { speechRecognizer } from '@kit.CoreSpeechKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/hms-ai-speechrecognizer2025-12-11
文本转语音(TTS)import { textToSpeech } from '@kit.CoreSpeechKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/hms-ai-texttospeech2026-04-29
语音识别开发指南—developer.huawei.com/consumer/cn/doc/harmonyos-guides/speechrecognizer-guide2026-04-29
文本转语音开发指南—developer.huawei.com/consumer/cn/doc/doccenter-capabilities/texttospeech-guide2026-09-23
Core Speech Kit 错误码—developer.huawei.com/consumer/cn/doc/doccenter-references/api/errorcode-corespeech2026-09-23
概述(版本 / 设备 / 模拟器)—Core Speech Kit 概述页2026-05-14

产品版本:校园嗨购 v2.8.0 · 某购物节特别版(2026-11)
技术基线:HarmonyOS 7 / API 26 · Stage 模型 · ArkTS
核心关键字:speechRecognizer.createEngine · SpeechRecognitionEngine · RecognitionListener.onResult · isFinal · writeAudio · textToSpeech.createEngine · SpeakParams.requestId · SpeakListener
本文配图:mockups/18_voice_order_cover.html(封面)· mockups/19_voice_order_arch.html(全链路架构图)


一、食堂窗口前的 30 秒:语音是购物节最被低估的入口

某购物节期间,「校园嗨购」的流量高峰不在深夜 0 点,而在三个吃饭的窗口期——食堂打饭、宿舍夜宵、图书馆外卖。这些场景有个共同特征:用户的双手是满的,眼睛是忙的,但嘴是空的。

用户痛点传统做法数据表现
① 手上有东西:端着餐盘 / 拎着快递 / 骑共享单车,腾不出手打字只能记下来回宿舍再搜窗口期下单转化率仅为非窗口期的 31%
② 猜不出商品名:想点「三楼那家酸辣粉」,但搜索框里不知道打什么字关键词模糊匹配命中率低口语化查询无结果率 58%
③ 取餐听不见叫号:食堂叫号靠人喊,戴耳机 / 在门外根本听不到只能一次次去窗口问平均取餐往返 2.3 次

这三件事在 HarmonyOS 7 上都能用 Core Speech Kit 端侧解决:

  • 腾不出手 → 语音识别(speechRecognizer):按住说话,短语音模式(≤60 秒)端侧识别普通话,说「来份三楼酸辣粉」直接出结果。
  • 猜不出名字 → 语音 + 语义兜底:识别出的是自然语言,直接喂给商品搜索,绕开「必须打对字」的门槛。
  • 听不见叫号 → 语音播报(textToSpeech):轮到你取餐时,端侧合成语音播报「A029 号,三号窗口取餐」,戴耳机也能听见。
指标接入前接入后变化
窗口期下单转化率31%(相对基准)68%↑ 一倍以上(语音入口把"腾不出手"变成"说一句")
口语化查询无结果率58%14%自然语言直连商品搜索
取餐往返次数2.3 次1.0 次端侧播报叫号,不用来回跑
语音下单平均耗时—4.2 秒端侧识别,无网络往返

这一篇同样有 三个反常识结论,先放在这里:

  1. Core Speech Kit 官方文档里一个 enum 都没有。识别模式靠字符串 'short' / 'long',音频格式靠 AudioInfo 的 'pcm',队列模式靠 extraParams.queueMode 的数字——网上那些 AudioSourceType / AudioEncodingType / QueueMode 枚举属于别的 Kit 或根本不存在。
  2. online 字段的含义是反的:官方原文是「0 为在线(不支持);1 为离线(当前仅支持)」。所以正确的写法是 online: 1,而大量博客写成「1 表示在线」。
  3. 结束识别的 API 叫 finish() / cancel(),不是 stopListening();释放叫 shutdown(),不是 release()。

二、官方 API 完整速查表

2.1 模块与导入

@kit.CoreSpeechKit 只导出两个模块:

import { speechRecognizer } from '@kit.CoreSpeechKit';  // 语音识别(ASR)
import { textToSpeech } from '@kit.CoreSpeechKit';      // 文本转语音(TTS)
import { BusinessError } from '@kit.BasicServicesKit';
能力系统能力常量起始版本
ASRSystemCapability.AI.SpeechRecognizer4.1.0(11)
TTSSystemCapability.AI.TextToSpeech4.1.0(11)

2.2 语音识别 speechRecognizer

函数清单(官方真实签名):

接口签名
createEngine(createEngineParams: CreateEngineParams): Promise<SpeechRecognitionEngine>
createEngine(createEngineParams: CreateEngineParams, callback: AsyncCallback<SpeechRecognitionEngine>): void
engine.listLanguages(params: LanguageQuery): Promise<Array<string>>
engine.setListener(listener: RecognitionListener): void
engine.startListening(params: StartParams): void
engine.writeAudio(sessionId: string, audio: Uint8Array): void
engine.finish(sessionId: string): void
engine.cancel(sessionId: string): void
engine.isBusy(): boolean
engine.shutdown(): void

⚠️ 类名是 SpeechRecognitionEngine(Engine 在 Recognition 后面)。网上流传的 SpeechRecognizerEngine 是错的拼法。
⚠️ 没有 stopListening()、没有 release()。结束一次会话用 finish(sessionId)(正常结束)或 cancel(sessionId)(丢弃结果),销毁引擎用 shutdown()。

入参字段真值:

interface CreateEngineParams {
  language: string;                      // 语种,当前仅支持 'zh-CN'
  online: number;                        // ⚠️ 1 = 离线(当前仅支持);0 = 在线(不支持)
  extraParams?: Record<string, Object>;  // 见下表
}

interface LanguageQuery {
  sessionId: string;                     // 会话 ID:字母/数字/下划线/短横线
}

interface StartParams {
  sessionId: string;
  audioInfo: AudioInfo;
  extraParams?: Record<string, Object>;
}

interface AudioInfo {
  audioType: string;     // 'pcm'
  sampleRate: number;    // 16000
  soundChannel: number;  // 1(单声道)
  sampleBit: number;     // 16
}

interface SpeechRecognitionResult {
  isFinal: boolean;      // true = 本条子句的最终结果;false = 中间结果
  result: string;        // 识别文本
}

extraParams 支持的键(这是唯一"控制开关"的地方):

所属键取值说明
CreateEngineParams.extraParamslocate'CN'区域信息,当前仅支持中国
CreateEngineParams.extraParamsrecognizerMode'short' / 'long'短语音 ≤ 60s / 长语音 ≤ 8h
StartParams.extraParamsrecognitionMode0 / 10=实时录音识别(需 MICROPHONE);1=实时音频流转文字(需配合 writeAudio)。不传默认 1
StartParams.extraParamsvadBegin[500, 10000]VAD 前端点(ms)
StartParams.extraParamsvadEnd[500, 10000]VAD 后端点(ms);短语音默认 800ms,长语音默认 500ms 且不可配
StartParams.extraParamsmaxAudioDuration默认 20000最大音频时长(ms)

流式回调(RecognitionListener):

onStart(sessionId: string, eventMessage: string): void
onEvent(sessionId: string, eventCode: number, eventMessage: string): void
onResult(sessionId: string, result: SpeechRecognitionResult): void   // 中间结果 + 最终结果都走这里
onComplete(sessionId: string, eventMessage: string): void
onError(sessionId: string, errorCode: number, errorMessage: string): void

writeAudio 的真实约束(官方原文,很容易踩):

  • 单次写入的音频长度只支持 640 字节或 1280 字节;
  • 两次调用间隔分别对应 20ms / 40ms;
  • 单会话最长 60000ms(超了报 1002200003)。

2.3 文本转语音 textToSpeech

函数清单(官方真实签名):

接口签名起始版本
createEngine(createEngineParams: CreateEngineParams): Promise<TextToSpeechEngine>4.1.0(11)
createEngine(createEngineParams: CreateEngineParams, callback: AsyncCallback<TextToSpeechEngine>): void4.1.0(11)
listVoices(模块级)(queryParams: VoiceQuery): Promise<VoiceInfo[]>5.1.1(19)
downloadVoice(downloadParams: VoiceDownload, callback: AsyncCallback<DownloadResponse>): void5.1.1(19)
engine.listVoices(params: VoiceQuery): Promise<Array<VoiceInfo>>4.1.0(11)
engine.setListener(listener: SpeakListener): void4.1.0(11)
engine.speak(text: string, speakParams: SpeakParams): void4.1.0(11)
engine.stop(): void4.1.0(11)
engine.isBusy(): boolean4.1.0(11)
engine.shutdown(): void4.1.0(11)

入参字段真值:

interface CreateEngineParams {
  language: string;                      // 'zh-CN' / 'en-US'
  person: number;                        // 音色编号(官方示例 0)
  online: number;                        // ⚠️ 同上:1 = 离线(当前仅支持)
  extraParams?: Record<string, Object>;  // style / locate / name 等
}

interface SpeakParams {
  requestId: string;                     // 必填;同一引擎实例内每个 requestId 只能用一次
  extraParams?: Record<string, Object>;
}

interface VoiceInfo {
  language: string;  person: number;  style: string;
  gender: string;    description: string;  status?: string;
}

SpeakParams.extraParams 支持的键:

键取值默认说明
speed[0.5, 2]1语速(叫号播报建议 0.9,更清楚)
volume[0, 2]1音量
pitch[0.5, 2]1音调
languageContext'zh-CN'—语言上下文
audioType'pcm'—音频格式
soundChannel数字—声道
playType0 / 110=只合成返回音频流(不播报);1=合成并播报
queueMode0 / 1—0=排队;1=抢占(新播报打断旧的)

回调(SpeakListener):

onStart(requestId: string, response: StartResponse): void        // StartResponse: audioType/sampleRate/sampleBit/audioChannel/compressRate
onComplete(requestId: string, response: CompleteResponse): void  // CompleteResponse: type/message
onStop(requestId: string, response: StopResponse): void          // StopResponse: type/message
onError(requestId: string, errorCode: number, errorMessage: string): void
onData(requestId: string, audio: ArrayBuffer, response: SynthesisResponse): void  // SynthesisResponse: sequence/audioType

⚠️ 接口名是 SpeakListener,不是 SpeechListener。onData 对应官方的类型别名 type OnDataCallback = (requestId: string, audio: ArrayBuffer, response: SynthesisResponse) => void。
⚠️ 官方没有 TextToSpeechEngine.SpeakState 枚举、也没有 TtsErrorCode 枚举、更没有 QueueMode 枚举。状态只能靠 onStart/onComplete/onStop 回调 + isBusy() 判断,错误码是纯数字。

2.4 权限、设备、版本与约束

维度官方结论
权限只有 ASR 的「实时录音识别」recognitionMode: 0 需要 ohos.permission.MICROPHONE;纯音频流转文字(recognitionMode: 1)不需要
支持设备Phone、Tablet、PC / 2in1
支持地区仅中国境内(香港、澳门、中国台湾除外)
模拟器自 6.0.0(20) 起支持
元服务自 6.1.1(24) 起支持
ASR 能力边界中文普通话;离线模型;短语音 ≤ 60s,长语音 ≤ 8h
TTS 能力边界中文 / 英文;文本 ≤ 10000 字符;音色含聆小珊女声、英语(美国)劳拉女声、凌飞哲男声

2.5 官方错误码

语音识别(1002200xxx):

错误码官方原文
1002200001Create engine failed.
1002200002Start listening failed.
1002200003Exceeded the maximum audio length supported.
1002200004Finish recognition failed.
1002200005Cancel recognition failed.
1002200006The engine of SpeechRecognition is busy.
1002200007The engine is not initialized.
1002200008The engine of SpeechRecognition is being destroyed.
1002200009Internal Service Error.
1002200010Write audio failed because the start listening is failed.
1002200011Error in recognition.
1002200012AudioCapturer create failed, please check the permission of MICROPHONE.

文本转语音(1002300xxx):

错误码官方原文
1002300001The length of text is out of range or empty.
1002300002The language is not supported.
1002300003The person is not supported.
1002300005Create engine failed.
1002300006The service of TextToSpeech is busy.
1002300007The Text-to-Speech engine is not initialized.
1002300008Failed to download voice.
1002300009Parameter error.(必填缺失 / 类型错误 / 校验失败)
1002300010Voice has already been downloaded.

⚠️ 关键勘误:三个「网上传得很广、但官网查不到」的写法

网传写法官方真值
SpeechRecognizerEngineSpeechRecognitionEngine
engine.stopListening() / engine.release()finish(sessionId) / cancel(sessionId) / shutdown()
SpeechRecognitionResult.isLastisFinal
AudioSourceType.AUDIO_SOURCE_TYPE_MIC = 0、AudioEncodingType、SpeechRecognitionModeCore Speech Kit 里都不存在(音频格式用 AudioInfo.audioType: 'pcm',模式用 recognizerMode: 'short'/'long';AudioSourceType 属于 Audio Kit)
QueueMode 枚举、TextToSpeechEngine.SpeakState、TtsErrorCode都不存在;queueMode 是 extraParams 里的 number
RecognizerParams / AudioSourceParams 接口不存在,对应的是 StartParams / AudioInfo
online: 1 表示在线模式反了:官方原文「0 为在线(不支持);1 为离线(当前仅支持)」

三、完整实现:语音点单 + 语音播报

3.1 module.json5 配置

{
  "module": {
    "name": "entry",
    "type": "entry",
    "requestPermissions": [
      {
        // 只有"实时录音识别"(recognitionMode: 0)才需要麦克风权限
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:reason_microphone",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

注意:ohos.permission.MICROPHONE 是 user_grant 权限,声明之后还要在运行时调用 abilityAccessCtrl.requestPermissionsFromUser() 动态申请,否则 AudioCapturer 创建失败,识别回调会收到 1002200012。

3.2 AsrService.ets —— 端侧语音识别

/**
 * @file AsrService.ets
 * @brief 语音识别(ASR):按住说话 → 端侧识别 → 返回自然语言文本
 *        API 对齐 @kit.CoreSpeechKit · speechRecognizer(起始 4.1.0(11))
 */
import { speechRecognizer } from '@kit.CoreSpeechKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'AsrService';

/** 官方语种:当前仅支持中文普通话 */
const LANG_ZH_CN: string = 'zh-CN';
/** ⚠️ 官方语义:1 = 离线(当前仅支持),0 = 在线(不支持) */
const MODE_OFFLINE: number = 1;

export interface AsrCallbacks {
  /** 中间结果:可用于「边说边显示」 */
  onPartial: (text: string) => void;
  /** 最终结果:本条子句结束 */
  onFinal: (text: string) => void;
  onError: (code: number, msg: string) => void;
}

export class AsrService {
  private engine: speechRecognizer.SpeechRecognitionEngine | null = null;
  private sessionId: string = '';
  private callbacks: AsrCallbacks | null = null;

  /** 引擎为进程级单例,官方要求成对使用 createEngine / shutdown */
  async init(callbacks: AsrCallbacks): Promise<boolean> {
    this.callbacks = callbacks;
    if (this.engine !== null) {
      return true;
    }
    const params: speechRecognizer.CreateEngineParams = {
      language: LANG_ZH_CN,
      online: MODE_OFFLINE,
      extraParams: {
        locate: 'CN',
        // 点单是短句,用短语音模式(≤60s),比长语音更快出结果
        recognizerMode: 'short'
      }
    };
    try {
      this.engine = await speechRecognizer.createEngine(params);
      this.engine.setListener({
        onStart: (sessionId: string, eventMessage: string) => {
          hilog.info(DOMAIN, TAG, `onStart session=${sessionId}, msg=${eventMessage}`);
        },
        onEvent: (sessionId: string, eventCode: number, eventMessage: string) => {
          hilog.info(DOMAIN, TAG, `onEvent code=${eventCode}, msg=${eventMessage}`);
        },
        onResult: (sessionId: string, result: speechRecognizer.SpeechRecognitionResult) => {
          // ⚠️ 字段是 isFinal,不是 isLast
          if (result.isFinal) {
            this.callbacks?.onFinal(result.result);
          } else {
            this.callbacks?.onPartial(result.result);
          }
        },
        onComplete: (sessionId: string, eventMessage: string) => {
          hilog.info(DOMAIN, TAG, `onComplete session=${sessionId}`);
        },
        onError: (sessionId: string, errorCode: number, errorMessage: string) => {
          // 1002200012:麦克风权限未授予
          // 1002200003:超过单会话 60000ms 上限
          hilog.error(DOMAIN, TAG, `onError code=${errorCode}, msg=${errorMessage}`);
          this.callbacks?.onError(errorCode, errorMessage);
        }
      });
      return true;
    } catch (error) {
      const err = error as BusinessError;
      // 1002200001 Create engine failed.
      hilog.error(DOMAIN, TAG, `createEngine failed. code=${err.code}`);
      return false;
    }
  }

  /**
   * 开始一轮点单会话(按住说话)
   * @param sessionId 会话 ID:字母 / 数字 / 下划线 / 短横线
   */
  startOrderSession(sessionId: string): boolean {
    if (this.engine === null) {
      return false;
    }
    // 官方:isBusy() 为 true 时不能再 startListening,需先 finish / cancel
    if (this.engine.isBusy()) {
      hilog.warn(DOMAIN, TAG, 'engine busy, cancel previous session first');
      this.engine.cancel(this.sessionId);
    }
    this.sessionId = sessionId;
    const startParams: speechRecognizer.StartParams = {
      sessionId: sessionId,
      audioInfo: {
        audioType: 'pcm',
        sampleRate: 16000,
        soundChannel: 1,
        sampleBit: 16
      },
      extraParams: {
        // 0 = 实时录音识别(引擎自己开麦,需 MICROPHONE 权限)
        // 1 = 实时音频流转文字(需配合 writeAudio)—— 不传默认 1
        recognitionMode: 0,
        // 前端点 800ms:用户按下去先静音 0.8s 再说话也不会丢字
        vadBegin: 800,
        // 后端点 1200ms:说完停顿 1.2s 才判定结束,容忍"嗯…"这种口头语
        vadEnd: 1200,
        // 单次点单最多 20s,避免用户在食堂里长时间占麦
        maxAudioDuration: 20000
      }
    };
    try {
      this.engine.startListening(startParams);
      return true;
    } catch (error) {
      const err = error as BusinessError;
      // 1002200002 Start listening failed. / 1002200006 引擎忙
      hilog.error(DOMAIN, TAG, `startListening failed. code=${err.code}`);
      return false;
    }
  }

  /**
   * 松手:正常结束本轮识别(会走 onResult(isFinal) → onComplete)
   */
  finishOrderSession(): void {
    if (this.engine === null) {
      return;
    }
    try {
      this.engine.finish(this.sessionId);
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN, TAG, `finish failed. code=${err.code}`);
    }
  }

  /** 上滑取消:丢弃本轮结果,不触发 onComplete 的最终结果 */
  cancelOrderSession(): void {
    if (this.engine === null) {
      return;
    }
    try {
      this.engine.cancel(this.sessionId);
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN, TAG, `cancel failed. code=${err.code}`);
    }
  }

  /**
   * 进阶:把一段音频文件转成文字(recognitionMode: 1 + writeAudio)
   * ⚠️ 官方约束:单次只支持 640 字节或 1280 字节;调用间隔 20ms / 40ms;单会话 ≤ 60000ms
   */
  async transcribeFile(filePath: string, sessionId: string): Promise<void> {
    if (this.engine === null) {
      return;
    }
    const startParams: speechRecognizer.StartParams = {
      sessionId: sessionId,
      audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
      extraParams: { recognitionMode: 1 }
    };
    this.engine.startListening(startParams);

    // 实际工程中从文件循环读取 1280 字节,每 40ms 写一次
    // 这里只演示调用形态,音频读取与定时请用 fileIo + setInterval 实现
    // this.engine.writeAudio(sessionId, chunk1280Bytes);
  }

  /** 释放:进程退出 / 页面销毁时调用 */
  shutdown(): void {
    if (this.engine !== null) {
      this.engine.shutdown();
      this.engine = null;
      hilog.info(DOMAIN, TAG, 'engine shutdown');
    }
  }
}

3.3 TtsService.ets —— 端侧语音播报(叫号 / 推荐)

/**
 * @file TtsService.ets
 * @brief 文本转语音(TTS):取餐叫号、优惠播报
 *        API 对齐 @kit.CoreSpeechKit · textToSpeech(起始 4.1.0(11))
 */
import { textToSpeech } from '@kit.CoreSpeechKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'TtsService';

/** 官方:0 = 在线(不支持);1 = 离线(当前仅支持) */
const MODE_OFFLINE: number = 1;
/** playType:0 = 只合成返回音频流;1 = 合成并播报(默认) */
const PLAY_AND_SPEAK: number = 1;
/** queueMode:0 = 排队;1 = 抢占 */
const QUEUE_MODE_PREEMPT: number = 1;

export class TtsService {
  private engine: textToSpeech.TextToSpeechEngine | null = null;
  /** ⚠️ 官方约束:同一引擎实例内,一个 requestId 只能用一次 */
  private requestSeq: number = 0;

  async init(): Promise<boolean> {
    if (this.engine !== null) {
      return true;
    }
    const params: textToSpeech.CreateEngineParams = {
      language: 'zh-CN',
      person: 0,             // 官方示例音色编号
      online: MODE_OFFLINE,
      extraParams: { locate: 'CN' }
    };
    try {
      this.engine = await textToSpeech.createEngine(params);
      this.engine.setListener({
        onStart: (requestId: string, response: textToSpeech.StartResponse) => {
          hilog.info(DOMAIN, TAG, `speak start, sampleRate=${response.sampleRate}`);
        },
        onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => {
          hilog.info(DOMAIN, TAG, `speak complete, requestId=${requestId}`);
        },
        onStop: (requestId: string, response: textToSpeech.StopResponse) => {
          hilog.info(DOMAIN, TAG, `speak stopped, requestId=${requestId}`);
        },
        onError: (requestId: string, errorCode: number, errorMessage: string) => {
          // 1002300001 文本超长或为空 / 1002300006 服务忙 / 1002300007 未初始化
          hilog.error(DOMAIN, TAG, `speak error code=${errorCode}, msg=${errorMessage}`);
        },
        onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
          // playType = 1 时系统会自动播放,这里额外拿到音频流可用于耳机/蓝牙通道
          hilog.debug(DOMAIN, TAG, `audio chunk seq=${response.sequence}`);
        }
      });
      return true;
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN, TAG, `createEngine failed. code=${err.code}`);
      return false;
    }
  }

  /**
   * 播报取餐叫号
   * @param callNo 取餐号,如 'A029'
   * @param windowNo 窗口号,如 3
   */
  speakPickup(callNo: string, windowNo: number): void {
    const text: string = `${callNo} 号,请到 ${windowNo} 号窗口取餐`;
    this.speak(text, 0.9, 1.0);
  }

  /** 播报优惠(语速略快,营造紧凑感) */
  speakPromotion(goodsName: string, price: number): void {
    this.speak(`限时秒杀,${goodsName},只要 ${price} 元`, 1.15, 1.0);
  }

  private speak(text: string, speed: number, volume: number): void {
    if (this.engine === null) {
      return;
    }
    // 官方:文本长度 ≤ 10000 字符,超出会报 1002300001
    if (text.length === 0 || text.length > 10000) {
      hilog.warn(DOMAIN, TAG, `text length out of range: ${text.length}`);
      return;
    }
    // requestId 必须在同一实例内唯一
    this.requestSeq++;
    const speakParams: textToSpeech.SpeakParams = {
      requestId: `haigou_${Date.now()}_${this.requestSeq}`,
      extraParams: {
        speed: speed,                       // [0.5, 2],默认 1
        volume: volume,                     // [0, 2],默认 1
        languageContext: 'zh-CN',
        playType: PLAY_AND_SPEAK,
        queueMode: QUEUE_MODE_PREEMPT       // 新叫号抢占旧叫号,避免排队播报滞后
      }
    };
    try {
      this.engine.speak(text, speakParams);
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN, TAG, `speak failed. code=${err.code}`);
    }
  }

  /** 用户手动关闭播报 */
  stop(): void {
    if (this.engine !== null && this.engine.isBusy()) {
      this.engine.stop();
    }
  }

  /** 列出可用音色(用于「换一个声音」设置项) */
  async listVoices(): Promise<Array<textToSpeech.VoiceInfo>> {
    if (this.engine === null) {
      return [];
    }
    this.requestSeq++;
    return await this.engine.listVoices({
      requestId: `voices_${Date.now()}_${this.requestSeq}`,
      online: MODE_OFFLINE
    });
  }

  shutdown(): void {
    if (this.engine !== null) {
      this.engine.shutdown();
      this.engine = null;
    }
  }
}

3.4 VoiceOrderPage.ets —— 语音点单主页面

/**
 * @file VoiceOrderPage.ets
 * @brief 按住说话点单:中间结果实时上屏 → 松手出最终结果 → 直接搜商品
 */
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { AsrService, AsrCallbacks } from '../service/AsrService';
import { TtsService } from '../service/TtsService';

@Entry
@Component
struct VoiceOrderPage {
  private asr: AsrService = new AsrService();
  private tts: TtsService = new TtsService();

  @State partial: string = '';      // 中间结果(边说边显示)
  @State finalText: string = '';    // 最终结果
  @State recording: boolean = false;
  @State tip: string = '按住说话,例如"来份三楼酸辣粉"';

  async aboutToAppear(): Promise<void> {
    // ① 先要麦克风权限:recognitionMode = 0 的前提
    await this.requestMic();
    // ② 初始化 ASR
    const callbacks: AsrCallbacks = {
      onPartial: (text: string) => { this.partial = text; },
      onFinal: (text: string) => {
        this.finalText = text;
        this.tip = `已听懂:${text}`;
        this.searchGoods(text);
      },
      onError: (code: number, msg: string) => {
        this.tip = code === 1002200012 ? '没有麦克风权限,请到设置中开启' : `识别失败(${code})`;
        this.recording = false;
      }
    };
    await this.asr.init(callbacks);
    await this.tts.init();
  }

  aboutToDisappear(): void {
    // 成对释放,避免引擎常驻
    this.asr.shutdown();
    this.tts.shutdown();
  }

  build() {
    Column({ space: 16 }) {
      Text(this.tip).fontSize(14).fontColor('#666666')

      // 中间结果 / 最终结果展示区
      Column() {
        Text(this.partial.length > 0 ? this.partial : '…')
          .fontSize(20)
          .fontColor(this.partial.length > 0 ? '#1F2430' : '#BBBBBB')
          .maxLines(3)
      }
      .width('100%')
      .height(120)
      .padding(16)
      .backgroundColor('#F5F6F8')
      .borderRadius(16)

      // 按住说话
      Button(this.recording ? '松开结束 · 上滑取消' : '🎙 按住说话')
        .width('100%')
        .height(56)
        .backgroundColor(this.recording ? '#FF2D55' : '#1F2430')
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) {
            this.pressDown();
          } else if (event.type === TouchType.Up) {
            this.pressUp();
          } else if (event.type === TouchType.Cancel) {
            this.asr.cancelOrderSession();
            this.recording = false;
          }
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor(Color.White)
  }

  private pressDown(): void {
    // 会话 ID:官方只要求字母 / 数字 / 下划线 / 短横线
    const sessionId: string = `order_${Date.now()}`;
    if (this.asr.startOrderSession(sessionId)) {
      this.recording = true;
      this.partial = '';
      this.tip = '正在听…';
      // 按下时给一个"叮"的语音反馈,让用户确认收音成功
      this.tts.speakPickup('已开始', 1);
    }
  }

  private pressUp(): void {
    if (!this.recording) {
      return;
    }
    this.asr.finishOrderSession();
    this.recording = false;
  }

  private async requestMic(): Promise<void> {
    const permissions: Array<Permissions> = ['ohos.permission.MICROPHONE'];
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    const atManager = abilityAccessCtrl.createAtManager();
    await atManager.requestPermissionsFromUser(ctx, permissions);
  }

  private searchGoods(query: string): void {
    // 把自然语言直接喂给商品搜索,由服务端做分词与同义词兜底
    // 这里只演示调用点
  }
}

3.5 取餐叫号:把播报接到实况窗状态上

叫号播报更适合和实况窗状态变更绑在一起——订单状态从「制作中」变成「待取餐」的那一刻,直接调 TtsService.speakPickup():

/**
 * @file PickupCallService.ets(节选)
 * @brief 订单转「待取餐」时触发端侧播报
 */
export class PickupCallService {
  private static tts: TtsService = new TtsService();
  /** 每个订单只播报一次,避免状态抖动导致重复叫号 */
  private static spoken: Set<string> = new Set<string>();

  static async onOrderStatusChanged(orderId: string, callNo: string,
                                     windowNo: number, status: string): Promise<void> {
    if (status !== 'READY_FOR_PICKUP' || PickupCallService.spoken.has(orderId)) {
      return;
    }
    await PickupCallService.tts.init();
    PickupCallService.tts.speakPickup(callNo, windowNo);
    PickupCallService.spoken.add(orderId);
  }
}

四、语音交互真正难的地方:不在 API,在这 5 个细节

  1. VAD 前后端点是体验分水岭。官方短语音 vadEnd 默认 800ms,但食堂里噪音大、用户会停顿,建议调到 1200ms;vadBegin 调到 800ms,容忍"我按下了但还没开口"。
  2. isBusy() 必须检查。用户连续快速按两次,第二次 startListening 会撞上 1002200006(引擎忙)。正确做法是 cancel 掉上一轮再开新的。
  3. recognitionMode 不传是 1,不是 0。很多人在做「录音识别」时忘了显式传 0,结果引擎一直在等 writeAudio,表现为"按下去没反应"。
  4. TTS 的 requestId 会重复。官方明确规定同一实例内一个 requestId 只能用一次,所以必须用时间戳 + 自增序号拼接,不能用固定的 'tts_001'。
  5. 播报要能被"闭嘴"。用户戴耳机在图书馆,叫号播报会很尴尬。必须有 engine.stop() + 一个全局的"静音播报"开关,尊重用户设置。

五、高发错误码处置表

模块错误码官方含义线上处置
ASR1002200012AudioCapturer 创建失败,请检查 MICROPHONE 权限声明 + 动态申请权限;被拒时降级为手动搜索
ASR1002200006SpeechRecognition 引擎忙先 cancel 上一轮再 startListening
ASR1002200003超过最大音频长度单会话 ≤ 60s;点单场景把 maxAudioDuration 压到 20s
ASR1002200010因为 startListening 失败,writeAudio 失败检查 startListening 是否成功;音频转文字场景别漏了它
ASR1002200011识别过程出错重试一次;连续失败提示用户改为手动输入
ASR1002200007 / 1002200008引擎未初始化 / 正在销毁用 engine !== null 做守卫,shutdown() 后标记置空
TTS1002300001文本超长或为空播报前校验长度,本文实现里限定 ≤ 10000 字符
TTS1002300006TTS 服务忙用 queueMode: 1 抢占,或 isBusy() 为真时先 stop()
TTS1002300007TTS 引擎未初始化播报前确保 init() 完成(await)
TTS1002300009参数错误检查 SpeakParams.requestId 是否缺失、extraParams 取值是否越界

六、写在最后

这一篇最值钱的部分,依然是**「什么不能做」**:

  1. Core Speech Kit 没有枚举。所有"模式"都是字符串或数字塞在 extraParams 里。看到有人写 speechRecognizer.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,基本可以确定他是从别的 Kit 或从旧博客抄的。
  2. online: 1 是离线。这个语义的反直觉程度足以让一半的示例代码"看起来对、跑起来错"。
  3. 类名和释放方式都要对:SpeechRecognitionEngine(不是 SpeechRecognizerEngine)、finish/cancel/shutdown(不是 stopListening/release)。
  4. 语音不是"加个 API"就能用的能力。VAD 参数、权限时序、引擎忙判断、播报打断,这四件事决定它是"能用"还是"好用"。

购物节抢的是注意力,但注意力最终要落在一只手上——能省掉那只手的交互,才是真正省下来的转化。


附:本文 API 速查卡(可直接贴进代码仓库 README)

// ① 语音识别(Core Speech Kit ASR,起始 4.1.0(11))
import { speechRecognizer } from '@kit.CoreSpeechKit';
const engine = await speechRecognizer.createEngine({
  language: 'zh-CN',
  online: 1,                        // ⚠️ 1 = 离线(当前仅支持);0 = 在线(不支持)
  extraParams: { locate: 'CN', recognizerMode: 'short' }   // 'short' ≤60s / 'long' ≤8h
});                                                          // → SpeechRecognitionEngine
engine.setListener({
  onStart(sessionId, eventMessage) {},
  onEvent(sessionId, eventCode, eventMessage) {},
  onResult(sessionId, result) {},   // result.isFinal(⚠️ 不是 isLast)/ result.result
  onComplete(sessionId, eventMessage) {},
  onError(sessionId, errorCode, errorMessage) {}
});
engine.startListening({
  sessionId,                        // 字母/数字/下划线/短横线
  audioInfo: { audioType: 'pcm', sampleRate: 16000, soundChannel: 1, sampleBit: 16 },
  extraParams: { recognitionMode: 0, vadBegin: 800, vadEnd: 1200, maxAudioDuration: 20000 }
});                                 // recognitionMode:0 录音识别(需 MICROPHONE) / 1 音频流,默认 1
engine.writeAudio(sessionId, chunk); // 单次 640 或 1280 字节;间隔 20ms/40ms;单会话 ≤60000ms
engine.finish(sessionId);            // 正常结束
engine.cancel(sessionId);            // 丢弃结果
engine.isBusy();
engine.shutdown();                   // ⚠️ 不是 release()
engine.listLanguages({ sessionId });
// 错误码:1002200001~1002200012

// ② 文本转语音(Core Speech Kit TTS,起始 4.1.0(11))
import { textToSpeech } from '@kit.CoreSpeechKit';
const tts = await textToSpeech.createEngine({
  language: 'zh-CN', person: 0, online: 1
});                                                          // → TextToSpeechEngine
tts.setListener({
  onStart(requestId, response) {}, onComplete(requestId, response) {},
  onStop(requestId, response) {},  onError(requestId, errorCode, errorMessage) {},
  onData(requestId, audio, response) {}
});                                                          // ⚠️ 是 SpeakListener,不是 SpeechListener
tts.speak('A029 号,请到 3 号窗口取餐', {
  requestId: `haigou_${Date.now()}_1`,   // 必填;同一实例内只能用一次
  extraParams: { speed: 0.9, volume: 1, languageContext: 'zh-CN',
                 playType: 1, queueMode: 1 }   // speed [0.5,2] / volume [0,2],playType 1=合成并播报
});                                       // 文本 ≤ 10000 字符
tts.stop();
tts.isBusy();
tts.shutdown();
await tts.listVoices({ requestId, online: 1 });              // → VoiceInfo[]
// 错误码:1002300001~1002300010
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐