fix(voice): smooth TTS playback by splitting chunks and serializing native writes - #399
Merged
yyy-router merged 6 commits intoAug 27, 2026
Conversation
把句级 TTS 帧切成 ~100ms 小块再喂原生播放器,避开原生播放循环写完一块空等 50% 时长、把小缓冲抽干导致的句间静音;base64 编码同步改按 32KB 分块拼接。
语音条服务(AssistantConversationService)用 playbackChain 串行化 startStream/pushChunk/endStream/stop,避免切分后并发 pushChunk 让小块交错乱序。
Codecov Report✅ All modified and coverable lines are covered by tests.
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
Contributor
There was a problem hiding this comment.
The TTS frame serialization and chunking changes are directionally correct, but the cancellation path still permits an in-flight native write to outlive stop(). This can leave stale audio or a rejected native write after dismiss/cancel; the focused frontend tests could not be run because frontend/node_modules is not present in the workspace.
去掉 gap 测量与 audio_duration 里测试无法触达的死分支(max() 取代 if、删掉恒不命中的 else 0),并补空回复(0 帧)测试覆盖 chunk_count 为 0 的路径。
stopPlaybackImmediately 把 stop 串到 playbackChain 末尾而非直接替换,避免 stop 与在途 pushChunk 竞争、stop 之后又继续写剩余小块;补 voice.tts.end、代次守卫、pushChunk 拒绝三类覆盖测试。
…e hangup - 手动挂断(点"结束对话")或组件卸载时停掉 TTS,避免挂断后音频还在响。 - 语音挂断(voice.session.end,AI 已道别)不截断道别音频,让它播完。 - AppRoot 测试的 ExpoAudioPlayback mock 补上 stop 等方法。
LUPENGHAN
added a commit
to LUPENGHAN/timeflow
that referenced
this pull request
Aug 27, 2026
main 上合并进来的 1024XEngineer#399 给播放操作加了 playbackChain/playbackGeneration 序列化(避免 pushChunk 分片交错、stop 和在途写入竞争)。这条分支里 _startTurn() 开始新一轮时的 stop 调用还是合并前的旧写法,直接调 deps.playback.stop(),绕过了这套序列化——会跟 chainPlayback 里在途的 pushChunk 竞争,且不会让已经排队但还没执行的旧流分片失效。 改成调 stopPlaybackImmediately(),跟 dismissReply()/cancelTurn() 保持 一致。同时修一条现有测试的断言:这次改动让 stop 在每次 _startTurn() 都会被调用一次(即使没有正在播的音频,对应原生层已经修过的空操作 分支),测试原来只预期 dismiss 触发的那一次。
yyy-router
pushed a commit
that referenced
this pull request
Aug 27, 2026
* fix(assistant): 按住说话长回复不再在语音播放中被清空 _startTurn() 开头无条件清 replyText:上一轮回复还在流式、语音还在播时 用户又按住说话,已经显示的气泡会立刻消失,而 TTS 不受影响照常播—— 现象就是"长回复气泡完全不出现"。回复越长,用户在播放中再按一次的 概率越大,所以只有长回复才容易踩到。 叠加了 #364 加的 request_id 轮次门控:新一轮换了 request_id 后, 上一轮剩余的流式增量全部被当成"别人的"丢掉,即使气泡真的显示出来, 后续增量也进不来,replyText 永远停在清空前那一刻。 现在 _startTurn 不再清 replyText,气泡留到被新一轮自己的回复覆盖, 或用户点掉/取消为止;门控放宽成"当前轮,或者气泡本来就在显示的 那一轮"都放行,让上一轮的剩余增量能继续更新已经显示的气泡。 dismissReply()/cancelTurn() 里一并清掉这个"气泡属于哪一轮"的记录, 保住 #364 本来要防的场景——气泡点掉后不会被晚到的旧回复重新弹出来。 * fix(assistant): 按住说话再按一次不再打断上一轮的语音状态和追问 同一类问题的另外两处:voice.dialogue.question 完全没有轮次门控, 上一轮被打断后晚到的追问会把已经推进到新一轮的 phase 强行掰回 asking;voice.tts.start/voice.tts.end 同样没有门控(协议本身也不带 request_id,没法照搬 dialogue.reply 那套判断),上一轮语音还没播完 就开始新一轮时两轮音频会同时播,且上一轮迟到的 tts.end 会把新一轮 的 phase 强行掰回 idle。 voice.dialogue.question 补上跟 reply 一样的 request_id 门控。 新增 abandonedAudioId:新一轮开始(或点掉气泡)时如果上一轮语音还 没播完,主动停止播放并记住这个 audio_id;它迟到的 tts.end 到达时 只清记录,不再碰 phase。isCurrentTurnReply 改名 isCurrentTurn,因为 现在不止 reply 一处在用它。 已知局限:voice.tts.start/voice.tts.end 协议本身不带任何轮次标识, 新一轮开始的瞬间如果恰好卡在服务端取消生效前那个协程调度间隙内 (毫秒级窗口),上一轮迟到的 tts.start 仍会被当成合法消息接受。这 道窄缝要彻底堵上需要后端配合,这次范围只做前端,先记录着。 * fix(assistant): 放弃的音频 id 改用集合,防止连续打断时互相覆盖 abandonedAudioId 之前是单个值:turn 1 的音频还没等到自己的 tts.end 就被 turn 2 自己的音频顶替、又被 turn 3 打断覆盖掉记录,turn 1 迟到 的 tts.end 落进正常分支,把已经推进到 turn 3 的 phase 错误地掰回 idle(PR #401 review 指出)。 改用 Set<string> 记录所有还没等到 tts.end 的放弃 id,命中就从集合 里摘掉;cancelTurn() 里连接整个关闭时顺手清空,避免长会话攒垃圾。 补了对应的多轮打断回归测试。 * test(assistant): 补上点掉气泡时语音正在播放的覆盖 dismissReply() 里新增的放弃-音频逻辑(abandonedAudioIds.add / 清空 currentAudioId)之前没有测试覆盖——所有已有用例点掉气泡时都没有语音 正在播。补一个用例:语音播放中点掉气泡,确认立即停播且这条 audio_id 被记进放弃集合,迟到的 tts.end 不会把随后开始的新一轮状态掰回 idle。 * fix(assistant): 再次按住说话改回清空气泡并停播,跟点掉气泡一致 上一版把 _startTurn() 改成不清 replyText,让气泡跨轮次持续显示。产品 预期不是这样:再次按住说话应该等同于主动点掉气泡(dismissReply)—— 文字立即清空、语音立即停播,然后干净地开始听新一段,不应该让上一轮 内容跟新一轮混在一起。 _startTurn() 里重新加回 this.replyText = null(放弃音频那部分不动)。 删掉 displayedReplyRequestId 字段和相关逻辑——它是上一版"气泡不清空" 设计专用的(让已显示的气泡继续吃旧轮次的流式增量),现在用不上了。 voice.dialogue.reply 门控简化回单纯的 isCurrentTurn(requestId)。 * fix(assistant): 再按住说话时 stop() 改成无条件调用,不再被漏掉 之前 stop() 放在 if (currentAudioId !== null) 里面,只有当前正追踪着 一个 audio_id 时才会调用。长回复的 TTS 可能分好几段下发(一句一个 tts.start/tts.end),如果按下的瞬间恰好卡在上一段 tts.end 和下一段 tts.start 之间,currentAudioId 这时候是 null,stop() 直接被跳过—— 原生播放器完全没收到停止指令,会正常播完当前缓冲区里的音频、甚至 接着播下一段。dismissReply() 里 stop() 本来就是无条件调用的,这处 不一致正是"点气泡能停、按住说话不能停"的根因。 现在 stop() 挪到 if 外面,跟 dismissReply() 保持一致;abandonedAudioIds 的记录仍然只在 currentAudioId 非空时才添加(没有 id 可记就不记)。 * fix(patch): AudioPlaybackManager.stopPlayback() 空队列也要真的停播 playbackChannel 是还没解码写入 AudioTrack 的排队分片,流式播放时它 经常是空的(解码速度快于播放速度),空不代表 AudioTrack 硬件缓冲区 里没有还在播的音频。原实现把它也当"没在播"的判据,导致 stop() 在 这种(很常见的)时刻直接跳过 audioTrack.stop()/flush(),调用方以为 已经停了,实际上已经写进硬件缓冲区的音频还会继续播完。 判据改成只看 isPlaying。这是通过 patch-package 打的补丁,会在 npm install 时自动重新应用。 * fix(assistant): 确认类追问也要写进 replyText,不然气泡永远不出现 voice.dialogue.question(比如"删除某一个"触发的"确认要删除吗?")之前 只写 state.speechText,气泡 UI(AssistantVoiceOverlay)只读 replyText—— 两个完全独立的字段,追问从来没被接进气泡显示逻辑。语音照常播(走 voice.tts.start,跟这个无关),但气泡永远不会出现,也没法点掉。这跟 打断/连续按没有关系,是这条消息类型本身漏了这一步。 现在 voice.dialogue.question 也把 speech_text 写进 replyText。 * fix(assistant): 新一轮开始的 stop 接入 playbackChain/generation 序列化 main 上合并进来的 #399 给播放操作加了 playbackChain/playbackGeneration 序列化(避免 pushChunk 分片交错、stop 和在途写入竞争)。这条分支里 _startTurn() 开始新一轮时的 stop 调用还是合并前的旧写法,直接调 deps.playback.stop(),绕过了这套序列化——会跟 chainPlayback 里在途的 pushChunk 竞争,且不会让已经排队但还没执行的旧流分片失效。 改成调 stopPlaybackImmediately(),跟 dismissReply()/cancelTurn() 保持 一致。同时修一条现有测试的断言:这次改动让 stop 在每次 _startTurn() 都会被调用一次(即使没有正在播的音频,对应原生层已经修过的空操作 分支),测试原来只预期 dismiss 触发的那一次。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
关联
Fixes #398
改动
验证