摘要:中国方言题库的听音能力并不是播放预置方言音频文件,而是在
PracticePage中调用@hms.ai.textToSpeech,把题目的audioHint文本交给系统语音引擎合成。实现采用本地引擎优先、在线引擎回退的策略,用监听器同步开始、完成、停止和错误状态;点击播放时先打开文字兜底弹窗,再尽力启动 TTS;页面离开时停止并关闭引擎。本文面向 HarmonyOS 5.0 及以上版本,基于真实 ArkTS 源码复核引擎创建、重复点击、请求标识、状态恢复、底部弹窗和生命周期释放,并明确zh-CN合成不能等同于真实方言录音。

一、先说清楚:这是 TTS 合成,不是真实方言音频
项目的 Question 模型定义:
ts
export interface Question {
id: string
bankId: string
chapterId: string
type: string
stem: string
audioHint?: string
options: Option[]
answer: string
analysis: string
example?: string
}
audioHint 的注释是"听音题占位描述"。题目数据中存放的是:
ts
{
type: 'audio',
stem: '听音选择:这段四川话表达的意思是?',
audioHint: '莫挨老子',
options: [
'请离我远点',
'请帮我搬东西',
'我饿了',
'走慢点'
],
answer: 0,
analysis: '"莫挨老子"表示"别靠近我",语气较强。'
}
源码没有 .mp3、.wav 资源地址,也没有媒体播放器。它使用 language: 'zh-CN' 的文本转语音引擎朗读短语。因此可以描述为"系统语音合成示例",不能宣传成"真人方言发音""标准母语者录音"或"原生方言音色"。
唯一校验标记:先保证听音题可完成,再尽力提供系统语音合成。
二、真实调用链位于 PracticePage
语音能力的真实核心文件是:
text
entry/src/main/ets/pages/PracticePage.ets
entry/src/main/ets/mock/MockBanks.ets
librarya/src/main/ets/models/Dialect.ets
entry/src/main/module.json5
调用链为:
text
音频题卡片点击
-> toggleAudioPreview(question)
-> 立即打开文字弹窗
-> ensureTtsEngine()
-> 本地引擎或在线引擎
-> speak(audioText)
-> listener 更新状态
-> close / 页面离开时 stop
-> 页面离开时 shutdown
页面详情模块本身不负责播放,播放能力跟随答题会话存在。这样语音状态、当前题目和答题弹窗属于同一个组件生命周期。
三、为什么引擎不是 @State
页面声明:
ts
private ttsEngine?:
textToSpeech.TextToSpeechEngine = undefined
引擎对象是平台资源句柄,不是直接渲染到 UI 的业务数据,因此不需要 @State。真正驱动界面的状态是:
ts
@State activeAudioQuestionId: string = ''
@State audioStatusText: string = ''
@State showAudioDialog: boolean = false
@State audioDialogText: string = ''
@State audioDialogStem: string = ''
这形成清晰分工:
| 类型 | 字段 | 作用 |
|---|---|---|
| 平台资源 | ttsEngine |
创建、朗读、停止、关闭 |
| 活动目标 | activeAudioQuestionId |
标记当前播放题目 |
| 用户反馈 | audioStatusText |
显示播放或错误状态 |
| 弹窗状态 | showAudioDialog |
决定兜底弹窗是否可见 |
| 弹窗内容 | audioDialogText |
显示可参考的发音文本 |
平台对象和声明式状态分开,页面重建时不会把引擎当作普通可观察数据反复替换。
四、文本来源优先 audioHint
页面准备朗读文本:
ts
private audioText(question: Question): string {
return (
question.audioHint || question.stem
).replace(/["""]/g, '').trim()
}
优先使用 audioHint,缺失时回退到题干。随后去除直引号和中文引号,并清理首尾空格。
例如:
text
audioHint = ""莫挨老子""
output = "莫挨老子"
这样可以避免 TTS 把引号或题目提示一并朗读。由于 UI 只有在 type === 'audio' && audioHint 时显示播放区,正常路径几乎总会使用 audioHint;题干回退主要是方法级防御。
五、本地引擎优先,在线引擎回退
ensureTtsEngine() 先检查是否已有引擎:
ts
if (this.ttsEngine) return true
随后尝试本地模式:
ts
this.ttsEngine =
await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 0
})
失败后再尝试在线模式:
ts
this.ttsEngine =
await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 1
})
两次都失败时:
ts
this.audioStatusText =
'当前设备语音引擎不可用'
return false
这个顺序能优先利用设备本地语音服务;在线模式是降级补充。项目的 module.json5 真实声明了:
json5
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
因此不能把整个语音链路描述成"纯离线"。即使本地引擎通常可用,代码仍明确包含在线回退。

六、引擎复用避免每次点击重复创建
这句短路:
ts
if (this.ttsEngine) return true
意味着首次点击完成创建后,后续题目复用同一个引擎。创建平台能力通常比调用 speak() 更重,复用能降低延迟和资源波动。
但复用有一个前提:引擎一旦进入不可恢复错误,当前代码仍保留对象,下一次 ensureTtsEngine() 会直接返回 true。如果平台错误需要重建,onError 中应考虑关闭并置空:
ts
try {
this.ttsEngine?.shutdown()
} catch (_) {
}
this.ttsEngine = undefined
当前源码的错误监听只更新 UI,没有自动重建。这是可改进项,不是现成功能。
七、监听器把平台回调映射为页面状态
引擎创建成功后设置监听器:
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 =
'语音播放失败,请检查系统语音服务'
}
})
四个回调覆盖了开始、自然完成、主动停止和失败。UI 不需要主动轮询引擎,而是由回调收敛状态。
八、requestId 已生成,但回调没有校验
播放请求使用:
ts
requestId: `${question.id}_${Date.now()}`
这个 ID 同时包含题目和时间,能区分连续请求。但监听器接收 requestId 后没有判断它是否仍是最新请求。
可能的竞态是:
- 用户播放 A;
- 很快切换并播放 B;
- A 的停止回调晚到;
- 回调清空 B 的活动状态。
当前页面在新播放前调用 stop(),大多数情况下足够,但严格状态机可以保存:
ts
private activeAudioRequestId: string = ''
回调只处理匹配的请求:
ts
if (requestId !== this.activeAudioRequestId) return
这能防止旧请求改变新请求的 UI。现有源码尚未做该校验。
九、点击后先打开弹窗,再等待引擎
toggleAudioPreview() 的顺序是:
ts
this.activeAudioQuestionId = question.id
this.audioStatusText = '正在播放示例'
this.audioDialogText = this.audioText(question)
this.audioDialogStem = question.stem
this.showAudioDialog = true
完成 UI 状态后,才异步执行:
ts
const ready = await this.ensureTtsEngine()
这个顺序非常关键。用户点击后立刻得到可见反馈,不会因为引擎创建耗时而误以为按钮失效。即使引擎不可用,弹窗仍显示发音文字,用户仍可继续完成题目。
十、播放前先停止旧内容
引擎准备完成后:
ts
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
再发起新请求:
ts
this.ttsEngine.speak(
this.audioText(question),
{
requestId:
`${question.id}_${Date.now()}`
}
)
这避免两段语音同时播放或排队叠加。对于用户连续点不同听音题,最新意图应优先。
需要注意,方法名叫 toggleAudioPreview,但行为不是"点击同一项切换播放/暂停":无论当前是否播放,都会打开弹窗并重新请求。更准确的命名是 openAudioPreview() 或 playAudioPreview()。
十一、外层 try/catch 为什么保证弹窗可用
语音尝试被包在:
ts
try {
const ready = await this.ensureTtsEngine()
if (ready && this.ttsEngine) {
// stop + speak
}
} catch (_) {
}
即使 isBusy()、stop() 或 speak() 抛出异常,前面设置的 showAudioDialog=true 仍然有效。注释也明确写着:
text
后台尽力尝试 TTS,失败也不影响弹窗使用
这体现了能力降级原则:平台语音是增强能力,文字弹窗是任务可完成的基础路径。
不过空 catch 会让 audioStatusText 可能长期停留在"正在播放示例"。更好的做法是在异常分支显式写入失败状态。
十二、为什么文字兜底对听音题很重要
弹窗展示:
ts
Text(this.audioDialogText)
并提示:
text
部分设备未开启语音服务时,
可参考上方文字辨识发音并选出正确答案。
这确保设备没有语音服务时,用户不会卡死在无法作答的题目。对于教育题库,功能完整性比强制依赖某个引擎更重要。
但文字提示会降低纯听力测验的难度。产品应明确它是"学习辅助模式"还是"严格听力考试"。当前实现选择了可完成性优先,不能宣传为封闭式听力测评。
十三、zh-CN 合成不能承诺方言音准
创建参数固定为:
ts
language: 'zh-CN'
person: 0
系统会按可用的中文普通话语音能力合成。"唔该晒""阿拉上海宁""涯爱转屋下"等文本可能被按普通话字音朗读,不能保证声调、变调、声母韵母和地域口音符合对应方言。
若产品需要可评测的方言发音,应使用:
- 获得授权的真人录音;
- 明确支持目标方言的语音模型;
- 音频资源版本与题目版本绑定;
- 播放失败时保留文本兜底;
- 在隐私和版权材料中说明音频来源。
当前源码没有这些录音资源,本文不能伪造。
十四、活动题目标记驱动卡片视觉
播放卡片颜色根据:
ts
this.activeAudioQuestionId ===
this.currentQ()!.id
活动时,图标与波形使用主色;非活动时使用提示色。文案也会切换:
ts
active
? `${audioHint} · ${audioStatusText || '正在播放示例'}`
: `${audioHint} · 点击播放`
activeAudioQuestionId 不保存布尔值,而是保存题目 ID,这比 isPlaying 更能说明"哪一道题正在播放"。当页面允许多个可播放项时,这种状态更可靠。
十五、波形只是静态视觉,不是音频分析
播放卡片和弹窗中的波形来自固定高度数组:
ts
ForEach(
[3, 6, 10, 8, 5, 12, 7, 4, 9, 6, 3, 8, 11, 5, 7],
(h: number) => {
Column()
.width(3)
.height(h)
}
)
它没有读取音频振幅、播放进度或实时频谱。因此应描述为"波形样式装饰"或"播放状态图形",不能叫实时声波可视化。
十六、关闭弹窗时停止当前语音
关闭逻辑:
ts
private closeAudioDialog(): void {
if (this.ttsEngine) {
try {
this.ttsEngine.stop()
} catch (_) {
}
}
this.showAudioDialog = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
用户点击关闭按钮或遮罩,都会调用该方法。先尝试停止语音,再收起弹窗并清状态,避免弹窗消失后声音继续播放。
如果停止失败,UI 仍会关闭。此时平台音频是否继续取决于引擎状态,所以页面离开时还有第二层 shutdown() 保障。
十七、"再听一次"复用现有引擎
弹窗按钮逻辑:
ts
if (this.ttsEngine) {
try {
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(
this.audioDialogText,
{
requestId: `replay_${Date.now()}`
}
)
} catch (_) {
}
}
只有引擎已经创建成功时才执行。若首次创建失败,按钮不会再次调用 ensureTtsEngine(),因此无法自动重试恢复。
更稳妥的实现是让"再听一次"调用统一异步方法:
ts
private async replayAudio(): Promise<void> {
const ready = await this.ensureTtsEngine()
if (!ready || !this.ttsEngine) return
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(
this.audioDialogText,
{ requestId: `replay_${Date.now()}` }
)
}
这是建议方案,当前源码尚未采用。
十八、切换下一题时重置播放标记
goNext() 在普通模式中执行:
ts
this.selectedKey = ''
this.showAnalysis = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''
在错题解析模式中,applyAnalysisState() 也会:
ts
this.activeAudioQuestionId = ''
这能避免下一题错误继承上一题的活动颜色。但这里没有主动 stop();如果用户在语音仍播放时通过其他交互切题,声音可能继续到自然完成。
严格处理可以在题目切换前统一调用 stopAudio()。当前主要关闭路径依赖弹窗关闭,实际交互中用户通常先关闭弹窗。
十九、页面离开时必须 stop 和 shutdown
生命周期释放是:
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() 释放引擎资源,最后置空引用。这样返回上一页、跳到结果页或离开答题页后,不会继续占用语音引擎。
唯一需要注意的是 stop() 和 shutdown() 放在同一个 try 中:如果 stop() 抛出异常,shutdown() 可能不会执行。更稳妥的是分两个保护块,或者使用 finally 确保关闭。

二十、生命周期资源和 UI 状态应统一收口
当前释放引擎后,没有显式重置:
ts
showAudioDialog
activeAudioQuestionId
audioStatusText
页面已经离开时通常不会继续渲染,因此问题不大。但如果组件实例因导航缓存后再次出现,旧弹窗状态可能需要重新确认。
可以提供统一方法:
ts
private releaseAudio(): void {
try {
this.ttsEngine?.stop()
} catch (_) {
}
try {
this.ttsEngine?.shutdown()
} catch (_) {
}
this.ttsEngine = undefined
this.showAudioDialog = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
关闭弹窗只停止,页面离开则完整释放,职责会更清晰。
二十一、在线回退带来的权限与隐私边界
项目真实声明 ohos.permission.INTERNET,语音创建也设置过 online: 1。因此发布材料需要确认:
- 在线语音服务是否实际发送待合成文本;
- 服务由系统还是应用后端提供;
- 隐私政策是否覆盖联网语音处理;
- 无网络时是否仍可完成核心流程;
- 应用是否仍适合描述为"主要本地运行";
- 语音文本是否含个人信息。
本项目的题目短语来自内置题库,不包含用户输入,风险相对可控,但不能因为文本固定就忽略网络行为说明。
二十二、错误反馈目前有两套语义
引擎创建失败:
text
当前设备语音引擎不可用
播放回调失败:
text
语音播放失败,请检查系统语音服务
这两条能区分"没有可用引擎"和"引擎存在但播放失败"。但 toggleAudioPreview() 外层异常被吞掉,可能显示错误的"正在播放示例"。
建议把语音状态收窄为联合类型:
ts
type AudioState =
| 'idle'
| 'preparing'
| 'playing'
| 'unavailable'
| 'failed'
显示文案由纯函数映射,避免在多个回调中直接拼接字符串。
二十三、重复点击与并发创建风险
首次点击时 ensureTtsEngine() 是异步的。若用户在引擎创建完成前快速点击两次,两次调用都可能看到:
ts
this.ttsEngine === undefined
然后并发创建两个引擎。后完成的一个覆盖字段,另一个对象可能失去释放路径。
可以缓存创建中的 Promise:
ts
private ttsCreating?:
Promise<textToSpeech.TextToSpeechEngine> =
undefined
所有调用等待同一个 Promise。或者在 preparing 状态下禁用重复点击。当前源码没有创建锁,这应纳入高频点击测试。
二十四、弹窗布局如何保障小屏可达
听音弹窗使用整页 Column:
text
上部半透明遮罩 -> layoutWeight(1)
底部内容面板 -> 自适应内容高度
底部面板 padding 包含:
ts
bottom:
Sizes.PADDING_LARGE +
this.bottomSafePadding()
因此"再听一次"按钮不会压入系统手势区。背景遮罩与"关闭"文字都可退出,用户不会被不可用的 TTS 困在弹窗中。
如果未来增加更长说明、语速或音色控制,底部内容应增加最大高度和内部 Scroll,防止横屏或大字体下溢出。
二十五、按钮行为与可访问性
播放入口当前是一个 Row,整行绑定 onClick;图标本身 40x40,外层还有 12vp padding,触控范围较好。活动状态不仅通过颜色表达,还带有"正在播放示例"文字。
仍可补充:
- 播放入口的可访问性文本;
- "再听一次"禁用态;
- 引擎准备时的进度反馈;
- 错误状态下"重试"动作;
- 2in1 键盘焦点顺序;
- 屏幕阅读器对波形装饰的忽略。
装饰性波形不应被逐条朗读。
二十六、测试矩阵要覆盖资源生命周期
| 场景 | 期望 |
|---|---|
| 首次播放且本地引擎可用 | 创建一次并开始朗读 |
| 本地失败、在线可用 | 在线回退成功 |
| 两种引擎都失败 | 弹窗仍可见,显示不可用 |
| 连续点击两道题 | 旧播放停止,新内容开始 |
| 点击关闭 | 停止播放并清空活动状态 |
| 点击遮罩 | 与关闭按钮行为一致 |
| 点击再听一次 | 停止当前后重新朗读 |
| 播放过程中切题 | 不保留上一题活动样式 |
| 播放过程中离页 | stop、shutdown、引用置空 |
| 回调发生错误 | 显示失败文案,不崩溃 |
| 无网络 | 本地可用则继续,不可用则文字兜底 |
| 横屏、小窗、大字体 | 弹窗按钮可达且不遮挡 |
还应观察日志确认没有重复创建和离页后回调写入失效页面。
二十七、不要为系统能力异常伪造成功状态
语音引擎不可用时,页面没有伪造"播放完成",而是保留文字兜底。技术文章和测试记录同样应区分:
text
TTS 本地引擎成功
TTS 在线回退成功
TTS 不可用但文字兜底可用
TTS 播放失败
不能把"弹窗打开成功"写成"语音播放成功",也不能根据波形变色推断系统真的发声。唯一可靠证据是引擎回调与真实设备听测。
二十八、如果要升级为真实方言音频
更高质量方案可以保留现有状态机,但替换音源层:
ts
interface PronunciationSource {
kind: 'asset' | 'tts'
uri?: string
text?: string
dialectCode: string
speaker?: string
license?: string
}
优先播放授权音频资源,缺失时再使用 TTS,并在 UI 标注来源。资源层还要处理:
- 下载或包内资源;
- 文件校验;
- 播放器状态;
- 耳机和音频焦点;
- 暂停与继续;
- 版权和音色授权;
- 题库版本迁移。
这是一条产品升级路线,不是当前项目已经实现的功能。
二十九、发布前合规检查
语音功能上架前,应验证:
INTERNET权限与在线 TTS 回退一致;- 隐私政策准确描述可能的在线语音处理;
- 不宣称真人录音或标准方言音色;
- 无语音服务时核心答题仍可完成;
- 页面离开后不继续播放;
- 系统深浅色下播放卡片和弹窗可读;
- 手机、平板、2in1 和小窗下按钮不被遮挡;
- 状态栏与底部导航区安全;
- 安装、启动、播放、答题、切题、退出和卸载无异常;
- 不记录或上传用户私密语音;
- 错误文案不暴露内部错误细节;
- 网络断开和系统语音服务关闭均有真实测试结果。
三十、结语
中国方言题库的语音实现最有价值的不是"调用一次 speak()",而是把平台能力放在可降级的答题流程中:先显示文字弹窗保证任务可完成,再本地优先创建引擎,必要时尝试在线回退;播放前停止旧请求;通过监听器同步状态;关闭弹窗停止声音;页面离开时关闭引擎。
它的边界也必须讲清楚:当前是 zh-CN 系统 TTS,不是真实方言录音;波形是静态装饰;请求 ID 尚未用于过滤旧回调;首次快速重复点击可能并发创建;"再听一次"在首次引擎失败后不会主动重建。只有把这些事实写清楚,后续性能优化、生命周期修复和真实音频升级才有可靠起点。
AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段和工程结论均依据项目真实源码人工复核;未将系统 TTS 描述为真人方言录音,也未伪造播放结果或平台数据。