fix(conversation): 修复实时会话 TTS/回复被离屏 WS 抢占

- 列表预热仅预取消息缓存,避免后台 WebSocket 覆盖服务端连接
- RealtimeSession UI 回调按 owner 独占,防止 offscreen 覆盖聊天页
- 列表页聚焦时再 prewarm,会话页 TTS 入队优先 base64
- 管线下发 TTS 同时带 audio_base64 与 audio_url;协议说明同步
- 移除 TTS 排查用前后端调试日志,保留错误/告警
- 补充 WS / RealtimeSession / entry-warmup / 播放器相关单测

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-12 10:42:44 +08:00
parent 93be60f74c
commit 3d01085442
18 changed files with 643 additions and 261 deletions

View File

@@ -37,6 +37,7 @@ describe('usePlayer', () => {
});
jest.mocked(audioFocus.acquireForPlayback).mockResolvedValue(true);
jest.mocked(audioFocus.releaseIfOwnedBy).mockResolvedValue(undefined);
jest.mocked(audioFocus.onOwnerChange).mockImplementation(() => jest.fn());
});
test('keeps the native audio session active while app-level audio focus owns teardown', () => {
@@ -127,4 +128,43 @@ describe('usePlayer', () => {
expect(pause).not.toHaveBeenCalled();
expect(result.current.status).toBe('idle');
});
test('retries queued audio after acquire fails once then audio focus frees', async () => {
const acquire = jest.mocked(audioFocus.acquireForPlayback);
acquire.mockResolvedValueOnce(false).mockResolvedValue(true);
let ownerListener: ((owner: null | string) => void) | undefined;
jest.mocked(audioFocus.onOwnerChange).mockImplementation((cb) => {
ownerListener = cb as (owner: null | string) => void;
return jest.fn();
});
mockUseAudioPlayerStatus.mockReturnValue({
isLoaded: true,
playing: false,
currentTime: 0,
duration: 10,
});
const play = jest.fn();
mockUseAudioPlayer.mockReturnValue({ pause: jest.fn(), play });
const { result } = renderHook(() => usePlayer());
await act(async () => {
await result.current.enqueue({
uri: 'file:///queued.mp3',
kind: 'tts_auto',
});
});
expect(acquire).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe('idle');
await act(async () => {
ownerListener?.(null);
});
expect(acquire).toHaveBeenCalledTimes(2);
expect(play).toHaveBeenCalled();
});
});