Files
CamTalk/frontend/src/hooks/useVisionSession.ts
cfy666 24a6ce5295 fix: 修复新旧 TTS 语音重叠播放的问题
- TTSPlayer 新增 stopCurrentAudio() 方法,play() 开始前先停止旧音频
- onSpeechEnd 发送新 query 前停止上一轮 TTS 播放
2026-06-14 12:01:34 +08:00

392 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// useVisionSession — 核心视觉对话会话 Hook
// 职责封装一次完整的视觉对话会话摄像头、VAD、WebSocket、消息状态
// 来源docs/02-系统架构.md 核心 Hook 设计
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { wsClient } from "../lib/websocket";
import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio";
import { getErrorMessage } from "../lib/errors";
import { TTSPlayer } from "../lib/ttsPlayer";
import { showToast } from "../lib/toast";
import { loadConfig, saveConfig } from "../lib/storage";
import { useCamera } from "../components/CameraManager";
import { useMicrophone } from "../components/MicManager";
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
import { useWebSocketManager } from "../components/WebSocketManager";
import { useObservationMode } from "./useObservationMode";
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
export type SessionMode = "dialogue" | "observation";
const MAX_HISTORY_ROUNDS = 10;
export interface SessionStats {
queryCount: number;
totalTokens: number;
}
export function useVisionSession() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentReply, setCurrentReply] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
const [config, setConfig] = useState<SessionConfig>(loadConfig);
const [stats, setStats] = useState<SessionStats>({ queryCount: 0, totalTokens: 0 });
const [mode, setMode] = useState<SessionMode>("dialogue");
const [isCameraOn, setIsCameraOn] = useState(false);
const [isMicOn, setIsMicOn] = useState(false);
// 上一帧采样数据(用于关键帧检测)
const prevFrameRef = useRef<Uint8ClampedArray | null>(null);
// 对话历史role + content用于多轮上下文
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
// TTS 播放器
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
const getTTSPlayer = useCallback(() => {
if (!ttsPlayerRef.current) {
const player = new TTSPlayer();
player.onEnd(() => setIsAudioPlaying(false));
ttsPlayerRef.current = player;
}
return ttsPlayerRef.current;
}, []);
const { videoRef, captureFrame, startCamera, stopCamera, stream } = useCamera();
const { startMic, stopMic } = useMicrophone();
const { status, connect, disconnect, send } = useWebSocketManager();
// 用 ref 跟踪 isProcessing避免 VAD 回调闭包问题
const isProcessingRef = useRef(false);
useEffect(() => {
isProcessingRef.current = isProcessing;
}, [isProcessing]);
// 观察模式:画面变化时自动发送 query
const { isObserving, startObserving, stopObserving } = useObservationMode({
onChange: useCallback(
(frameDataUrl: string) => {
if (isProcessingRef.current) return;
const requestId = uuidv4();
send({
type: "query",
request_id: requestId,
image: dataUrlToBase64(frameDataUrl),
audio: "", // 观察模式无音频
});
setMessages((prev) => [
...prev,
{
role: "user",
content: "👁️ 画面变化检测",
timestamp: Date.now(),
},
]);
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
setIsProcessing(true);
},
[send],
),
});
/** 切换对话/观察模式 */
const toggleMode = useCallback(() => {
setMode((prev) => {
const next = prev === "dialogue" ? "observation" : "dialogue";
if (next === "observation") {
startObserving(videoRef.current, captureFrame);
} else {
stopObserving();
}
return next;
});
}, [videoRef, captureFrame, startObserving, stopObserving]);
// WebSocket 连接成功后发送 config
useEffect(() => {
if (status === "connected") {
send({
type: "config",
payload: {
tts_enabled: config.ttsEnabled,
detail_level: config.detailLevel,
language: config.language,
},
});
}
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
/** 更新会话配置 */
const updateConfig = useCallback((partial: Partial<SessionConfig>) => {
setConfig((prev) => {
const next = { ...prev, ...partial };
saveConfig(next);
// 如果已连接,立即发送更新
if (status === "connected") {
send({
type: "config",
payload: {
tts_enabled: next.ttsEnabled,
detail_level: next.detailLevel,
language: next.language,
},
});
}
return next;
});
}, [status, send]);
// VAD语音结束时自动发送 query
const {
isSpeaking,
isReady: isVADReady,
error: vadError,
start: startVAD,
stop: stopVAD,
} = useVAD({
onSpeechEnd: useCallback(
(audio: Float32Array) => {
// 处理中忽略,防止重复发送
if (isProcessingRef.current) {
console.warn("[Session] 正在处理中,忽略语音输入");
return;
}
// 停止上一轮的 TTS 播放,防止新旧音频重叠
ttsPlayerRef.current?.stop();
setIsAudioPlaying(false);
const frame = captureFrame();
if (!frame) {
console.warn("[Session] 无法捕获图像帧");
return;
}
// 关键帧检测:与上一帧对比,相似度过高则跳过
const video = videoRef.current;
if (video) {
const currentSample = sampleFrame(video);
if (currentSample && prevFrameRef.current) {
const { similarity } = compareFrames(prevFrameRef.current, currentSample);
if (similarity > 0.9) {
console.log(`[Session] 画面无变化 (similarity=${similarity.toFixed(2)}),跳过`);
prevFrameRef.current = currentSample;
return;
}
}
if (currentSample) {
prevFrameRef.current = currentSample;
}
}
const requestId = uuidv4();
send({
type: "query",
request_id: requestId,
image: dataUrlToBase64(frame),
audio: encodeAudioToBase64(audio),
});
// 更新请求统计
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
// 添加用户消息STT 流式结果会逐步更新文本)
setMessages((prev) => [
...prev,
{ role: "user", content: "(语音识别中...", timestamp: Date.now() },
]);
setIsProcessing(true);
},
[captureFrame, send, videoRef]
),
});
// 处理服务端消息
useEffect(() => {
const unsub = wsClient.onMessage((msg: ServerMessage) => {
switch (msg.type) {
case "stt_result": {
// 流式更新用户消息文本(包括中间结果和最终结果)
setMessages((prev) => {
const updated = [...prev];
const lastUserIdx = updated.findLastIndex((m) => m.role === "user");
if (lastUserIdx >= 0) {
updated[lastUserIdx] = {
...updated[lastUserIdx],
content: msg.text || "(未识别到语音)",
};
}
return updated;
});
break;
}
case "llm_chunk":
setCurrentReply((prev) => prev + msg.delta);
break;
case "llm_done": {
const done = msg as LLMDoneMessage;
// 记录到对话历史
historyRef.current.push({ role: "assistant", content: done.full_text });
// 裁剪历史到最近 N 轮
if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) {
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
}
// 累计 token 统计
if (done.tokens_used?.total) {
setStats((prev) => ({
...prev,
totalTokens: prev.totalTokens + done.tokens_used.total,
}));
}
setMessages((prev) => [
...prev,
{
role: "assistant",
content: done.full_text,
timestamp: Date.now(),
tokensUsed: done.tokens_used?.total,
latencyMs: done.latency_ms,
model: done.model,
},
]);
setCurrentReply("");
setIsProcessing(false);
break;
}
case "tts_audio":
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
if (!msg.is_last) {
setIsAudioPlaying(true);
}
break;
case "error":
console.error("[Session] 服务端错误:", msg.code, msg.message);
showToast(getErrorMessage(msg.code), "error");
setIsProcessing(false);
break;
}
});
return unsub;
}, [getTTSPlayer]);
/** 启动会话 */
const startSession = useCallback(async () => {
// 1. 获取摄像头和麦克风
await startCamera();
setIsCameraOn(true);
const micStream = await startMic();
if (!micStream) {
showToast("无法获取麦克风权限", "error");
return;
}
setIsMicOn(true);
// 2. 连接 WebSocket
connect();
// 3. 启动 VAD传入麦克风 stream
await startVAD(micStream);
}, [startCamera, startMic, connect, startVAD]);
/** 结束会话 */
const stopSession = useCallback(async () => {
stopObserving();
setMode("dialogue");
await stopVAD();
stopMic();
stopCamera();
disconnect();
// 停止 TTS 并清理状态
ttsPlayerRef.current?.stop();
setIsAudioPlaying(false);
setMessages([]);
setCurrentReply("");
setIsProcessing(false);
setStats({ queryCount: 0, totalTokens: 0 });
historyRef.current = [];
prevFrameRef.current = null;
setIsCameraOn(false);
setIsMicOn(false);
}, [stopObserving, stopVAD, stopMic, stopCamera, disconnect]);
/** 摄像头开关 */
const toggleCamera = useCallback(async () => {
if (isCameraOn) {
stopCamera();
setIsCameraOn(false);
} else {
await startCamera();
setIsCameraOn(true);
}
}, [isCameraOn, startCamera, stopCamera]);
/** 麦克风开关 */
const toggleMic = useCallback(async () => {
if (isMicOn) {
stopMic();
setIsMicOn(false);
} else {
const micStream = await startMic();
setIsMicOn(!!micStream);
}
}, [isMicOn, startMic, stopMic]);
/** 打断当前回复 */
const interrupt = useCallback(() => {
send({ type: "interrupt" });
// 停止 TTS 播放
ttsPlayerRef.current?.stop();
setIsAudioPlaying(false);
// 将未完成的流式内容保存为最终消息
if (currentReply) {
const interrupted = currentReply + "(已打断)";
historyRef.current.push({ role: "assistant", content: interrupted });
setMessages((prev) => [
...prev,
{ role: "assistant", content: interrupted, timestamp: Date.now() },
]);
}
setCurrentReply("");
setIsProcessing(false);
}, [send, currentReply]);
return {
messages,
currentReply,
isProcessing,
isAudioPlaying,
isSpeaking,
isVADReady,
vadError,
connectionStatus: status,
videoRef,
stream,
config,
updateConfig,
stats,
mode,
isObserving,
toggleMode,
startSession,
stopSession,
interrupt,
isCameraOn,
isMicOn,
toggleCamera,
toggleMic,
};
}