Skip to content

fix(voice): smooth TTS playback by splitting chunks and serializing native writes - #399

Merged
yyy-router merged 6 commits into
1024XEngineer:mainfrom
yyy-router:feature/smooth-voice-playback
Aug 27, 2026
Merged

fix(voice): smooth TTS playback by splitting chunks and serializing native writes#399
yyy-router merged 6 commits into
1024XEngineer:mainfrom
yyy-router:feature/smooth-voice-playback

Conversation

@yyy-router

Copy link
Copy Markdown
Contributor

关联

Fixes #398

改动

  • 客户端把句级 TTS 帧切成 ~100ms 小块再喂原生播放器,避开原生播放循环空等 50% 时长抽干缓冲导致的句间静音。
  • 语音条服务用 playbackChain 串行化 startStream/pushChunk/endStream/stop,避免切块后并发 pushChunk 交错乱序。
  • base64 编码改按 32KB 分块拼接,避免 Hermes 逐字节拼接退化。
  • composed agent 增加 TTS 帧级埋点(帧数/字节/最大间隔),便于定位卡顿。

验证

  • 后端 check.sh:1227 passed / 49 skipped,覆盖率 97.25%。
  • 前端:eslint 0 error、tsc 干净、jest 744 passed。

把句级 TTS 帧切成 ~100ms 小块再喂原生播放器,避开原生播放循环写完一块空等 50% 时长、把小缓冲抽干导致的句间静音;base64 编码同步改按 32KB 分块拼接。
语音条服务(AssistantConversationService)用 playbackChain 串行化 startStream/pushChunk/endStream/stop,避免切分后并发 pushChunk 让小块交错乱序。
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Flag Coverage Δ
backend 96.59% <100.00%> (+<0.01%) ⬆️
frontend 90.30% <100.00%> (+0.37%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ackend/src/timeflow/intelligence/composed/agent.py 90.21% <100.00%> (+0.34%) ⬆️
...lication/AssistantContinuousConversationService.ts 93.96% <100.00%> (+0.04%) ⬆️
...istant/application/AssistantConversationService.ts 94.48% <100.00%> (+5.43%) ⬆️
...ontend/src/features/assistant/data/audio/base64.ts 100.00% <100.00%> (ø)
...end/src/features/assistant/data/audio/split-pcm.ts 100.00% <100.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread frontend/src/features/assistant/application/AssistantConversationService.ts Outdated
去掉 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 LUPENGHAN left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ok

@yyy-router
yyy-router merged commit f3e34b9 into 1024XEngineer:main Aug 27, 2026
9 checks passed
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 触发的那一次。
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.

组合式语音 Agent:TTS 回复播放卡顿(句间/词间顿挫)

2 participants