date: 2026-07-22
tags: HarmonyOS, speechRecognizer, 语音识别, 异步回调, 状态管理, ArkTS
type: 踩坑实录
1️⃣ 现象(发生了什么?)
使用 speechRecognizer 做语音输入,踩了一串连环坑:
- 用户说话停顿后消息"等一会才发出来"或者直接丢失
- 发出的文本是乱码拼接,比如用户说 "hello",发出来的是 "helhellohello helhello hello"
- 松手后一条消息被分裂成两条
- 首次使用就报错
1002200010: Write audio failed because the start listening is failed - 报错后引擎疯狂重启,session 计数器 1→2→4→8→16 指数爆炸
- 说话大约 20 秒后录音被强制中断
- 识别准确度不高,"停杯投箸"被识别成"情杯投注"
- 代码里手动创建 AudioCapturer + 切片喂引擎,复杂且容易出 bug
2️⃣ 解决办法(我是怎么做的?)
一、short 模式 → long 模式
speechRecognizer 有两种识别模式:
| 维度 | short 模式 | long 模式 |
|---|---|---|
| 停顿后 | 引擎自动结束 → onComplete → 需重启 | 只结束子句 → onResult(isFinal=true) → 引擎继续 |
| 结束方式 | 自动,控制不了 | 必须手动 finish() |
| onComplete 触发 | 停顿就触发 | 只有 finish() 后触发 |
| maxAudioDuration | 最长 60s | 最长 8 小时 |
short 模式的核心问题:用户说话停顿,引擎就自动触发 onComplete 死掉了。用户还按着按钮,但引擎已不识别了。代码需要复杂的双标志来处理"引擎死了但用户还按着"的异常状态。
long 模式完美匹配用户心智模型------按住录音,松开发送,移开丢弃。引擎不会因停顿自动结束,状态极简。
arkts
const extraParams: Record<string, Object> = { 'locate': 'CN', 'recognizerMode': 'long' }
const params: speechRecognizer.CreateEngineParams = {
language: 'zh-CN',
online: 1,
extraParams: extraParams
}
二、onResult 回调的 result 是"替换"不是"追加"
long 模式 enablePartialResult(蹦字模式)下,onResult 回调的 result.result 语义:
| isFinal | result.result 含义 | 应该怎么处理 |
|---|---|---|
false |
当前子句的最新猜测(替换上次 partial) | 存为 _currentPartial,不追加 |
true |
当前子句的最终确认(追加到总文本) | _accumulatedText += result.result |
实测日志展示的替换行为:
onResult isFinal=false text=hel ← 引擎目前认为整句话是 "hel"
onResult isFinal=false text=hello ← 更新猜测为 "hello"(不是 "hel"+"lo"!)
onResult isFinal=false text=hello hel ← 继续更新
onResult isFinal=false text=hello hello ← 继续更新
onResult isFinal=true text=Hello,hello。← 子句最终确认
我最初把 isFinal=false 的 result 当增量追加了,导致发出 "helhellohello helhello hello" 这种垃圾文本。
正确的拼接逻辑:
arkts
onResult(sessionId, result) {
const text = result.result.trim()
if (result.isFinal) {
if (text !== '') {
self._accumulatedText = self._accumulatedText + text
}
self._currentPartial = ''
} else {
self._currentPartial = text
}
}
三、发送时机:onComplete 最可靠
我试过三种发送时机:
| 发送时机 | 可靠? | 问题 |
|---|---|---|
| stop() 立即发 | ❌ | finish() 是异步的,最终结果还没到 |
| onResult(isFinal=true) + !holding | ❌ | 空的 isFinal=true 会提前触发 |
| onComplete + !holding | ✅ | finish() 后一定会来,时机正确 |
onResult(isFinal=true) 的空文本陷阱 :finish() 后引擎可能先回调一个空的 isFinal=true(确认前一个子句结束),然后才回调最终的确认文本。如果在 isFinal=true 时立即发送,空回调会触发提前发送,导致后续文本只能单独发成另一条消息。
正确做法:onResult(isFinal=true) 只累积,不发送。由 onComplete 负责发送。
arkts
// onResult 中
if (result.isFinal) {
if (text !== '') {
self._accumulatedText = self._accumulatedText + text
}
self._currentPartial = ''
// 不发送!由 onComplete 负责
}
// onComplete 中
if (!self._holding) {
self.sendAccumulated() // 这里发,时机正确
}
四、startListening 失败 + restartEngine 指数爆炸
Bug 1:StartParams.extraParams 格式问题
short 模式时 StartParams 只传 sessionId + audioInfo,没有 extraParams,工作正常。改 long 模式后加了 StartParams.extraParams(maxAudioDuration/enablePartialResult),引擎的 startListening 内部失败,报错 1002200010。
修复:先去掉 StartParams.extraParams,只改 CreateEngineParams 的 recognizerMode: 'long'。后续加回 extraParams 时用 recognitionMode: 0 + maxAudioDuration 的组合,不加 recognizerOption。
Bug 2:restartEngine 无并发防护
restartEngine() 是 async 的,await createEngine() 期间旧引擎的 listener 还在触发 onError/onComplete,每个回调都触发一次 restartEngine,导致并发创建多个引擎,session 计数指数增长。
日志:
22:00:26.608 onStart session_1
22:00:26.705 onError 1002200010 "start listening is failed"
22:00:26.806 onError while holding, restarting engine
22:00:27.037 engine restarted session_2 ← 第1次重启
22:00:27.045 restart onError 1002200010 ← 又失败!但旧listener还在触发
22:00:27.200 restart onStart session_2
22:00:27.200 restart onComplete ← 立即onComplete!
22:00:27.398 engine restarted session_4 ← 并发重启!跳过了3
22:00:27.702 engine restarted session_8 ← 指数爆炸!
修复:3 重防护
arkts
private _restarting: boolean = false // 防并发
private _restartCount: number = 0 // 限次数(最多3次)
private _sessionGeneration: number = 0 // 过滤旧代listener事件
// listener 回调中检查 generation
onComplete(sessionId, eventMessage) {
if (gen !== self._sessionGeneration) return // 旧代事件忽略
}
五、maxAudioDuration 默认只有 20 秒
去掉 StartParams.extraParams 后,maxAudioDuration 使用默认值 20000ms(20秒) 。用户说话超过 20 秒,引擎报错 1002200003: Exceeded the maximum audio length supported,录音被强制中断。
long 模式支持的 maxAudioDuration 范围是 20000 ~ 28800000ms(20秒 ~ 8小时)。
修复:在 StartParams.extraParams 中显式设置:
arkts
const startExtraParams: Record<string, Object> = {
'recognitionMode': 0,
'maxAudioDuration': 28800000
}
long 模式必须显式设置 maxAudioDuration,否则默认只有 20 秒。
六、recognitionMode 参数 --- 不需要手动喂音频
speechRecognizer 的 StartParams.extraParams 中有 recognitionMode 参数:
| recognitionMode | 含义 | 需要手动 writeAudio? |
|---|---|---|
| 0 | 实时录音识别(引擎自己录音) | 不需要 |
| 1(默认) | 音频转文字(外部写入音频流) | 需要 writeAudio |
我之前的代码没有传 recognitionMode,默认值是 1,引擎不自己录音,等待外部写入音频。所以不得不创建 AudioCapturer + processAudioData 手动喂引擎。
改用 recognitionMode=0 后:删掉 import { audio }、audioCapturer、audioBuffer、processAudioData()、releaseCapturer(),代码从 410 行降到 290 行。
七、listener 回调中 self vs this
arkts
this.engine.setListener({
onComplete(sessionId: string, eventMessage: string): void {
// 这里 this ≠ 当前实例!
// this 指向调用这个函数的对象(SDK 内部某个对象)
// 所以 this._accumulatedText 会是 undefined
self._accumulatedText // ✅ 通过闭包捕获,指向正确的实例
}
})
listener 回调是 SDK 调用的,不是我的实例调用的,所以 this 不指向我的实例。const self = this 在函数外面把实例存下来,listener 里通过闭包访问 self,一定能拿到正确的实例。
| 场景 | 用 this 还是 self | 原因 |
|---|---|---|
| 实例方法体内 | this | 方法由实例调用,this 指向实例 |
| SDK listener 回调内 | self(闭包) | 回调由 SDK 调用,this 不指向实例 |
| Arrow function 回调内 | this | arrow function 不绑定自己的 this,继承外层 |
同理,普通服务类(export class)的回调属性不需要 @Event/@Param 装饰器------这些装饰器只能用在 @ComponentV2 struct 里面,是 ArkUI 框架的组件通信机制。普通类用普通属性 onResult? 就行。
3️⃣ 为什么能解决?(刨根问底)
为什么 short 模式问题这么多?
short 模式的设计目标是"说一句话识别一句",引擎检测到停顿就自动结束。但用户按住按钮说话时,停顿是很正常的------喘口气、想一下措辞。引擎死了但用户还按着,代码就得处理这个异常状态,复杂度飙升。
long 模式的设计目标是"持续录音持续识别",停顿只结束子句不结束引擎,完美匹配"按住说话"的交互模式。
为什么 onResult 的 result 是替换而不是追加?
这是 ASR(自动语音识别)的通用设计。引擎在识别过程中不断更新对当前子句的猜测------说了一个音节 "hel",引擎猜整句话是 "hel";再说了 "lo",引擎更新猜测为 "hello"。这不是追加,是修正。
如果按追加处理,就会出现 "hel" + "hello" + "hello hel" = "helhellohello helhello hello" 这种结果。
为什么 ASR 准确度有限?
speechRecognizer 只做语音转文字(ASR),不做语义理解。这是两个不同的能力:
| 能力 | 做什么 | Kit |
|---|---|---|
| speechRecognizer | 语音 → 文字 | CoreSpeechKit |
| textProcessing | 文字 → 实体/意图 | NaturalLanguageKit |
"情杯投注"→"停杯投箸"这种纠错需要世界知识,不是传统 NLP 能解决的。离线 ASR 准确度天然不如云端,因为端侧模型小、计算资源有限。这是硬限制,代码层面无法优化。
4️⃣ 验证
onResult 回调替换行为验证
onResult isFinal=false text=hel
onResult isFinal=false text=hello
onResult isFinal=false text=hello hel
onResult isFinal=false text=hello hello
onResult isFinal=true text=Hello,hello。
结论:isFinal=false 的 result 是当前子句的最新完整猜测,不是增量文本。
空 isFinal=true 导致消息分裂验证
gen=12:
isFinal=false "心茫然" → _currentPartial = "心茫然"
stop() → _holding=false, finish()
isFinal=true "" (空!) → 空文本不追加,但触发了提前发送
isFinal=true "心茫然。" → 追加到空的 _accumulatedText,单独发送
onComplete → 已空,无文本可发
结论:finish() 后引擎可能先回调空的 isFinal=true,再回调最终确认文本。onResult(isFinal=true) 不能作为发送时机。
restartEngine 指数爆炸验证
session_1 → onError → restart → session_2 → onError(旧listener) + onComplete(旧listener)
→ 并发 restart → session_4 → session_8 → ...
结论:旧 listener 事件 + 无并发防护 = 指数爆炸。必须用 generation 过滤 + restarting 防并发。
5️⃣ 最终结论(我的观察)
| 维度 | 建议 |
|---|---|
| 模式选择 | 优先用 long 模式,short 模式不适合"按住说话"场景 |
| onResult 处理 | isFinal=false 是替换(更新 partial),isFinal=true 是追加(累积到总文本) |
| 发送时机 | onComplete 最可靠,onResult(isFinal=true) 可能有空回调提前触发 |
| maxAudioDuration | long 模式必须显式设置,默认只有 20 秒 |
| recognitionMode | 用 0(引擎自己录音),除非有特殊需求需要手动 writeAudio |
| listener 回调 | 用 const self = this 闭包捕获,不要用 this |
| 引擎重启 | 必须 3 重防护:generation 过滤旧事件 + restarting 防并发 + restartCount 限次 |
| ASR 准确度 | 离线模式硬限制,代码无法优化,日常对话场景够用 |
arkts
// speechRecognizer long 模式推荐配置
const createParams: speechRecognizer.CreateEngineParams = {
language: 'zh-CN',
online: 1,
extraParams: { 'locate': 'CN', 'recognizerMode': 'long' }
}
const startParams: speechRecognizer.StartParams = {
sessionId: sessionId,
audioInfo: 'audioInfo',
extraParams: {
'recognitionMode': 0,
'maxAudioDuration': 28800000
}
}
核心教训:speechRecognizer 的 short/long 模式不只是时长的区别,而是完全不同的回调语义和生命周期。选错模式会导致一系列连环问题。long 模式 + recognitionMode=0 + onComplete 发送是最简可靠的组合。
学习小结:speechRecognizer 从 short 改 long 模式后,状态变量从 5 个降到 3 个,代码从 410 行降到 290 行。核心认知是 onResult 的 result 语义(替换 vs 追加)、发送时机(onComplete 最可靠)、引擎重启的并发防护。每个坑都来自对 API 语义的误解------short 不是"短录音",而是"引擎自动管理生命周期";result 不是"增量",而是"当前最佳猜测"。