【中国方言题库|10】HarmonyOS ArkTS 语音播放实战:管理读音播放与页面生命周期

摘要:中国方言题库的听音能力并不是播放预置方言音频文件,而是在 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 后没有判断它是否仍是最新请求。

可能的竞态是:

  1. 用户播放 A;
  2. 很快切换并播放 B;
  3. A 的停止回调晚到;
  4. 回调清空 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。因此发布材料需要确认:

  1. 在线语音服务是否实际发送待合成文本;
  2. 服务由系统还是应用后端提供;
  3. 隐私政策是否覆盖联网语音处理;
  4. 无网络时是否仍可完成核心流程;
  5. 应用是否仍适合描述为"主要本地运行";
  6. 语音文本是否含个人信息。

本项目的题目短语来自内置题库,不包含用户输入,风险相对可控,但不能因为文本固定就忽略网络行为说明。

二十二、错误反馈目前有两套语义

引擎创建失败:

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 标注来源。资源层还要处理:

  • 下载或包内资源;
  • 文件校验;
  • 播放器状态;
  • 耳机和音频焦点;
  • 暂停与继续;
  • 版权和音色授权;
  • 题库版本迁移。

这是一条产品升级路线,不是当前项目已经实现的功能。

二十九、发布前合规检查

语音功能上架前,应验证:

  1. INTERNET 权限与在线 TTS 回退一致;
  2. 隐私政策准确描述可能的在线语音处理;
  3. 不宣称真人录音或标准方言音色;
  4. 无语音服务时核心答题仍可完成;
  5. 页面离开后不继续播放;
  6. 系统深浅色下播放卡片和弹窗可读;
  7. 手机、平板、2in1 和小窗下按钮不被遮挡;
  8. 状态栏与底部导航区安全;
  9. 安装、启动、播放、答题、切题、退出和卸载无异常;
  10. 不记录或上传用户私密语音;
  11. 错误文案不暴露内部错误细节;
  12. 网络断开和系统语音服务关闭均有真实测试结果。

三十、结语

中国方言题库的语音实现最有价值的不是"调用一次 speak()",而是把平台能力放在可降级的答题流程中:先显示文字弹窗保证任务可完成,再本地优先创建引擎,必要时尝试在线回退;播放前停止旧请求;通过监听器同步状态;关闭弹窗停止声音;页面离开时关闭引擎。

它的边界也必须讲清楚:当前是 zh-CN 系统 TTS,不是真实方言录音;波形是静态装饰;请求 ID 尚未用于过滤旧回调;首次快速重复点击可能并发创建;"再听一次"在首次引擎失败后不会主动重建。只有把这些事实写清楚,后续性能优化、生命周期修复和真实音频升级才有可靠起点。


AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段和工程结论均依据项目真实源码人工复核;未将系统 TTS 描述为真人方言录音,也未伪造播放结果或平台数据。

相关推荐
YM52e12 小时前
鸿蒙ArkTS实战项目 - 门店陈列巡检台:巡检卡片与多列切换实现
学习·华为·harmonyos
lilian23313 小时前
Harmony os 技术实战|拼豆制图27:用单字符编码承载 50 张 70×70 图纸
前端·数据库·华为·harmonyos
HwJack2018 小时前
UIAbility 生命周期全链路:从冷启动到热启动的实战笔记
笔记·华为·harmonyos
梦想不只是梦与想19 小时前
鸿蒙 AppGallery Connect:应用创建(二)
harmonyos·appgallery·创建应用
●VON21 小时前
芯笺 Markdown:面向 HarmonyOS PC 的本地优先 Markdown 编辑器
华为·编辑器·harmonyos·鸿蒙
世人万千丶21 小时前
鸿蒙项目实战 - 社区活动编排板:标签云布局算法与自动换行
学习·算法·华为·harmonyos·鸿蒙
YM52e1 天前
鸿蒙ArkTS项目实战 - 门店陈列巡检台:完整代码与运行效果
学习·华为·harmonyos
贾伟康1 天前
【中国方言题库|06】HarmonyOS ArkTS 闽南语与客家话实战:统一分库页面导航与空状态
harmonyos·arkts·arkui·router·空状态
贾伟康1 天前
【中国方言题库|07】HarmonyOS ArkTS 方言练习实战:推进题目、提交答案并同步统计
harmonyos·arkts·数据持久化·状态管理·arkui