fix(frontend): clean up voice preview listeners
This commit is contained in:
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "ai-video-admin-frontend",
|
"name": "ai-video-admin-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@daily-co/daily-js": "^0.90.0",
|
||||||
"@pipecat-ai/client-js": "^1.12.0",
|
"@pipecat-ai/client-js": "^1.12.0",
|
||||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||||
"@xyflow/react": "^12.11.0",
|
"@xyflow/react": "^12.11.0",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@daily-co/daily-js": "^0.90.0",
|
||||||
"@pipecat-ai/client-js": "^1.12.0",
|
"@pipecat-ai/client-js": "^1.12.0",
|
||||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||||
"@xyflow/react": "^12.11.0",
|
"@xyflow/react": "^12.11.0",
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ export function WorkflowCanvas({
|
|||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [addSourceId, setAddSourceId] = useState<string | null>(null);
|
const [addSourceId, setAddSourceId] = useState<string | null>(null);
|
||||||
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
|
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
// Fast Refresh 会替换模块级对象,而保留 React Flow 实例。用 state 固定本次
|
||||||
|
// 挂载使用的映射,避免热更新后被误判为每次渲染都在创建新对象。
|
||||||
|
const [flowNodeTypes] = useState(nodeTypes);
|
||||||
|
const [flowEdgeTypes] = useState(edgeTypes);
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
|
|
||||||
// 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等
|
// 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等
|
||||||
@@ -569,8 +573,8 @@ export function WorkflowCanvas({
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={flowNodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={flowEdgeTypes}
|
||||||
onNodesChange={handleNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Daily from "@daily-co/daily-js";
|
||||||
import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js";
|
import type { PipecatClientOptions, RTVIMessage } from "@pipecat-ai/client-js";
|
||||||
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
|
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
|
||||||
|
|
||||||
@@ -101,6 +102,21 @@ function publicVariableSnapshot(
|
|||||||
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
|
class AppSmallWebRTCTransport extends SmallWebRTCTransport {
|
||||||
onAppMessage?: (message: AppMessage) => void;
|
onAppMessage?: (message: AppMessage) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmallWebRTCTransport 的媒体管理器会复用 Daily 的全局 call object,
|
||||||
|
* 但 disconnect() 不会移除构造时注册的设备和 track 监听器。销毁 call
|
||||||
|
* object 后,下一次连接会得到干净的实例,避免监听器跨会话累积。
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.onAppMessage = undefined;
|
||||||
|
try {
|
||||||
|
await this.disconnect();
|
||||||
|
} finally {
|
||||||
|
const call = Daily.getCallInstance();
|
||||||
|
if (call && !call.isDestroyed()) await call.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override handleMessage(raw: string): void {
|
override handleMessage(raw: string): void {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(raw) as AppMessage;
|
const message = JSON.parse(raw) as AppMessage;
|
||||||
@@ -262,6 +278,8 @@ export function useVoicePreview(
|
|||||||
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
|
const [selectedOutputDeviceId, setSelectedOutputDeviceId] = useState("");
|
||||||
|
|
||||||
const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
|
const transportRef = useRef<AppSmallWebRTCTransport | null>(null);
|
||||||
|
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const connectionGenerationRef = useRef(0);
|
||||||
const startingRef = useRef(false);
|
const startingRef = useRef(false);
|
||||||
const messageSeqRef = useRef(0);
|
const messageSeqRef = useRef(0);
|
||||||
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
|
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
|
||||||
@@ -321,9 +339,14 @@ export function useVoicePreview(
|
|||||||
}, [refreshDevices]);
|
}, [refreshDevices]);
|
||||||
|
|
||||||
const releaseResources = useCallback(() => {
|
const releaseResources = useCallback(() => {
|
||||||
|
connectionGenerationRef.current += 1;
|
||||||
const transport = transportRef.current;
|
const transport = transportRef.current;
|
||||||
transportRef.current = null;
|
transportRef.current = null;
|
||||||
transport?.disconnect().catch(() => {});
|
if (transport) {
|
||||||
|
cleanupPromiseRef.current = cleanupPromiseRef.current
|
||||||
|
.then(() => transport.dispose())
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
if (audioRef.current) audioRef.current.srcObject = null;
|
if (audioRef.current) audioRef.current.srcObject = null;
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
pendingAssistantTurnsRef.current.clear();
|
pendingAssistantTurnsRef.current.clear();
|
||||||
@@ -585,6 +608,8 @@ export function useVoicePreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
|
const generation = connectionGenerationRef.current + 1;
|
||||||
|
connectionGenerationRef.current = generation;
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
setError(null);
|
setError(null);
|
||||||
setMicWarning(null);
|
setMicWarning(null);
|
||||||
@@ -594,10 +619,15 @@ export function useVoicePreview(
|
|||||||
setCallEnded(false);
|
setCallEnded(false);
|
||||||
endedByServerRef.current = false;
|
endedByServerRef.current = false;
|
||||||
|
|
||||||
|
await cleanupPromiseRef.current;
|
||||||
|
if (connectionGenerationRef.current !== generation) return;
|
||||||
|
|
||||||
const iceServers = await webrtcApi
|
const iceServers = await webrtcApi
|
||||||
.iceServers()
|
.iceServers()
|
||||||
.then((response) => response.iceServers)
|
.then((response) => response.iceServers)
|
||||||
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
|
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
|
||||||
|
if (connectionGenerationRef.current !== generation) return;
|
||||||
|
|
||||||
const transport = new AppSmallWebRTCTransport({ iceServers });
|
const transport = new AppSmallWebRTCTransport({ iceServers });
|
||||||
transportRef.current = transport;
|
transportRef.current = transport;
|
||||||
transport.onAppMessage = handleAppMessage;
|
transport.onAppMessage = handleAppMessage;
|
||||||
@@ -648,9 +678,11 @@ export function useVoicePreview(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.initDevices();
|
await transport.initDevices();
|
||||||
|
if (connectionGenerationRef.current !== generation) return;
|
||||||
if (selectedDeviceIdRef.current) {
|
if (selectedDeviceIdRef.current) {
|
||||||
await transport.updateMic(selectedDeviceIdRef.current);
|
await transport.updateMic(selectedDeviceIdRef.current);
|
||||||
}
|
}
|
||||||
|
if (connectionGenerationRef.current !== generation) return;
|
||||||
const localAudio = transport.tracks().local.audio;
|
const localAudio = transport.tracks().local.audio;
|
||||||
const localVideo = transport.tracks().local.video;
|
const localVideo = transport.tracks().local.video;
|
||||||
setLocalStream(localAudio ? new MediaStream([localAudio]) : null);
|
setLocalStream(localAudio ? new MediaStream([localAudio]) : null);
|
||||||
@@ -678,13 +710,18 @@ export function useVoicePreview(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (connectionGenerationRef.current !== generation) return;
|
||||||
transport.sendAppMessage({ type: "client-ready" });
|
transport.sendAppMessage({ type: "client-ready" });
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
} catch (connectionError) {
|
} catch (connectionError) {
|
||||||
|
if (connectionGenerationRef.current === generation) {
|
||||||
fail(errorMessage(connectionError, "无法连接语音服务。"));
|
fail(errorMessage(connectionError, "无法连接语音服务。"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (connectionGenerationRef.current === generation) {
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
assistantId,
|
assistantId,
|
||||||
applyOutputDevice,
|
applyOutputDevice,
|
||||||
|
|||||||
Reference in New Issue
Block a user