【听见课堂 HarmonyOS NEXT 实战系列 25】识别回调如何变成可展示字幕:临时结果、最终结果与当前行

语音识别回调给出的通常不是一组一次性完成的句子,而是同一句话不断修正的临时结果,最后再用 isFinal 标记稳定版本。如果每次回调都向列表追加一行,用户会看到“今天”“今天我们”“今天我们学习”三条重复字幕;如果只保留最终结果,课堂字幕又会明显迟到。

听见课堂用 currentCaptionId 把临时结果更新到同一行,用 isFinal 结束当前行,再通过 LiveCaptionItemLiveSessionSnapshot 交给 ArkUI。本文拆解这条归一化链路,也指出当前 100 条可见窗口、全量持久化和高频渲染之间尚需补齐的工程边界。

临时识别结果到最终字幕行的归一化过程

一、识别回调不能直接等同于字幕记录

一段“请打开课本第三十页”的识别过程可能依次返回:

请打开
请打开课本
请打开课本第三十
请打开课本第三十页(final)

这些是同一语义单元的修订过程,不是四条课堂事实。领域层需要先判断“更新当前行还是创建新行”,再决定何时允许持久化。

二、LiveCaptionItem 承担哪些展示语义

当前模型包含:

export class LiveCaptionItem {
  id: string;
  timestamp: string;
  speaker: string;
  text: string;
  isKeyPoint: boolean;
  isUncertain: boolean;
  isCurrent: boolean;
  isFinal: boolean;
}

各字段分为三组:

  • 身份与内容:idtimestampspeakertext
  • 用户确认信息:isKeyPointisUncertain
  • 实时会话信息:isCurrentisFinal

isKeyPointisUncertain 不是模型置信度。当前 SDK 结果模型没有被项目使用的置信度字段,“没听清”由用户主动标记,不能伪造一个百分比分数。

三、先过滤旧 session 和空文本

回调入口先做两道门禁:

if (activeSessionId !== this.sessionId) {
  return;
}
const recognizedText: string = result.result.trim();
if (recognizedText.length === 0) {
  return;
}

旧会话回调被忽略,避免暂停/继续后污染新会话;纯空白结果不创建占位行,避免列表出现没有内容却参与计数的字幕。

真正的生产实现还可以限制单条最大长度、处理异常控制字符,但不能静默改写用户语义。

四、没有 currentCaptionId 时创建当前行

第一次有效结果到来时,项目生成稳定 ID:

this.currentCaptionId =
  activeSessionId + '-caption-' + Date.now().toString();

this.captions.push(new LiveCaptionItem(
  this.currentCaptionId,
  this.formatElapsed(this.elapsedSeconds),
  '课堂语音',
  recognizedText,
  false,
  false,
  true,
  result.isFinal
));

时间戳取首次出现时的会话计时,后续临时修订不会不断改时间。这比用每次回调时间更接近“这句话从何时开始进入字幕”的展示语义。

五、临时结果更新同一行

只要 currentCaptionId 仍存在,后续结果就查找同一项并覆盖文本:

const index = this.captions.findIndex(
  (item: LiveCaptionItem) => item.id === this.currentCaptionId
);
if (index >= 0) {
  this.captions[index].text = recognizedText;
  this.captions[index].isCurrent = true;
  this.captions[index].isFinal = result.isFinal;
}

这样 ArkUI 中只有一行在增长,用户可以及时看到内容,又不会积累重复片段。列表 key 包含 id 和文本状态,目标行会随修订更新。

六、最终结果如何固化

result.isFinaltrue 时,项目把当前 ID 清空:

if (result.isFinal) {
  this.currentCaptionId = '';
}

下一次有效回调会创建新的字幕行。已完成项的 isFinal 保持为 true,文本不会再被本轮临时结果覆盖。

这里的“固化”是 Service 内部语义,不代表已经写入数据库。当前项目在用户结束课堂时才把可用字幕交给 Repository;“最终识别结果”和“已持久化记录”必须分成两个状态理解。

七、isCurrent 与 isFinal 可以同时为 true

当前实现中,一条最终结果到达时会同时设置 isCurrent=trueisFinal=true,随后清空 currentCaptionId。直到下一条结果调用 clearCurrentMarker(),这条最终字幕仍是页面上的“当前行”。

这不是逻辑矛盾:

  • isFinal 表示文本不再被本轮识别修订;
  • isCurrent 表示它仍是用户操作“重点/没听清”默认作用的最近一行。

如果产品把 current 定义为“仍在流式生成”,就应在 final 时立即清除;如果定义为“当前关注行”,现有行为更符合用户标记操作。字段语义必须在产品和测试中写清楚。

八、重点和没听清为什么作用于当前行

项目通过 currentCaptionIndex() 找到 isCurrent 项;没有显式当前项时,回退到最后一条:

const markedIndex = this.captions.findIndex(
  (item: LiveCaptionItem) => item.isCurrent
);
return markedIndex >= 0 ? markedIndex : this.captions.length - 1;

这样用户在老师刚讲完一句后点击“重点”或“没听清”,仍能作用于最近字幕。没有字幕时则返回明确提示,而不是创建空记录。

isUncertain 是人工证据,适合课后按时间点校正;isKeyPoint 则进入重点复盘。两者都应随字幕 ID 持久化,而不是只保存在按钮视觉状态里。

九、为什么每次结果前要清除旧 current 标记

clearCurrentMarker() 遍历字幕,把所有 isCurrent 设为 false,随后只把本轮目标项设回 true。它保证列表最多只有一个当前行。

