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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user