给练习页加一个"点击播放"入口并不难,难的是让语音状态与题目状态保持一致。用户可能在引擎初始化期间关闭弹窗,可能连续点击两道题,也可能在朗读尚未结束时退出页面。如果代码只调用一次 speak(),没有处理停止、完成、错误和页面销毁,上一题的声音就可能在下一题继续播放,甚至页面退出后仍占用语音引擎。
另一个常见误解是把文字转语音、语音识别和语音作答混为一谈。文字转语音只负责把题目文本朗读出来,不会监听麦克风,也不会识别用户答案。若应用没有申请麦克风权限、没有语音识别服务和答案映射,就不能把功能描述成"语音答题"。
本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 PracticePage.ets、MathModels.ets、ThemeConstants.ets、module.json5 与题库数据。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前代码保留了旧 audio 题型的点击朗读能力:优先尝试离线 TTS,失败后尝试在线引擎,并始终显示文字提示弹窗作为降级路径;当前默认题库没有 type === 'audio' 或 audioHint 数据,所以正常口算流程不会显示该入口。本文讨论的是这条可复核的兼容链路及其工程完善方法,不把它写成默认题库已经启用的语音功能。

本文重点解决五个问题:
- 明确当前语音入口的真实触发条件和能力边界;
- 拆解离线优先、在线回退、文字兜底的初始化流程;
- 协调朗读请求、弹窗、切题、作答与页面退出;
- 用请求标识避免旧回调覆盖新题状态;
- 建立无引擎、无网络、快速切换和生命周期测试矩阵。
一、语音入口只为旧 audio 题型渲染
练习页并不会给每一道口算题显示播放按钮。真实条件是:
ts
if (
this.currentQ()!.type === 'audio' &&
this.currentQ()!.audioHint
) {
// 渲染提示卡和点击播放入口
}
两个条件必须同时满足:
Question.type等于audio;Question.audioHint有值。
题目模型只把 audioHint 声明为可选字段:
ts
export interface Question {
id: string
bankId: string
chapterId: string
type: string
stem: string
audioHint?: string
options: Option[]
answer: string
analysis: string
example?: string
}
对当前源码执行题库扫描时,默认 mock 题库没有发现 type: 'audio',也没有实际 audioHint 数据。toggleAudioPreview() 上方注释也明确写着"兼容旧数据的提示弹窗,当前口算题库默认不会触发"。
因此准确描述是:项目保留了旧语音提示题型的兼容实现,但默认口算题库暂未启用。不能仅凭页面里存在 TTS 代码,就宣称所有题目都支持自动报题。
二、它是文字转语音,不是语音识别
页面导入的是:
ts
import textToSpeech from
'@hms.ai.textToSpeech'
代码把文字交给语音引擎输出声音,没有创建录音器,没有读取麦克风,也没有把用户说出的内容转换成答案。module.json5 中的:
json5
"requestPermissions": []
进一步说明当前模块没有声明麦克风权限。作答仍然通过 ArkUI 选项点击完成:
ts
private selectOption(key: string): void {
if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) return
this.selectedKey = key
this.showAnalysis = true
// 记录正确或错误
}
功能边界可以整理为:
| 能力 | 当前是否存在 | 证据 |
|---|---|---|
| 点击后朗读提示文字 | 有兼容代码 | ttsEngine.speak() |
| TTS 失败时看文字提示 | 有 | showAudioDialog |
| 自动朗读每一道普通口算题 | 无 | UI 只匹配 audio + audioHint |
| 语音识别用户答案 | 无 | 没有 ASR 或录音链路 |
| 麦克风作答 | 无 | 权限列表为空 |
| 根据声纹判断儿童身份 | 无 | 没有采集与模型 |
把边界写清楚不仅是技术准确性问题,也关系到权限和隐私说明。TTS 输出与麦克风输入是两条完全不同的能力链。
三、朗读文本优先取 audioHint
页面通过一个小函数生成朗读内容:
ts
private audioText(question: Question): string {
return (
question.audioHint || question.stem
)
.replace(/["""]/g, '')
.trim()
}
优先使用 audioHint,为空时回退到题干 stem,同时去掉中英文引号并清理首尾空格。这样可以避免引擎把引号读成不自然停顿,也允许旧题目用更适合听觉表达的提示文字。
不过当前 UI 的显示条件要求 audioHint 存在,所以正常点击入口不会触发 stem 回退。这个回退属于方法级防御,不等于所有普通题干都可点击朗读。
若未来要让全部题目支持报题,可以把"是否显示入口"和"读什么文本"拆开:
ts
private canReadQuestion(
question?: Question
): boolean {
return question !== undefined &&
this.audioText(question).length > 0
}
然后再定义题型朗读规则。例如选择题可读题干和选项,应用题只读题干,公式中的符号需转换成自然语言。不能简单把任意 ArkTS 字符串都交给 TTS,再假设可听懂。
四、引擎初始化采用离线优先、在线回退
ensureTtsEngine() 先检查现有实例,避免每次点击都重新创建:
ts
private async ensureTtsEngine():
Promise<boolean> {
if (this.ttsEngine) return true
try {
this.ttsEngine =
await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 0
})
} catch (_) {
try {
this.ttsEngine =
await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 1
})
} catch (_) {
this.audioStatusText =
'当前设备语音引擎不可用'
return false
}
}
// 注册监听器
return true
}
真实策略是:
- 已有引擎时直接复用;
- 优先创建中文离线引擎;
- 离线创建失败后尝试在线模式;
- 两次都失败时返回
false并更新状态文字。
离线优先能减少网络依赖和等待,但是否成功仍取决于设备是否提供对应语言资源。在线回退也不能被理解为必然成功:当前 module.json5 没有声明网络权限,设备服务状态和系统能力也可能不同。源码没有执行网络能力预检,所以文章只能说"尝试在线引擎",不能承诺离线缺失时一定可播放。

五、文字弹窗是核心降级能力
点击提示卡时,页面先更新 UI,再后台尝试 TTS:
ts
private async toggleAudioPreview(
question: Question
): Promise<void> {
this.activeAudioQuestionId = question.id
this.audioStatusText = '正在播放示例'
this.audioDialogText = this.audioText(question)
this.audioDialogStem = question.stem
this.showAudioDialog = true
try {
const ready = await this.ensureTtsEngine()
if (ready && this.ttsEngine) {
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(
this.audioText(question),
{
requestId:
`${question.id}_${Date.now()}`
}
)
}
} catch (_) {
}
}
顺序很关键:弹窗不会等待引擎创建完成。即使引擎不存在、在线回退失败或 speak() 抛出异常,用户仍然能看到 audioDialogText,不会因为语音能力不可用而失去题目提示。
这是一种"核心任务优先"的降级设计:
- 核心任务:理解提示并继续作答;
- 增强能力:听到语音;
- 失败策略:增强能力失败,文字仍可用;
- 页面结果:不阻断答题。
如果把弹窗显示放在 ensureTtsEngine() 成功之后,设备没有语音引擎时连文字提示也无法打开,兼容入口就会变成失败入口。
六、播放前停止旧请求
引擎已经忙碌时,代码先调用 stop():
ts
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(text, {
requestId
})
这能避免两个提示同时朗读。用户重复点击"再听一次"或快速触发新内容时,旧任务先停,新任务再开始。
不过"调用 stop 后立即 speak"仍然需要关注回调顺序。旧请求可能触发 onStop,新请求随后触发 onStart。当前监听器不比较 requestId:
ts
onStop: (requestId: string) => {
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
如果旧请求的 onStop 晚于新请求的 onStart 到达,就可能把新题的播放状态清空。声音仍在播放,UI 却显示未播放。
更稳的方式是保存当前请求标识:
ts
private activeTtsRequestId: string = ''
private isActiveRequest(
requestId: string
): boolean {
return requestId ===
this.activeTtsRequestId
}
发起朗读时:
ts
const requestId =
`${question.id}_${Date.now()}`
this.activeTtsRequestId = requestId
this.ttsEngine.speak(text, {
requestId
})
监听器只处理当前请求:
ts
onComplete: (requestId: string) => {
if (!this.isActiveRequest(requestId)) {
return
}
this.activeTtsRequestId = ''
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
这不是为了增加复杂度,而是为了让异步回调不能修改已被后续操作替代的状态。
七、监听器把引擎事件映射成页面状态
当前代码注册四类事件:
ts
this.ttsEngine.setListener({
onStart: (requestId: string) => {
this.audioStatusText = '正在播放示例'
},
onComplete: (requestId: string) => {
this.activeAudioQuestionId = ''
this.audioStatusText = ''
},
onStop: (requestId: string) => {
this.activeAudioQuestionId = ''
this.audioStatusText = ''
},
onError: (
requestId: string,
errorCode: number,
errorMessage: string
) => {
this.activeAudioQuestionId = ''
this.audioStatusText =
'语音播放失败,请检查系统语音服务'
}
})
对应状态机可以写成:
| 事件 | 活跃题目 | 状态文字 |
|---|---|---|
| 点击 | 当前题 ID | 正在播放示例 |
onStart |
当前题 ID | 正在播放示例 |
onComplete |
清空 | 清空 |
onStop |
清空 | 清空 |
onError |
清空 | 播放失败 |
| 关闭弹窗 | 清空 | 清空 |
| 页面退出 | 引擎停止并销毁 | 页面不再接收状态 |
错误回调接收了 errorCode 和 errorMessage,但当前没有记录或分级处理。发布版本不应把底层错误直接展示给儿童用户,不过开发日志可以记录错误码,便于区分引擎缺失、资源不可用、服务异常或请求参数问题。
八、关闭弹窗必须同时停止声音
文字提示弹窗提供遮罩点击、右上角"关闭"和"再听一次"。关闭动作执行:
ts
private closeAudioDialog(): void {
if (this.ttsEngine) {
try {
this.ttsEngine.stop()
} catch (_) {
}
}
this.showAudioDialog = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
如果只把 showAudioDialog 设为 false,声音仍会继续,用户会认为页面失控。视图状态和媒体状态必须一起收敛。
"再听一次"的源码只在引擎已存在时执行:
ts
if (this.ttsEngine) {
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(
this.audioDialogText,
{ requestId: `replay_${Date.now()}` }
)
}
若首次初始化失败,按钮点击不会重新调用 ensureTtsEngine(),也没有额外提示。更完整的实现应复用统一朗读入口,让重试能够再次初始化,并在失败时保持明确状态。
ts
private async replayAudio(): Promise<void> {
const ready = await this.ensureTtsEngine()
if (!ready || !this.ttsEngine) {
this.audioStatusText =
'语音暂不可用,请阅读文字提示'
return
}
// 停旧请求并发起新请求
}
九、页面退出时 stop 与 shutdown 缺一不可
aboutToDisappear() 负责清理计时器和语音引擎:
ts
aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
if (this.ttsEngine) {
try {
this.ttsEngine.stop()
this.ttsEngine.shutdown()
} catch (_) {
}
this.ttsEngine = undefined
}
}
stop() 终止当前朗读,shutdown() 释放引擎资源,随后把引用设为 undefined。下次重新进入页面时,ensureTtsEngine() 会创建新实例。
只调用 stop() 可能留下引擎资源;只调用 shutdown() 而不先停止,行为更难预测。页面生命周期是媒体能力的所有权边界:页面创建或首次使用时获得引擎,页面消失时释放。
异步初始化还有一个竞态:用户点击播放后立即退出,createEngine() 可能在页面消失后才完成。当前 aboutToDisappear() 执行时 ttsEngine 仍是空,随后异步结果写回页面字段。可增加页面活跃标识:
ts
private pageActive: boolean = false
aboutToAppear(): void {
this.pageActive = true
// 原有参数加载
}
aboutToDisappear(): void {
this.pageActive = false
this.releaseTts()
}
创建完成后检查:
ts
const engine =
await textToSpeech.createEngine(config)
if (!this.pageActive) {
engine.shutdown()
return false
}
this.ttsEngine = engine
这样,页面已经退出的异步结果不会重新占用资源。

十、切题、答题和语音状态要统一
练习页不仅有"下一题",还有答题卡跳转、自动下一题、错题解析和考试模式。当前 goNext() 在进入新题时会清空:
ts
this.selectedKey = ''
this.showAnalysis = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''
答题卡点击也会清空 activeAudioQuestionId。但单纯清空 UI 状态不等于停止引擎。如果未来语音弹窗不再阻塞切题,或加入自动朗读,就应把"停止当前朗读"抽成统一方法:
ts
private stopCurrentAudio(): void {
if (this.ttsEngine) {
try {
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
} catch (_) {
}
}
this.activeTtsRequestId = ''
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
所有题目切换路径都先调用它:
- 点击下一题;
- 自动进入下一题;
- 答题卡跳题;
- 考试自动提交;
- 返回上一页;
- 关闭提示弹窗。
媒体状态不能散落在多个按钮里,否则新增一个导航路径就可能漏掉清理。
十一、UI 中的波形只是装饰
提示卡和弹窗使用固定高度数组绘制柱状波形:
ts
ForEach(
[3, 6, 10, 8, 5, 12, 7, 4, 9],
(height: number, index: number) => {
Column()
.width(3)
.height(height)
.backgroundColor(Colors.PRIMARY)
}
)
这些柱形不会读取真实音频振幅,也不会随播放进度变化。它们只是视觉提示,不能描述成实时声波、音量检测或语音分析。
若要表达真实播放状态,可以使用简单、可验证的状态动画:
idle:静态图标;preparing:加载指示;speaking:循环高度动画;error:错误图标和文字;completed:恢复静态状态。
即使做动画,也应称为播放状态动效,而不是实时音频波形,除非确实接入了音频振幅数据。
十二、无权限不代表无合规边界
当前模块没有请求麦克风权限,这与"只输出语音、不采集用户声音"的实现一致。也没有看到录音、声纹、上传语音或语音识别链路。
但在线 TTS 回退仍需要在发布前核对:
- 在线模式是否需要网络权限;
- 文字是否会离开设备;
- 使用的系统或 HMS 服务是否涉及服务声明;
- 离线不可用、网络不可用时是否仍可完成答题;
- 隐私政策是否与真实数据流一致。
如果无法确认在线文本处理边界,保守方案是只启用离线引擎,并保留文字弹窗。不能为了提高播放成功率而悄悄增加网络能力,再继续把应用描述成完全离线。
本文不宣称在线回退当前必然可用。源码只体现了创建参数中的 online: 1 尝试,实际能力仍需在目标设备和发布配置上验证。
十三、audio 题型还缺少共享标签
questionTypeLabel() 目前只处理:
ts
'add', 'sub', 'mul', 'div',
'mixed', 'word', 'speed'
没有 audio 分支,因此旧语音题进入练习页时,题型标签会走默认值"题目"。这不会阻止播放,但会让语义不完整。
如果决定恢复该题型,应同步补齐:
ts
case 'audio':
return '听题练习'
还要同步检查:
- 分类目录是否需要出现该类型;
- 搜索页是否允许筛选;
- 题库计数是否统计它;
- 题目图标和颜色是否有对应配置;
- 测试数据是否真的包含
audioHint; - 上架描述是否准确说明为 TTS 提示。
不能只在练习页增加一个分支,就认为新题型已经贯穿完整产品链。
十四、推荐的语音状态模型
当前页面用多个字符串和布尔值表达状态:
ts
activeAudioQuestionId
audioStatusText
showAudioDialog
audioDialogText
功能继续扩展时,可以引入明确状态:
ts
type AudioPhase =
'idle' |
'preparing' |
'speaking' |
'stopped' |
'error'
interface AudioUiState {
phase: AudioPhase
questionId: string
requestId: string
message: string
}
状态转换集中处理:
ts
private setAudioState(
next: AudioUiState
): void {
this.audioPhase = next.phase
this.activeAudioQuestionId =
next.questionId
this.activeTtsRequestId =
next.requestId
this.audioStatusText =
next.message
}
这样更容易回答"按钮此刻能否点击""旧回调能否更新 UI""关闭弹窗后是否仍在 preparing"等问题。状态模型不是为了追求形式,而是把异步边界写成可检查规则。
十五、常见故障与排查顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 普通口算题没有播放入口 | 题型是否为 audio 且有 audioHint |
当前默认题库本就不会触发 |
| 点击后只有文字没有声音 | 离线与在线引擎是否都失败 | 保留文字提示并记录错误码 |
| 退出页面后仍在朗读 | aboutToDisappear() 是否执行 |
stop + shutdown + undefined |
| 新题播放但 UI 显示空闲 | 旧请求回调是否覆盖新状态 | 比较 requestId |
| 重播按钮无反应 | 引擎是否初始化失败 | 重播时再次调用 ensureTtsEngine() |
| 两段语音重叠 | 新请求前是否停止旧任务 | isBusy() 后 stop() |
| 页面退出后引擎又创建 | 异步初始化是否晚返回 | 增加 pageActive 检查 |
| 题型标签只显示"题目" | 标签映射是否缺少 audio |
补齐共享题型配置 |
| 波形不随声音变化 | 固定高度数组是装饰 | 不描述成实时波形 |
| 误以为可语音答题 | 是否存在 ASR 和麦克风权限 | 明确当前只有 TTS 输出 |
排查时建议记录 questionId、requestId、页面是否活跃、引擎是否存在、isBusy() 和最后一次回调。不要记录用户隐私文本或任何认证信息。
十六、可执行测试矩阵
真实设备测试至少覆盖:
| 场景 | 操作 | 预期 |
|---|---|---|
| 默认题库 | 打开普通加减乘除题 | 不出现旧 audio 提示入口 |
| 兼容题 | 注入 audio + audioHint |
显示提示卡 |
| 离线引擎可用 | 点击播放 | 朗读并显示播放状态 |
| 离线不可用、在线可用 | 点击播放 | 尝试在线模式 |
| 两种引擎都不可用 | 点击播放 | 文字弹窗仍可阅读 |
| 连续点击 | 重复播放 | 旧任务停止,不重叠 |
| 快速关闭 | 播放后关闭弹窗 | 声音立即停止 |
| 快速退出 | 初始化期间返回 | 不残留引擎 |
| 旧回调晚到 | 新请求已开始 | 不清空新请求状态 |
| 重播 | 点击"再听一次" | 使用同一文本重新朗读 |
| 无语音 | 阅读提示并选择答案 | 答题流程完整可用 |
还要检查手机、平板和 2in1:
- 弹窗底部避开系统导航区;
- 长提示文本不会挤出按钮;
- 窄屏内部可滚动;
- 横屏或小窗口中关闭按钮始终可见;
- 外接键盘焦点能到达关闭与重播操作;
- 深浅色下文字、图标和遮罩对比度可读。
十七、发布前复核清单
面向 HarmonyOS 5.0 及以上版本发布前,应确认:
- 当前能力准确命名为文字转语音提示,而非语音识别;
- 默认题库没有语音题时,不在介绍中声称全题库可朗读;
- 离线引擎优先策略经过目标设备验证;
- 在线回退的权限、网络和隐私边界已核对;
- TTS 失败不阻断文字阅读和选项作答;
- 每次新朗读前停止旧任务;
- 回调用
requestId隔离旧请求; - 页面退出执行
stop()与shutdown(); - 异步创建结果不会在页面退出后重新挂载;
- 关闭、切题、答题卡跳题和自动提交都能停止朗读;
audio题型恢复时同步补齐标签、分类和测试数据;- 固定柱状动效没有被描述成实时声波;
- 无麦克风权限、无录音和无语音上传的事实与隐私材料一致;
- 弹窗在手机、平板和 2in1 的安全区内可完整操作。
总结
语音报题不是一次 speak() 调用,而是一条由题目条件、文本准备、引擎创建、请求标识、回调状态、文字降级和生命周期释放共同组成的链路。口算王当前源码已经提供离线优先、在线尝试、文字弹窗、停止旧播放和页面退出销毁等基础能力,但它只服务于旧 audio 题型,默认题库并未触发。
继续完善时,应优先增加请求级状态隔离、页面活跃检查、统一停止入口和重试路径,再决定是否把朗读扩展到普通题型。只要声音失败时仍能理解题目、旧请求不能污染新题、页面退出后不残留资源,这项增强能力才真正服从答题主流程,而不是反过来干扰它。
本文由 AI 辅助整理,所有现有能力边界、代码路径与权限结论均依据项目真实源码复核。