
Notes / 前端与移动端
一个聊天 TTS 按钮,为什么要拆成三层边界
从会话开关、消息是否可朗读到 App 后台播放,复盘 Ionic + Vue 聊天 TTS 的权限、内容与生命周期设计。
页面上只有一个按钮,背后却不是一个状态
聊天消息旁边的语音按钮看起来很简单:点击后生成音频,再播放或暂停。实际联调时,问题分散在多个层次:有些会话没有开放语音,有些消息只有旁白没有对白,流式文本尚未结束,签名服务可能要求购买,缓存消息可能没有完整业务标识,App 进入后台后页面也不一定卸载。
如果把这些条件都压进按钮的 v-if,界面可能看起来正确,请求和播放器仍然会留下绕过路径。
我最后把 TTS 拆成三层:
- 会话权限:当前对话是否开放 TTS;
- 消息资格:这条消息是否存在真正需要朗读的内容;
- 请求与播放安全:点击后是否具备有效标识、文本和可控的播放器生命周期。
第一层:会话字段决定功能是否开放
后端在创建会话时返回一个布尔开关。前端需要把它保存到会话记录,再从当前会话传递到消息组件。
这里使用严格布尔判断:只有明确为 true 才开放;字段缺失、false 或其他类型都按关闭处理。这样可以避免旧会话或异常响应意外显示功能。
按钮和隐藏音频元素受这个开关控制,但请求处理函数仍会再次检查。UI 条件只负责展示,不能作为唯一的权限保护;旧状态、手动事件或异步更新都可能绕过它。
第二层:消息是否适合朗读,是另一条规则
产品规则确认后,纯旁白不生成语音,包含有效对白的消息才显示入口。会话开放不代表每一条消息都适合 TTS。
项目内容使用半角双引号标记对白。我把消息拆成:
text -> 真正朗读的对白
context -> 去掉对白后的动作、旁白和场景
context 只给语音服务提供语气参考,不应该被读出来;没有对白时,前端隐藏按钮并在请求层阻止空文本。
流式消息还增加了一个时机问题。文本没有完成时,估算函数可能只能返回一个很短的兜底秒数;最终内容到达后重新估算,用户会看到数字跳变。处理方式是流式阶段不持久化兜底值,等消息稳定后再计算和保存。
角色、消息和播放器需要不同身份
联调过程中,我逐渐确认了三个容易混用的标识:
- 角色稳定标识:决定同一角色长期复用哪一种声音;
- 业务消息标识:用于签名、缓存和消息级请求;
- 音频元素:决定浏览器里当前真正播放的是哪一个实例。
早期播放器用业务消息标识派发互斥事件。部分缓存音频没有完整消息标识,结果是两条音频都能播放,却无法互相通知暂停。
我没有为缓存消息伪造业务 ID,而是让播放器使用真实音频元素作为运行时身份:当前元素继续播放,其他元素立即暂停;不携带播放源的全局事件仍表示“暂停全部”。
业务身份保持业务含义,运行时身份由真实对象承担,修改范围更小,也不会影响消息持久化和接口参数。
购买动作应该复用已有业务入口
生成音频前的签名响应可能带回购买动作。前端不需要再做一套新的弹窗,只需把不同动作映射到项目已有的额度或订阅入口。
这类响应也再次说明,TTS 不是一个纯媒体接口。会话权限、余额、订阅、签名有效期和生成服务共同决定最终结果。组件只处理按钮展示,动作分发和请求安全应该留在已有业务层。
页面离开和 App 进入后台不是同一件事
原页面已经在正常离开时停止音频,但 iOS 锁屏或 App 切到后台时,Ionic 缓存页仍然存在,页面离开生命周期不会稳定触发,音频会继续播放。
最终在 Capacitor 全局 App 生命周期中监听活跃状态:当 App 进入非活跃状态时,暂停缓存聊天页中的音频,并同步播放器状态。返回前台后不自动继续播放,用户可以主动重新开始。
这个问题的根因不在 <audio> 的暂停方法,而在于最初选择了错误的生命周期信号。
分层验证服务链路,也要保持发布边界
联调早期,正式应用链路尚未完全可用。我把验证拆成服务可达、请求认证、语音生成和 iOS WebView 播放四个层次,根据每一层的响应继续缩小问题范围。
技术验证不能绕过正式应用服务的权限与额度控制,也不能把服务间配置带进客户端源码。验证完成后,前端只保留已经确认的文本拆分、语言映射、声音复用和播放器能力,网络入口仍由正式发布架构负责。
验证结果与剩余边界
目前已经验证:
- 会话关闭时不展示也不能触发请求;
- 纯旁白不显示入口,旁白加对白仍可朗读;
- 流式完成后重新估算时长;
- 开场消息具备有效标识时可以生成;
- 同一角色复用稳定声音;
- 多条缓存音频保持互斥;
- iOS 切后台或锁屏时停止播放;
- 定向测试与生产构建通过。
仍需继续覆盖:
- 不同 Android 与 iOS 版本的完整真机矩阵;
- 真实付费限制账号的购买流程;
- 签名过期、连续点击和快速切换角色;
- 后端调整对白格式后的兼容范围。
这次问题留下的判断
会话是否开放、消息是否适合朗读、请求是否安全,是三个不同层次。把它们分开后,按钮展示、内容规则和播放器生命周期各自拥有清楚的职责。
以后看到一个“点击后没反应”的媒体按钮,我不会只检查组件状态。更完整的排查应该同时包含功能权限、内容资格、服务链路、运行时身份和 App 生命周期。
Recommended