feat(conversation): TTS 投递与 WebSocket 管线;客户端播放门禁与会话页联动;COS 键与迁移脚本调整
This commit is contained in:
@@ -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,
|
||||
|
||||
17
app-expo/src/features/voice/tts-playback-gate.ts
Normal file
17
app-expo/src/features/voice/tts-playback-gate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user