Skip to content

feat(tts): 自动朗读串行化——合成完成即返回并保留句间预合成流水线 - #1498

Open
542474846 wants to merge 1 commit into
AAswordman:devfrom
542474846:feat/tts-serial-playback
Open

542474846 wants to merge 1 commit into
AAswordman:devfrom
542474846:feat/tts-serial-playback

Conversation

@542474846

Copy link
Copy Markdown

变更说明 / Description

背景与动机 / Context and motivation

自动朗读路径上,流式收集器每凑满一句(TtsSegmenter 按句末标点或 50 字切分)就调用一次 ChatViewModel.speakMessage,而 speakMessage 每次都起一个全新的播放协程,speechPlaybackJob 引用被反复覆盖、旧协程不取消继续运行:一次流式回复会产生几十个并发播放协程。协程体内对 SimpleVoiceProvider(系统 TTS)的 speak 调用经 withContext(Dispatchers.IO) 分发后由线程池并发执行,锁外的 ensureVoiceAndLocaleReady 与 synchronized 内的提交顺序取决于线程调度,句子进入系统 TTS 队列的顺序与 flush 顺序不一致,造成自动朗读跳句、乱序、插队或部分句子丢失。手动朗读是单协程顺序提交,不受影响。

队列型 Provider(QueuedTtsPlayback:HttpVoiceProvider、Doubao;SiliconFlowVoiceProvider 同构队列)的 speak 在播放完成后才返回,原实现依赖并发协程实现"当前句播放时下一句预合成"的流水线,因此不能简单地把调用改成串行等待。

改动范围 / Changes

  • ChatViewModel:朗读请求收敛为单一串行消费者。speakMessage 只入队(SpeakRequest + UNLIMITED Channel),常驻协程按入队顺序逐条以子协程执行并 join,保证 provider.speak 的调用顺序与 flush 顺序一致;interrupt 与 stop 先提升代数再取消当前播放并清空积压,消费者循环与 processSpeakRequest 入口各校验一次代数(覆盖 Main.immediate 下"子协程体内联启动先于引用赋值"的时间窗,与 QueuedTtsPlayback 的 isCurrent 校验同一模式)
  • QueuedTtsPlayback 与 SiliconFlowVoiceProvider:prepare 循环在合成成功即 completion.complete(true) 再入播放队列,speak 的返回时机提前到"已进入播放队列",播放循环不再回传 completion;playPreparedRequest 由返回 Boolean 改为 Unit。串行等待因此不再造成句间合成空窗,流水线保留
  • 清理链沿用主干既有的 TtsCleaner.cleanForSpeech(extractReplyBody 剥离 provider 协议 meta、思考链大小写与属性变体、tool/status/emotion 块),不叠加第二套过滤器
  • 新增实施记录 docs/TODO/tts_auto_read_serial_playback_20261107/index.md

不包含:MiniMax/Mimo 委托 HttpVoiceProvider(内部即 QueuedTtsPlayback),随本改动一并生效,无需单独修改;悬浮窗 SpeechInteractionManager 为 fire-and-forget 调用,不受返回时机变化影响;清理链本身不在本 PR 范围内改动。

兼容性与风险 / Compatibility and risks

  • 手动朗读行为不变
  • 队列型 Provider 保留"当前句播放时下一句预合成",句间无可感知合成空窗
  • 无 strings 变更;无数据格式、API 与 Room 迁移

关联 Issue / Related issue

N/A。背景与实施记录见 docs/TODO/tts_auto_read_serial_playback_20261107/index.md。

验证方式 / Verification

检查或命令:./gradlew :app:compileDebugKotlin -x :app:syncSttModelAssets
环境与变体:JAVA_HOME 指向 Android Studio JBR,Gradle 8.13 / AGP 8.13.2,debug 变体
结果:编译通过(exit 0)。排除 :app:syncSttModelAssets 是因为该任务每次强制重新下载
     STT 模型,本机到下载源不可达会以 ConnectException 中断构建,与 Kotlin 编译无关。
     单元测试未在本机执行,交由 CI。

手工回归(计划记录于 TODO 文档,结果待补):自动朗读日志中 autoRead[cut] 序列与 speech[segmentSpeak] 序列、SimpleVoiceProvider queue[speak.submitted] 序列单调一致;朗读中发送新消息,旧朗读立即停止且旧轮积压句子不再插播;暂停后继续,句子按序恢复不并发涌出;队列型 Provider 句间无可感知合成空窗。

检查清单 / Checklist

  • 我已记录可复现验证和未运行项原因 / Reproducible verification and reasons for unrun checks are recorded
  • 日常开发 PR 的目标分支为 dev;如目标为 main,我已说明这是维护者发布同步 / The target branch is dev for regular work; if it is main, I explained why this is a maintainer-led release sync
  • 我已确认 Candidate checks 覆盖改动范围,并会处理技术失败项 / Candidate checks covers the change scope and technical failures will be addressed
  • 最终 diff 无无关、临时、生成、二进制或敏感内容 / Final diff has no unrelated, temporary, generated, binary, or secret content
  • 已提供对应的回归、UI、文档/字符串或兼容性证据 / Relevant regression, UI, docs/strings, or compatibility evidence is provided

朗读请求收敛为 ChatViewModel 内单一串行消费者:speakMessage 只入队
(SpeakRequest Channel + 代数校验),常驻协程逐条处理后 join,
保证 provider.speak 的调用顺序与 flush 顺序一致,消除并发协程导致的
跳句、乱序与丢失;QueuedTtsPlayback 与 SiliconFlowVoiceProvider 在合成
成功即完成,保留队列型 Provider 的句间预合成流水线。

