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';
|
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 {
|
interface UsePlayerResult {
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
queueLength: number;
|
queueLength: number;
|
||||||
@@ -40,6 +73,8 @@ export function usePlayer(): UsePlayerResult {
|
|||||||
const isPlayingRef = useRef(false);
|
const isPlayingRef = useRef(false);
|
||||||
const wasBlockedByRecorderRef = useRef(false);
|
const wasBlockedByRecorderRef = useRef(false);
|
||||||
const isPlayNextInProgressRef = useRef(false);
|
const isPlayNextInProgressRef = useRef(false);
|
||||||
|
/** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」;不依赖 React state 闭包陈旧。 */
|
||||||
|
const currentPlaybackItemRef = useRef<PlaybackItem | null>(null);
|
||||||
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
|
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
|
||||||
const statusRef = useRef<PlayerStatus>('idle');
|
const statusRef = useRef<PlayerStatus>('idle');
|
||||||
/** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */
|
/** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */
|
||||||
@@ -69,6 +104,10 @@ export function usePlayer(): UsePlayerResult {
|
|||||||
statusRef.current = status;
|
statusRef.current = status;
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentPlaybackItemRef.current = currentPlaybackItem;
|
||||||
|
}, [currentPlaybackItem]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 必须在 `isLoaded` 之后再 `play()`。
|
* 必须在 `isLoaded` 之后再 `play()`。
|
||||||
* expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步
|
* expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步
|
||||||
@@ -206,10 +245,23 @@ export function usePlayer(): UsePlayerResult {
|
|||||||
async (item: PlaybackItem) => {
|
async (item: PlaybackItem) => {
|
||||||
/**
|
/**
|
||||||
* 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条,
|
* 用户在助手自动朗读中途点暂停时,`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 = [];
|
queueRef.current = [];
|
||||||
setQueueLength(0);
|
setQueueLength(0);
|
||||||
isPlayingRef.current = false;
|
isPlayingRef.current = false;
|
||||||
|
|||||||
@@ -207,4 +207,47 @@ describe('usePlayer', () => {
|
|||||||
expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst);
|
expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst);
|
||||||
expect(result.current.currentSource).toBe('file:///latest.mp3');
|
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