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 <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-15 17:25:44 +08:00
parent 6452019a1e
commit ddc701f22d
2 changed files with 98 additions and 3 deletions

View File

@@ -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<PlaybackItem | null>(null);
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
const statusRef = useRef<PlayerStatus>('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;

View File

@@ -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);
});
});