这个不变量很重要:如果两个字幕都被标为 current,用户点击“重点”时 findIndex() 只处理第一个,页面高亮和业务动作会不一致。

长列表下每次遍历最多 100 项,当前成本可控;若未来全量列表进入内存,应直接保存 current 索引或用 Map 查找,避免每次回调线性扫描。

十、快照复制如何保护 Service 所有权

getSnapshot() 返回字幕副本:

this.captions.map((item: LiveCaptionItem) => item.copy())

ArkUI 拿到的是一次展示快照,不能通过修改数组或对象反向改变 Service 内部事实。页面上的重点按钮也不直接改 liveCaptions[index],而是调用 Service 动作,再接收新快照。

这形成清晰单向流:

RecognitionListener
  -> Service 归一化
  -> LiveSessionSnapshot 副本
  -> ArkUI 渲染
  -> 用户动作回到 Service

识别回调、当前行、人工标记、快照复制与持久化边界

十一、状态文案也要区分临时与最终结果

项目在临时结果时提示“正在生成真实麦克风字幕”,最终结果时提示“已生成 N 段真实麦克风字幕”。capturedCaptionIds 只在创建新行时追加,因此临时修订不会反复增加段数。

这比直接显示回调次数更有意义。回调次数是技术指标,字幕段数才是用户理解的内容单位。两者可以同时监控,但不能混用。

十二、MAX_VISIBLE_CAPTIONS 的真实边界

超过 100 条后,Service 只保留最后 100 条:

if (this.captions.length > MAX_VISIBLE_CAPTIONS) {
  this.captions = this.captions.slice(
    this.captions.length - MAX_VISIBLE_CAPTIONS
  );
}

这防止 ArkUI 列表、快照复制和 clearCurrentMarker() 无限增长。但当前结束保存也是从 liveCaptions 生成记录,因此被裁掉的早期字幕可能不会进入最终替换写入。

理想设计需要分成:

  • allFinalCaptions 或分批持久化的全量事实;
  • visibleCaptions 的最近 100 条窗口;
  • currentDraft 的单条临时结果;
  • persistedCountvisibleCount 的独立指标。

当前项目尚未完成这层分离,不能把“最多显示 100 条”宣传成“长课堂仍完整保存全部字幕”。

十三、临时结果不应直接持久化

如果每次 partial 都更新数据库,会形成高频事务,并把不断修正的草稿当成稳定课堂事实。更合适的方案是:

partial -> 只更新 currentDraft 和 UI
final -> 进入待持久化批次
每 N 条/每 N 秒 -> 批量事务写入
结束 -> 刷新最后一批并校验计数

异常恢复时还需为最终项设计幂等键,避免重试产生重复字幕。sessionId + caption sequence 比纯时间戳更稳定,也便于按课程和会话查询。

十四、回调频率与 ArkUI 渲染需要节流

当前每次有效识别结果都会 emitSnapshot(),计时器每秒也会发一次快照。短演示没有问题,真实设备上的 partial 回调如果很密集,可能带来列表 key 变化、自动滚动和复制开销。

可以只对临时结果做短窗口合并,例如 50—100 ms;最终结果、错误和用户标记则立即发出。节流必须满足:

  • 不重排最终字幕顺序;
  • 不吞掉 final;
  • 页面延迟仍符合无障碍实时性;
  • 用户暂停自动滚动后不被强制拉回;
  • 性能指标能区分识别延迟与 UI 延迟。

十五、测试矩阵要覆盖哪些回调序列

输入序列 预期字幕结果
partial A → partial AB → final ABC 同一 ID、一行文本最终为 ABC
final A → final B 两个不同 ID、两条最终字幕
空文本 → final A 不创建空行,只保留 A
旧 session final → 当前 session final 忽略旧回调,只展示当前会话
partial A → error A 保留,进入可恢复错误态
final A → 点击重点 A 的 isKeyPoint 切换
final A → 点击没听清 A 的 isUncertain 为 true
超过 100 条 UI 只有最近 100 条,另行验证全量存储策略
快速 partial 高频输入 页面不卡顿,final 不丢失
结束并强停重启 最终字幕按既定持久化策略回读

应使用可控的 Listener 夹具测试归一化,再用真机验证真实回调节奏。只靠对着麦克风说一句话,很难稳定覆盖乱序、旧 session 和高频 partial。

十六、项目证据与未验证项

当前源码已经实现旧 session 过滤、空文本过滤、临时行更新、final 固化、当前行标记、人工重点/没听清、快照复制和 100 条窗口。静态实时听写合约通过,历史设备验收也验证了人工标记和可恢复错误态。

但项目报告明确记录:模拟器没有产生新的真实识别文本,真实 final 回调、真机字幕新增、长课堂全量保存与重启读回均未执行。华为当前指南还注明语音识别能力不支持模拟器。因此本文对回调的分析来自当前源码与官方 API 契约,不把未发生的真机结果写成已通过。

十七、总结

识别回调到字幕之间必须有一层领域归一化:用 session 隔离旧事件,用 current ID 合并临时结果,用 final 切分句子,用人工标记补足课堂语义,再通过不可变快照交给 ArkUI。

听见课堂当前已经建立了这条主链,但 100 条显示窗口和全量持久化仍未真正分层,高频 partial 的节流与真机长课堂也需要补验。只有把“回调、可见、最终、已保存”四个概念分开,实时字幕才不会在演示成功后陷入数据丢失和性能误判。

下一篇将继续拆解暂停、继续和结束为什么是三种不同的生命周期,以及每种状态应该保留和释放哪些资源。

参考:HarmonyOS Core Speech Kit 语音识别指南

Logo

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

更多推荐