CoreSpeechKit 离线语音识别实战:麦克风直采与约 60 秒透明续接

语音训练是这个 App 的主场景:用户对着手机自由说几分钟,实时出字幕、实时标填充词。技术选型在 ADR-004 冻结:CoreSpeechKit 的 speechRecognizer,Kit 内部麦克风直采,离线模式。这篇按真实适配层代码讲怎么把它做成生产可用,而不是 Demo 可用。
1. 选型:为什么是 Kit 直采而不是自己喂音频
speechRecognizer 有两种用法:Kit 内部采麦(recognitionMode=0),或业务层自己采集 PCM 喂进去。我们选前者,理由和代价都很明确:
理由:音频采集链路(AudioCapturer、采样率转换、权限时序、前后台切换的采集管理)全部交给系统,业务代码量少一个数量级;系统采麦天然带系统麦克风指示,用户可感知。
代价:业务层拿不到 PCM 流——想做"实时音量波形"就没有振幅信号。我们接受了这个代价,并且立了规矩:没有真实音量数据就不做假波形(随机动画冒充音量是欺骗用户)。这是选型决策连带的产品纪律,写进了冻结决策。
创建和启动参数收敛在两个工厂函数里:
/** Production offline create params (language + online=1). */
export function speakLabDefaultAsrCreateParams(): SpeakLabAsrCreateParams {
return new SpeakLabAsrCreateParams('zh-CN', 1); // online=1 → 离线识别
}
/** Production start params: unique kit sid + mic mode 0 + 16k mono 16-bit. */
export function speakLabDefaultAsrStartParams(kitSessionId: string): SpeakLabAsrStartParams {
return new SpeakLabAsrStartParams(kitSessionId, new SpeakLabAsrAudioInfo(), 0);
}
online=1 是离线模式(这个参数名的语义确实反直觉,spike 阶段真机验证过:1 = 走设备端识别,不依赖网络,这是我们离线卖点的根基);zh-CN;16kHz 单声道 16bit。
2. 系统边界:全项目只有一个文件认识 CoreSpeechKit
适配层第一条纪律写在 SpeakLabCoreSpeechPort.ets 文件头:
Unique production CoreSpeechKit binding for SpeakLab. Only this file under common/ may import @kit.CoreSpeechKit. System Record / SDK objects stay inside this boundary.
所有 @kit.CoreSpeechKit 的类型——speechRecognizer.SpeechRecognitionEngine、RecognitionListener、各种 Record——只存在于这个文件。它对外暴露的是一套中性接口(SpeakLabAsrPlatformPort):createEngine / startListening / finish / cancel,参数和返回值都是自定义类型。
边界上做的一件重要的事是信息裁剪。kit 回调带着系统的 sessionId、错误码、错误消息、事件消息,跨边界时被严格过滤:
onError: (sessionId: string, errorCode: number, _errorMessage: string): void => {
// Drop raw message at the system boundary; only numeric code crosses.
target.onError(sessionId, errorCode);
}
原始错误消息不过边界,只有数字错误码进入领域层。错误消息可能含系统路径、内部描述,一是泄露实现细节,二是上游一旦依赖消息文本做判断,系统升级改个文案你就崩了。数字码是唯一稳定的契约。同理 onEvent 整个被丢弃(注释:Intentionally ignored — not part of domain ASR events)——领域层需要的事件集合是设计出来的,不是系统给什么就转发什么。
还有个 ArkTS 语言细节:多方法监听器不能用无类型对象字面量,必须定义实体类(SpeakLabBoundAsrPlatformListener)。从 JS/TS 习惯转过来的人第一个跟头往往栽在这。
3. 双重 sessionId:业务的稳,kit 的每轮换
长会话的核心矛盾:CoreSpeechKit 的识别会话有 VAD 时长限制(实测约 60 秒无有效语音判定就会被系统完结),而用户的一次训练可能说五分钟。解法是分两层 ID:
-
业务 sessionId:一次训练一个,全程稳定。统计、高亮、历史记录都挂它——用户视角的"这一段话"是一个整体。
-
kit sessionId:每一轮识别一个,全局唯一。VAD 截断后开新一轮,新轮新 sid。
但多轮会话带来一个并发噩梦:旧轮的回调可能在新轮启动后才到。系统的回调是异步的,第 N 轮的 onResult 完全可能在第 N+1 轮已经 listening 时才姗姗来迟——如果不过滤,旧文本就串进新轮,甚至触发错误的状态迁移。
防线是监听器绑定时捕获三重身份,回调时逐一校验:
class SpeakLabBoundAsrPlatformListener implements SpeakLabAsrPlatformListener {
private readonly boundGen: number; // 域 generation(场景切换/重建递增)
private readonly boundEpoch: number; // 监听器 epoch(换监听器递增)
private readonly boundKitSid: string; // 绑定时的 kit sessionId
}
每个平台回调进来,先过 acceptCallback:bound gen/epoch 与当前活动值不等 → 丢;bound kitSid 与回调携带的 sid 严格不等 → 丢;空 sid → 直接丢。日志里留下 stale_epoch 事件可观测,但绝不让迟到回调碰状态机。这就是冻结决策里的"严格回调隔离"——异步系统的正确性,一半靠状态机,另一半靠把"过期世界的消息"挡在门外。
4. 透明续接与错误分级
VAD 完结(onComplete)时的续接决策:
private dispatchComplete(...): void {
if (!this.acceptCallback(...)) return;
// onComplete itself submits no text and no terminal error.
if (!this.userWantsListening || !this.autoRestart) {
this.phase = SpeakLabAsrAdapterPhase.STOPPED;
return;
}
if (this.restartInFlight) {
this.safeLog('complete_restart_skipped', 'already_in_flight');
return;
}
// …开新一轮(RESTARTING → LISTENING),新 kit sid,日志 cont=1
}
注意三个条件:用户还想听(userWantsListening——用户没按停止)、允许自动续(autoRestart)、没有续接在飞(restartInFlight 去重,防止多个 complete 叠出两个新会话)。续接对用户完全无感:业务 sessionId 不变、文本流不断,日志里只是一个 cont=1 的新轮标记。暂停/停止/释放/销毁各自走显式路径(safeCancel('pause'|'user_stop'|'release'|'dispose'),不会误触发续接。
错误处理分两级,中间有预算:
RECOVERABLE(可恢复)→ 退避重试:200ms / 400ms / 800ms,最多 3 次
TERMINAL(终结) → 不重试,直接失败态
预算耗尽 → budget_exhausted,监听器作废 + cancel
重试是"同一业务会话的透明新轮"——和 VAD 续接走同一条新轮路径,所以重试也不会串 sessionId。MAX_RECOVERABLE_RETRIES = 3 的预算是防"永远重试"的:麦克风被占用这类可恢复错误如果持续存在,说明环境有问题,重试三年也没用,不如明确失败。错误码到错误分级的映射集中在 SpeakLabAsrErrorMapper——加新错误码只改映射表,状态机不动。
5. 安全日志:元数据可以记,文本绝不行
适配层有一个专门的日志通道类型:
/** Safe structured log sink: phase + metadata only, never utterance text. */
export type SpeakLabAsrSafeLogSink = (phase: string, meta: string) => void;
注释就是红线:never utterance text。语音转写内容是用户语音的等价物,进日志等于把用户说的话写进诊断文件。日志里只有阶段、generation、sid 长度(bizSidLen=12,记长度不记内容)、online 标记这类元数据——够排查问题,不泄露内容。这条规矩同样适用于 AI 请求链(B13 会再看到它)。
6. 小结
-
选型连带纪律:Kit 直采换稳定性,代价是没有 PCM——没数据就不做假波形。
-
系统边界唯一化:一个文件 import Kit;跨边界信息裁剪,错误消息不过界,只有数字码。
-
双重 sessionId + gen/epoch/kitSid 三重校验:业务会话稳定,kit 会话每轮换,迟到回调一律丢弃。
-
VAD 约 60 秒截断 → 条件续接(用户意愿 × 自动开关 × 去重);错误分级 + 退避重试 + 3 次预算。
-
日志只记元数据,语音文本永不下日志。
更多推荐



所有评论(0)