feat(conversation): TTS 投递与 WebSocket 管线;客户端播放门禁与会话页联动;COS 键与迁移脚本调整

This commit is contained in:
Kevin
2026-03-26 15:51:24 +08:00
parent c23931ec91
commit d990399112
22 changed files with 630 additions and 74 deletions

View File

@@ -1,5 +1,5 @@
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { audioFocus } from '@/core/audio/audio-focus';
@@ -10,6 +10,8 @@ interface UsePlayerResult {
queueLength: number;
/** Current playback source URI (file, https, or data URL). */
currentSource: string | null;
/** 当前正在播放的队列项(含 kind / messageRef队列为空或未开始为 null */
currentPlaybackItem: PlaybackItem | null;
enqueue: (item: PlaybackItem) => void;
/** Replace queue and play this item (e.g. user voice bubble vs other sources). */
enqueueExclusive: (item: PlaybackItem) => Promise<void>;
@@ -29,6 +31,8 @@ export function usePlayer(): UsePlayerResult {
const [status, setStatus] = useState<PlayerStatus>('idle');
const [queueLength, setQueueLength] = useState(0);
const [currentSource, setCurrentSource] = useState<string | null>(null);
const [currentPlaybackItem, setCurrentPlaybackItem] =
useState<PlaybackItem | null>(null);
const isPlayingRef = useRef(false);
const wasBlockedByRecorderRef = useRef(false);
const isPlayNextInProgressRef = useRef(false);
@@ -37,16 +41,30 @@ export function usePlayer(): UsePlayerResult {
/** 当前 source 是否已进入过 playing=true避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */
const trackHasPlayedRef = useRef(false);
const player = useAudioPlayer(currentSource, { downloadFirst: false });
/** 远程 HTTPS 需先下载再解码,否则再读(仅 URL、无 base64可能无声本地/data URL 保持 false */
const playerOptions = useMemo(() => {
const remote =
typeof currentSource === 'string' &&
(currentSource.startsWith('https://') ||
currentSource.startsWith('http://'));
return { downloadFirst: remote };
}, [currentSource]);
const player = useAudioPlayer(currentSource, playerOptions);
const playerStatus = useAudioPlayerStatus(player);
// Start playback when a new source is set
/**
* 必须在 `isLoaded` 之后再 `play()`。
* expo-audio 在 `downloadFirst: true` 时先用 null 建 player再在内部 effect 里异步
* `resolveSourceWithDownload` 后 `replace()`(见 node_modules/expo-audio/build/ExpoAudio.js
* 若仅在 `currentSource` 变化时立刻 `play()`,会在 replace 完成前播放 → 远程 URL再读无声。
*/
useEffect(() => {
if (currentSource && player) {
player.play();
isPlayingRef.current = true;
}
}, [currentSource, player]);
if (!currentSource || !player) return;
if (!playerStatus.isLoaded) return;
player.play();
isPlayingRef.current = true;
}, [currentSource, player, playerStatus.isLoaded]);
const playNext = useCallback(async () => {
if (isPlayNextInProgressRef.current) return;
@@ -54,6 +72,7 @@ export function usePlayer(): UsePlayerResult {
try {
if (queueRef.current.length === 0) {
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
setStatus('idle');
setQueueLength(0);
@@ -74,6 +93,7 @@ export function usePlayer(): UsePlayerResult {
setStatus('playing');
trackHasPlayedRef.current = false;
playbackActiveUriRef.current = next.uri;
setCurrentPlaybackItem(next);
setCurrentSource(next.uri);
} finally {
isPlayNextInProgressRef.current = false;
@@ -147,6 +167,7 @@ export function usePlayer(): UsePlayerResult {
player.pause();
}
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
@@ -165,6 +186,7 @@ export function usePlayer(): UsePlayerResult {
}
playbackActiveUriRef.current = null;
setCurrentPlaybackItem(null);
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
@@ -174,6 +196,7 @@ export function usePlayer(): UsePlayerResult {
status,
queueLength,
currentSource,
currentPlaybackItem,
enqueue,
enqueueExclusive,
stop,

View File

@@ -0,0 +1,17 @@
/**
* 打断 TTS 后服务端仍可能推送迟到的 `tts_audio`;在恢复新一轮对话前丢弃这些片段。
* `interrupt` 在录音开始或点气泡停止时调用;`onUserMessageSent` 在用户发出下一条文本/语音成功后调用。
*/
export function createTtsPlaybackGate() {
let dropLateSegments = false;
return {
interrupt: () => {
dropLateSegments = true;
},
onUserMessageSent: () => {
dropLateSegments = false;
},
shouldAcceptIncomingTts: () => !dropLateSegments,
};
}

View File

@@ -31,7 +31,12 @@ export interface SegmentOutboxEntry {
export type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type PlaybackItemKind = 'tts_auto' | 'tts_repeat' | 'voice';
export interface PlaybackItem {
uri: string;
label?: string;
kind?: PlaybackItemKind;
/** 与 `flattenMessagesForList` 的 `listKey` 对齐,用于朗读中高亮与点气泡停止 */
messageRef?: { listKey: string };
}