相对本地分支的合并差异:清理链沿用主干既有的
TtsCleaner.cleanForSpeech(extractReplyBody 剥离协议 meta/思考链/tool 块)
而不是本地实现里的 cleanMessageContentForCopy 叠加,避免同一内容被两套
过滤器重复裁剪。
@CATMIAOZHI
CATMIAOZHI self-requested a review November 13, 2026 07:35

@CATMIAOZHI CATMIAOZHI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审计结论:💬 意见

改动(4 个文件,+247/−75):自动朗读收敛为 ChatViewModel 内的单一串行消费者(SpeakRequest + UNLIMITED Channel + 代数防残留),队列型 Provider 的 speak 返回时机提前到"合成完成、已进入播放队列",保留句间预合成流水线。

通过的检查:

  • 目标分支为 dev,非草稿 PR,单提交,diff 与 PR 描述一致。
  • 串行化根因找得准:此前每次 flush 起独立协程、speechPlaybackJob 反复被覆盖且旧协程不取消,提交顺序取决于线程调度;收敛到调用方单点串行是正确位置(Provider 侧加锁恢复不了已丢失的 flush 顺序)。
  • 中断语义三路调用方都已核对:流式自动朗读仅首句 interrupt=true(MessageProcessingDelegate.tryFlushAutoRead)、waifu 分段仅首段或 Retain 截断后为 true(WaifuSegmentMessageStore)、收尾整条朗读受 skipFinalAutoRead 保护;因此串行消费者的"interrupt 提升代数 + 取消当前播放 + 清空积压"不会在句间误触发。
  • 代数双重校验覆盖了 Main.immediate 下子协程内联启动先于 speechPlaybackJob 赋值的时间窗;stopSpeaking 先升代数再取消、清空,与 provider 侧 stopGeneration 是同一模式。
  • processSpeakRequest 内吞掉 CancellationException 只记日志是必要的:若向上抛,playbackJob.join() 会把取消带进消费者循环,导致常驻消费者永久退出;与旧版协程体行为一致。
  • 无死锁:SimpleVoiceProvider.speak 提交 utterance 后即返回(不等播完),串行 join 只串行化提交顺序;队列型 Provider 靠 prepare 循环预合成,串行等待不造成句间空窗。
  • QueuedTtsPlayback 的改动无行为回归:旧版 playPreparedRequest 本就无条件返回 true(playAudioFile 内部吞异常),播放失败本来也不会回传给调用方。
  • 无 strings 变更、无数据格式/API/Room 迁移;编译作者已验证通过(compileDebugKotlin exit 0)。

问题:

  1. [P2] SiliconFlow 的播放阶段失败现在对调用方完全不可见。旧版播放循环把 playPreparedRequest 的返回值(即 playAudioFileAndAwait 的结果,空文件/MediaPlayer 异常时为 false)回传给 speak() 调用方,ChatViewModel 会据此打 segmentFailed、弹"朗读失败" toast 并中断后续分段,TTS 测试页也会显示失败。新版在合成完成时即 complete(true),播放结果直接丢弃、只记日志。影响面:TextToSpeechScreen 的失败提示对 SiliconFlow 播放失败不再可达;设置里的 TTS 测试工具(StandardSoftwareSettingsModifyTools)的 playbackTriggered/success 现在含义变为"已合成入队"而非"已播放",PR 描述未提及这个调用方。建议:至少在 KDoc/文档里明确这一语义变化;若希望保留可观测性,可记录最近一次播放失败并在日志/测试工具中暴露。
  2. [P2] 新增实施记录 docs/TODO/tts_auto_read_serial_playback_20261107/index.md 的"步骤 3 [DONE]"与 diff 不符:文档称朗读清理链插入了 cleanMessageContentForCopy、修复了思考链大小写/属性变体(<Think>、<think >)并补齐 OpenAI Responses meta、附件与内部媒体链接的整块移除,涉及文件列为 ChatViewModel.kt;但实际 diff 未改动任何清理链文件,ChatViewModel.kt 里也没有 cleanMessageContentForCopy。所描述的过滤能力(思考链变体、OpenAI meta 等)是主干 TtsCleaner.extractReplyBody 已有的行为,并非本 PR 引入;PR 描述"不叠加第二套过滤器"与文档步骤 3 互相矛盾。请修正文档(去掉步骤 3,或改为"复核:主干已有,无需改动"),避免后人误以为本 PR 修了思考链朗读问题。同理,"已知边界"里"播放阶段错误本就只记日志"的说法对 SiliconFlow 不准确(见问题 1,旧版是会回传 false 的)。
  3. [P3] VoiceService.speak 接口 KDoc 仍沿用旧契约(只写"操作是否成功"),未定义成功到底指"提交成功"、"合成并入队成功"还是"播放完成"。两个队列型 Provider 的 KDoc 已更新,建议同步更新接口 KDoc,明确各实现的返回语义,避免后续调用方(例如测试工具)按旧理解使用返回值。
  4. [question] PR 描述中手工回归标注"结果待补",单元测试未在本机执行、交由 CI。自动朗读三条核心回归(autoRead[cut]/speech[segmentSpeak]/queue[speak.submitted] 序列单调一致、朗读中发新消息旧轮不插播、暂停后继续按序恢复)是否已在真机上跑过?若尚未跑,建议补上结果后再合入,因为串行化+返回时机提前这两处改动的正确性主要靠这几条回归覆盖,CI 的编译检查覆盖不到。

后续可跟进(不阻塞):

  • 悬浮窗朗读路径(SpeechInteractionManager 每次 speak 起独立协程)存在同类的并发提交模式,文档已注明不在本次作用域,可后续另开 PR 处理。

由 水晴喵的muse 审计

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants