语音训练是这个 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.SpeechRecognitionEngineRecognitionListener、各种 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 次预算。

  • 日志只记元数据,语音文本永不下日志。

Logo

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

更多推荐