
适合谁看
-
同时在接鸿蒙语音识别和 TTS 的开发者
-
觉得"都是语音能力,调法应该差不多"的人
-
想更快定位语音链路问题的人
问题背景
鸿蒙语音识别和 TTS 虽然都来自 CoreSpeechKit,但它们的能力模型不同:
| 维度 | 语音识别(ASR) | TTS |
|---|---|---|
| 方向 | 输入型(用户说话 → 文本) | 输出型(文本 → 语音播放) |
| 触发方式 | 用户主动触发 | 应用主动触发 |
| 关键回调 | onResult(文本) | onComplete(播报完成) |
| 最容易卡在哪 | 开始了但文本没带回来 | 能播但状态回不来 |
所以调试重点不可能完全一样。
项目中的真实场景
食界探味当前的语音能力实现:
| 文件 | 能力 | 调试关键 |
|---|---|---|
SpeechRecognitionPlugin.ets |
鸿蒙 ASR | 权限、引擎、回调、文本收口 |
TextToSpeechPlugin.ets |
鸿蒙 TTS | 参数、引擎复用、播报结束、stop |
speech_recognition_channel.dart |
Flutter ASR 通道 | 返回值、空结果处理 |
text_to_speech_channel.dart |
Flutter TTS 通道 | 阻塞返回、stop 调用 |
核心实现
一、语音识别调试重点------5 个关键点
重点 1:麦克风权限是否拿到
// SpeechRecognitionPlugin.ets
private async requestMicrophonePermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = ['ohos.permission.MICROPHONE'];
const context = getContext(this);
const grantResult = await atManager.requestPermissionsFromUser(context, permissions);
return grantResult.authResults.every(
status => status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
);
} catch (err) {
console.error(TAG, `requestPermission failed: ${JSON.stringify(err)}`);
return false;
}
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 权限已授权 | 返回 true | 返回 false,识别不启动 |
| 权限弹窗被拒绝 | 返回 false | 识别不启动,Flutter 收到错误 |
| 权限 API 调用失败 | 返回 false | 识别不启动 |
重点 2:引擎是否创建成功
private createEngine(): Promise<void> {
return new Promise((resolve, reject) => {
const extraParam: Record<string, Object> = { 'locate': 'CN', 'recognizerMode': 'short' };
const initParams: speechRecognizer.CreateEngineParams = {
language: 'zh-CN',
online: 1,
extraParams: extraParam
};
speechRecognizer.createEngine(initParams, (err, engine) => {
if (!err) {
console.info(TAG, 'Engine created successfully');
this.asrEngine = engine;
resolve();
} else {
console.error(TAG, `Failed to create engine: ${err.message}`);
reject(err);
}
});
});
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 引擎创建成功 | Engine created successfully |
Failed to create engine |
| 引擎创建超时 | Promise resolve | Promise reject |
| 引擎创建后为 null | 不为 null | 后续调用崩溃 |
重点 3:监听器是否真的触发
private setupListener(): void {
if (!this.asrEngine) return;
const listener: speechRecognizer.RecognitionListener = {
onStart: (sessionId, eventMessage) => {
console.info(TAG, `onStart sessionId: ${sessionId}`);
},
onResult: (sessionId, result) => {
console.info(TAG, `onResult: ${JSON.stringify(result)}`);
if (result.isLast && this.pendingResult) {
this.pendingResult.success(result.result);
this.pendingResult = null;
this.shutdownEngine();
}
},
onComplete: (sessionId, eventMessage) => {
console.info(TAG, `onComplete sessionId: ${sessionId}`);
if (this.pendingResult) {
this.pendingResult.success('');
this.pendingResult = null;
}
this.shutdownEngine();
},
onError: (sessionId, errorCode, errorMessage) => {
console.error(TAG, `onError code: ${errorCode}, msg: ${errorMessage}`);
if (this.pendingResult) {
this.pendingResult.error('ASR_ERROR', errorMessage, null);
this.pendingResult = null;
}
this.shutdownEngine();
}
};
this.asrEngine.setListener(listener);
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| onStart 触发 | 有日志 | 引擎没启动 |
| onResult 触发 | 有日志 | 没收到语音 |
| onResult(isLast: true) | 触发一次 | 多次触发或不触发 |
| onError 触发 | 有错误码 | 无日志 |
重点 4:result.isLast 是否正确收口
onResult: (sessionId, result) => {
if (result.isLast && this.pendingResult) {
this.pendingResult.success(result.result);
this.pendingResult = null;
this.shutdownEngine();
}
}
这是语音识别最关键的收口点。isLast = true 时才回传文本给 Flutter。
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| isLast: true 时回传 | pendingResult.success() | 文本没到 Flutter |
| isLast: true 后 shutdown | 引擎销毁 | 引擎一直占用 |
| isLast: false 时不回传 | 只记日志 | 中间结果泄露到 Flutter |
重点 5:空结果和错误结果是否区分开
// 协调器
final text = await SpeechRecognitionChannel.startListening();
if (text.isEmpty) {
// 空结果 → 友好提示
state = state.copyWith(
status: AiSessionStatus.error,
errorMessage: '未听清,请再说一次',
);
return;
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 空字符串 | 提示"未听清" | 弹技术性错误 |
| 正常文本 | 提交 AI | 文本丢失 |
| 错误异常 | 提示"请手动输入" | 页面卡死 |
二、TTS 调试重点------4 个关键点
重点 1:文本参数是否为空
// TextToSpeechPlugin.ets
private async handleSpeak(call: MethodCall, result: MethodResult): Promise<void> {
const text = call.argument('text') as string;
if (!text || text.length === 0) {
result.error('INVALID_ARGUMENT', '播报文本不能为空', null);
return;
}
this.pendingResult = result;
// ...
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 文本非空 | 正常播报 | 返回 INVALID_ARGUMENT |
| 文本为空 | 返回错误 | 引擎尝试播报空内容 |
| 文本只有空格 | 返回错误 | 引擎尝试播报空白 |
重点 2:引擎是否重复创建
private createEngine(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ttsEngine) {
resolve(); // 已创建则直接复用
return;
}
// 创建引擎...
});
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 首次调用 | 创建引擎 | 无日志 |
| 重复调用 | 复用引擎 | 重复创建,资源浪费 |
| 引擎被 shutdown 后调用 | 重新创建 | 引擎为 null,崩溃 |
重点 3:onComplete、onStop、onError 是否都能收口
const speakListener: textToSpeech.SpeakListener = {
onStart: (requestId, response) => {
console.info(TAG, `onStart requestId: ${requestId}`);
},
onComplete: (requestId, response) => {
console.info(TAG, `onComplete requestId: ${requestId}`);
if (this.pendingResult) {
this.pendingResult.success(null);
this.pendingResult = null;
}
},
onStop: (requestId, response) => {
console.info(TAG, `onStop requestId: ${requestId}`);
if (this.pendingResult) {
this.pendingResult.success(null);
this.pendingResult = null;
}
},
onError: (requestId, errorCode, errorMessage) => {
console.error(TAG, `onError code: ${errorCode}, msg: ${errorMessage}`);
if (this.pendingResult) {
this.pendingResult.error('TTS_ERROR', errorMessage, null);
this.pendingResult = null;
}
}
};
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| onComplete 触发 | pendingResult 回收 | Flutter await 挂起 |
| onStop 触发 | pendingResult 回收 | Flutter await 挂起 |
| onError 触发 | pendingResult 回收 | Flutter await 挂起 |
| 三个回调都不触发 | pendingResult 永远挂起 | Flutter 页面卡死 |
重点 4:stop() 是否真能停止播报
private handleStop(result: MethodResult): void {
try {
if (this.ttsEngine) {
this.ttsEngine.stop();
}
result.success(null);
} catch (err) {
result.error('TTS_ERROR', `停止播报失败: ${err.message}`, null);
}
}
调试检查:
| 检查项 | 预期 | 异常表现 |
|---|---|---|
| 播报中调用 stop | 播报停止 | 声音继续播放 |
| 没播报时调用 stop | 安全返回 | 报错 |
| stop 后 pendingResult | 回收 | Flutter await 挂起 |
三、两者共同的调试点
| 检查项 | ASR | TTS | 说明 |
|---|---|---|---|
| Flutter 通道名一致 | com.foodvoyage.speech_recognition |
com.foodvoyage.text_to_speech |
不一致则 MissingPluginException |
| 原生插件正确注册 | EntryAbility 添加插件 | EntryAbility 添加插件 | 没注册则找不到插件 |
| 页面退出时清理 | 无(引擎自动销毁) | TextToSpeechChannel.stop() |
TTS 必须手动停止 |
| pendingResult 回收 | isLast/error 时回收 | onComplete/onStop/error 时回收 | 不回收则 Flutter 挂起 |
四、调试时的日志对照
ASR 链路日志:
ArkTS: requestPermission → result
ArkTS: Engine created successfully / Failed
ArkTS: startListening
ArkTS: onStart sessionId: xxx
ArkTS: onResult: {result: "想吃鸡蛋", isLast: true}
ArkTS: onComplete sessionId: xxx
ArkTS: Engine shutdown
Flutter: 收到文本 "想吃鸡蛋"
Flutter: [AI助手] 工具调用: search_dishes(...)
TTS 链路日志:
Flutter: TextToSpeechChannel.speak(text)
ArkTS: speak called
ArkTS: onStart requestId: xxx
ArkTS: onData requestId: xxx, sequence: 1
ArkTS: onComplete requestId: xxx
Flutter: await 返回
TTS stop 链路日志:
Flutter: TextToSpeechChannel.stop()
ArkTS: stop 被调用
ArkTS: onStop requestId: xxx
Flutter: await 返回
Flutter: setState(_isSpeaking = false)
关键代码位置
| 文件 | 调试关键 |
|---|---|
app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets |
ASR 鸿蒙侧 |
app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets |
TTS 鸿蒙侧 |
app/lib/core/platform/speech_recognition_channel.dart |
ASR Flutter 侧 |
app/lib/core/platform/text_to_speech_channel.dart |
TTS Flutter 侧 |
ASR vs TTS 调试重点对比
| 维度 | 语音识别(ASR) | TTS |
|---|---|---|
| 最容易卡在哪 | 开始了但文本没带回来 | 能播但状态回不来 |
| 关键权限 | 麦克风权限 | 无 |
| 引擎生命周期 | 每次识别后 shutdown | 可复用,不主动 shutdown |
| 关键回调 | onResult(isLast: true) | onComplete / onStop |
| 空结果处理 | 提示"未听清" | 静默跳过 |
| stop 处理 | stopListening() | stop() + pendingResult 回收 |
| 页面退出清理 | 无(引擎自动销毁) | 必须 stop() |
常见坑
-
用同一套思路调试识别和播报 --- 它们的回调模型完全不同
-
语音识别不看权限和最终结果收口 --- 最容易卡在 isLast 没触发
-
TTS 不看 stop 路径 --- 用户手动停止时状态回不来
-
只看原生日志,不看 Flutter 最终状态 --- 需要看两端日志对照
-
TTS pendingResult 没有在 onStop 时回收 --- Flutter await 永远挂起
-
ASR 引擎没有 shutdown --- 鸿蒙端内存泄漏
-
TTS 引擎重复创建 --- 应该复用,不要每次播报都创建
可复用模板
ASR 调试检查清单
语音识别调试:
□ 麦克风权限是否拿到?
□ 引擎是否创建成功?
□ onStart 是否触发?
□ onResult 是否触发?
□ isLast: true 是否触发?
□ 最终文本是否回传给 Flutter?
□ 引擎是否 shutdown?
□ 空结果是否有友好提示?
TTS 调试检查清单
TTS 调试:
□ 文本参数是否非空?
□ 引擎是否创建成功?
□ 引擎是否重复创建?
□ onStart 是否触发?
□ onComplete 是否触发?
□ onStop 是否触发(手动停止时)?
□ onError 是否触发(出错时)?
□ pendingResult 是否在所有路径都回收?
□ stop() 是否真能停止播报?
□ 页面退出时是否调用 stop()?
共同调试检查清单
语音能力共同检查:
□ Flutter 通道名是否和鸿蒙插件一致?
□ 鸿蒙插件是否在 EntryAbility 中注册?
□ 页面退出时是否做清理?
□ pendingResult 是否在所有路径都回收?
本篇总结
鸿蒙语音识别和 TTS 虽然都属 CoreSpeechKit 能力,但调试重点完全不同:
-
语音识别 --- 权限 → 引擎 → 回调 → 文本收口(isLast)
-
TTS --- 参数 → 引擎复用 → 播报结束/停止 → 状态收口(pendingResult)
把两条链分开看,会比混着排查高效很多。一旦抓住各自最常出问题的点,定位效率会明显提升。