From ddc701f22def5905bf662941f507b35b804c567a Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 15 May 2026 17:25:44 +0800 Subject: [PATCH] fix(voice): queue split TTS segments after pause without replacing track Detect consecutive tts_auto items on the same assistant bubble via listKey (uuid_seg_n / uuid_part_n). When paused, skip the 'clear queue and play latest only' path so later segments enqueue instead of wiping playback. Add regression test. Co-authored-by: Cursor --- .../src/features/voice/hooks/use-player.ts | 58 ++++++++++++++++++- .../tests/features/voice/use-player.test.tsx | 43 ++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index 57380b5..edee04b 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -5,6 +5,39 @@ import { audioFocus } from '@/core/audio/audio-focus'; import type { PlaybackItem, PlayerStatus } from '../types'; +/** + * `handleTtsSegment` 使用 `assistantSegmentMessageId` → `{uuid}_seg_{n}`; + * 展平气泡使用 `{uuid}_part_{n}`。同一条落库助手消息上的连续分段应用入队续播, + * 而不是「暂停后又到一条 tts_auto 就整轨切换成最新」——否则多段朗读只会听到最后一段。 + */ +function parseAssistantSplitListKey(listKey: string | undefined): { + messageId: string; + segmentIndex: number; +} | null { + if (!listKey) return null; + const m = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i.exec( + listKey, + ); + if (!m) return null; + return { messageId: m[1]!, segmentIndex: Number(m[2]) }; +} + +function isLaterSegmentOfSameAssistantBubble( + current: PlaybackItem | null | undefined, + incoming: PlaybackItem, +): boolean { + if (incoming.kind !== 'tts_auto' || current?.kind !== 'tts_auto') { + return false; + } + const a = parseAssistantSplitListKey(current.messageRef?.listKey); + const b = parseAssistantSplitListKey(incoming.messageRef?.listKey); + if (!a || !b) return false; + return ( + a.messageId === b.messageId && b.segmentIndex > a.segmentIndex + ); +} + interface UsePlayerResult { status: PlayerStatus; queueLength: number; @@ -40,6 +73,8 @@ export function usePlayer(): UsePlayerResult { const isPlayingRef = useRef(false); const wasBlockedByRecorderRef = useRef(false); const isPlayNextInProgressRef = useRef(false); + /** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」;不依赖 React state 闭包陈旧。 */ + const currentPlaybackItemRef = useRef(null); /** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */ const statusRef = useRef('idle'); /** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */ @@ -69,6 +104,10 @@ export function usePlayer(): UsePlayerResult { statusRef.current = status; }, [status]); + useEffect(() => { + currentPlaybackItemRef.current = currentPlaybackItem; + }, [currentPlaybackItem]); + /** * 必须在 `isLoaded` 之后再 `play()`。 * expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步 @@ -206,10 +245,23 @@ export function usePlayer(): UsePlayerResult { async (item: PlaybackItem) => { /** * 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条, - * 后续 `tts_auto` 只会堆在队列里且不会 `playNext`。 - * 新片段到达时表示「最新已生成」:清掉暂停态与积压队列,只播本条。 + * 后续 `tts_auto` 默认堆在队列里且不会 `playNext`。 + * 无分段 listKey 时:新片段到达表示「另一条 / 最新一条」应只播它 → 清暂停态与队列。 + * 有 `{uuid}_seg_{n}` 且 n 递增:同一落库助手消息的多段 TTS → 只入队,不抢轨。 */ - if (item.kind === 'tts_auto' && statusRef.current === 'paused') { + const skipPausedClearForSplitContinue = + item.kind === 'tts_auto' && + statusRef.current === 'paused' && + isLaterSegmentOfSameAssistantBubble( + currentPlaybackItemRef.current, + item, + ); + + if ( + item.kind === 'tts_auto' && + statusRef.current === 'paused' && + !skipPausedClearForSplitContinue + ) { queueRef.current = []; setQueueLength(0); isPlayingRef.current = false; diff --git a/app-expo/tests/features/voice/use-player.test.tsx b/app-expo/tests/features/voice/use-player.test.tsx index 0bdd40d..45e5a08 100644 --- a/app-expo/tests/features/voice/use-player.test.tsx +++ b/app-expo/tests/features/voice/use-player.test.tsx @@ -207,4 +207,47 @@ describe('usePlayer', () => { expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst); expect(result.current.currentSource).toBe('file:///latest.mp3'); }); + + test('after pause, next uuid_seg tts_auto queues without replacing current (multi-segment TTS)', async () => { + const aid = '78b32c06-d2f9-453b-9cc4-354e68fbcb2d'; + mockUseAudioPlayerStatus.mockReturnValue({ + isLoaded: true, + playing: false, + currentTime: 0.1, + duration: 10, + }); + const pause = jest.fn(); + const play = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play }); + + const { result } = renderHook(() => usePlayer()); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///seg0.mp3', + kind: 'tts_auto', + messageRef: { listKey: `${aid}_seg_0` }, + }); + }); + + expect(result.current.status).toBe('playing'); + expect(result.current.currentSource).toBe('file:///seg0.mp3'); + + act(() => { + result.current.pausePlayback(); + }); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///seg1.mp3', + kind: 'tts_auto', + messageRef: { listKey: `${aid}_seg_1` }, + }); + }); + + expect(result.current.status).toBe('paused'); + expect(result.current.currentSource).toBe('file:///seg0.mp3'); + expect(result.current.queueLength).toBe(1); + }); });