校园嗨购 · 某购物节特别版:HarmonyOS 7「Core Speech Kit 语音点单 + 语音播报」实战
校园嗨购 · 某购物节特别版: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 秒 | 端侧识别,无网络往返 |
这一篇同样有 三个反常识结论,先放在这里:
- Core Speech Kit 官方文档里一个
enum都没有。识别模式靠字符串'short' / 'long',音频格式靠AudioInfo的'pcm',队列模式靠extraParams.queueMode的数字——网上那些AudioSourceType/AudioEncodingType/QueueMode枚举属于别的 Kit 或根本不存在。online字段的含义是反的:官方原文是「0 为在线(不支持);1 为离线(当前仅支持)」。所以正确的写法是online: 1,而大量博客写成「1 表示在线」。- 结束识别的 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';
| 能力 | 系统能力常量 | 起始版本 |
|---|---|---|
| ASR | SystemCapability.AI.SpeechRecognizer | 4.1.0(11) |
| TTS | SystemCapability.AI.TextToSpeech | 4.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.extraParams | locate | 'CN' | 区域信息,当前仅支持中国 |
CreateEngineParams.extraParams | recognizerMode | 'short' / 'long' | 短语音 ≤ 60s / 长语音 ≤ 8h |
StartParams.extraParams | recognitionMode | 0 / 1 | 0=实时录音识别(需 MICROPHONE);1=实时音频流转文字(需配合 writeAudio)。不传默认 1 |
StartParams.extraParams | vadBegin | [500, 10000] | VAD 前端点(ms) |
StartParams.extraParams | vadEnd | [500, 10000] | VAD 后端点(ms);短语音默认 800ms,长语音默认 500ms 且不可配 |
StartParams.extraParams | maxAudioDuration | 默认 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>): void | 4.1.0(11) |
listVoices(模块级) | (queryParams: VoiceQuery): Promise<VoiceInfo[]> | 5.1.1(19) |
downloadVoice | (downloadParams: VoiceDownload, callback: AsyncCallback<DownloadResponse>): void | 5.1.1(19) |
engine.listVoices | (params: VoiceQuery): Promise<Array<VoiceInfo>> | 4.1.0(11) |
engine.setListener | (listener: SpeakListener): void | 4.1.0(11) |
engine.speak | (text: string, speakParams: SpeakParams): void | 4.1.0(11) |
engine.stop | (): void | 4.1.0(11) |
engine.isBusy | (): boolean | 4.1.0(11) |
engine.shutdown | (): void | 4.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 | 数字 | — | 声道 |
playType | 0 / 1 | 1 | 0=只合成返回音频流(不播报);1=合成并播报 |
queueMode | 0 / 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):
| 错误码 | 官方原文 |
|---|---|
1002200001 | Create engine failed. |
1002200002 | Start listening failed. |
1002200003 | Exceeded the maximum audio length supported. |
1002200004 | Finish recognition failed. |
1002200005 | Cancel recognition failed. |
1002200006 | The engine of SpeechRecognition is busy. |
1002200007 | The engine is not initialized. |
1002200008 | The engine of SpeechRecognition is being destroyed. |
1002200009 | Internal Service Error. |
1002200010 | Write audio failed because the start listening is failed. |
1002200011 | Error in recognition. |
1002200012 | AudioCapturer create failed, please check the permission of MICROPHONE. |
文本转语音(1002300xxx):
| 错误码 | 官方原文 |
|---|---|
1002300001 | The length of text is out of range or empty. |
1002300002 | The language is not supported. |
1002300003 | The person is not supported. |
1002300005 | Create engine failed. |
1002300006 | The service of TextToSpeech is busy. |
1002300007 | The Text-to-Speech engine is not initialized. |
1002300008 | Failed to download voice. |
1002300009 | Parameter error.(必填缺失 / 类型错误 / 校验失败) |
1002300010 | Voice has already been downloaded. |
⚠️ 关键勘误:三个「网上传得很广、但官网查不到」的写法
网传写法 官方真值 SpeechRecognizerEngineSpeechRecognitionEngineengine.stopListening()/engine.release()finish(sessionId)/cancel(sessionId)/shutdown()SpeechRecognitionResult.isLastisFinalAudioSourceType.AUDIO_SOURCE_TYPE_MIC = 0、AudioEncodingType、SpeechRecognitionModeCore Speech Kit 里都不存在(音频格式用 AudioInfo.audioType: 'pcm',模式用recognizerMode: 'short'/'long';AudioSourceType属于 Audio Kit)QueueMode枚举、TextToSpeechEngine.SpeakState、TtsErrorCode都不存在; queueMode是extraParams里的 numberRecognizerParams/AudioSourceParams接口不存在,对应的是 StartParams/AudioInfoonline: 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 个细节
- VAD 前后端点是体验分水岭。官方短语音
vadEnd默认 800ms,但食堂里噪音大、用户会停顿,建议调到 1200ms;vadBegin调到 800ms,容忍"我按下了但还没开口"。 isBusy()必须检查。用户连续快速按两次,第二次startListening会撞上1002200006(引擎忙)。正确做法是cancel掉上一轮再开新的。recognitionMode不传是1,不是0。很多人在做「录音识别」时忘了显式传0,结果引擎一直在等writeAudio,表现为"按下去没反应"。- TTS 的
requestId会重复。官方明确规定同一实例内一个requestId只能用一次,所以必须用时间戳 + 自增序号拼接,不能用固定的'tts_001'。 - 播报要能被"闭嘴"。用户戴耳机在图书馆,叫号播报会很尴尬。必须有
engine.stop()+ 一个全局的"静音播报"开关,尊重用户设置。
五、高发错误码处置表
| 模块 | 错误码 | 官方含义 | 线上处置 |
|---|---|---|---|
| ASR | 1002200012 | AudioCapturer 创建失败,请检查 MICROPHONE 权限 | 声明 + 动态申请权限;被拒时降级为手动搜索 |
| ASR | 1002200006 | SpeechRecognition 引擎忙 | 先 cancel 上一轮再 startListening |
| ASR | 1002200003 | 超过最大音频长度 | 单会话 ≤ 60s;点单场景把 maxAudioDuration 压到 20s |
| ASR | 1002200010 | 因为 startListening 失败,writeAudio 失败 | 检查 startListening 是否成功;音频转文字场景别漏了它 |
| ASR | 1002200011 | 识别过程出错 | 重试一次;连续失败提示用户改为手动输入 |
| ASR | 1002200007 / 1002200008 | 引擎未初始化 / 正在销毁 | 用 engine !== null 做守卫,shutdown() 后标记置空 |
| TTS | 1002300001 | 文本超长或为空 | 播报前校验长度,本文实现里限定 ≤ 10000 字符 |
| TTS | 1002300006 | TTS 服务忙 | 用 queueMode: 1 抢占,或 isBusy() 为真时先 stop() |
| TTS | 1002300007 | TTS 引擎未初始化 | 播报前确保 init() 完成(await) |
| TTS | 1002300009 | 参数错误 | 检查 SpeakParams.requestId 是否缺失、extraParams 取值是否越界 |
六、写在最后
这一篇最值钱的部分,依然是**「什么不能做」**:
- Core Speech Kit 没有枚举。所有"模式"都是字符串或数字塞在
extraParams里。看到有人写speechRecognizer.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,基本可以确定他是从别的 Kit 或从旧博客抄的。 online: 1是离线。这个语义的反直觉程度足以让一半的示例代码"看起来对、跑起来错"。- 类名和释放方式都要对:
SpeechRecognitionEngine(不是SpeechRecognizerEngine)、finish/cancel/shutdown(不是stopListening/release)。 - 语音不是"加个 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
更多推荐

所有评论(0)