Files
ai-video-fullstack/frontend/src/hooks/use-voice-preview.ts
T

847 lines
28 KiB
TypeScript
Raw Normal View History

"use client";
/**
* 语音预览:把麦克风接到后端 /ws/voice(WebRTC 信令),听到助手实时回应。
*
* 走原生 RTCPeerConnection + 一条 ws 信令通道,与后端 voice_webrtc.py 的约定对齐:
* client → {type:"offer", payload:{pc_id, sdp, type, assistant_id}}
* server → {type:"answer", payload:{pc_id, sdp, type}}
* client → {type:"ice-candidate", payload:{pc_id, candidate:{...}}}
* 音频本身走 WebRTC 媒体流(Opus),不经 ws;后端 TTS 帧从 ontrack 拿到直接播放。
*
* 另开一条 data channel 与后端管线(pipeline.py)互通应用消息:
* client → {type:"user-text", text} 文字输入(打断并触发新回复)
* server → {type:"transcript", role, content, timestamp} 用户/助手最终转写(聊天记录)
*
* 纯本机(localhost)即可跑:localhost 是 secure context,麦克风可用,ws 用明文。
* 局域网/别的设备要 https+wss,见 deploy/README.md。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { API_BASE, webrtcApi } from "@/lib/api";
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
export type NetworkQuality = "unknown" | "good" | "fair" | "poor";
const NETWORK_SAMPLE_INTERVAL_MS = 2_000;
const VIDEO_BITRATE_BY_QUALITY: Record<
Exclude<NetworkQuality, "unknown">,
number
> = {
good: 600_000,
fair: 500_000,
poor: 300_000,
};
const INITIAL_VIDEO_BITRATE = VIDEO_BITRATE_BY_QUALITY.fair;
type NetworkStatsSample = {
packetsSent: number;
packetsLost: number;
};
type VideoNetworkMetrics = {
packetsSent: number | null;
packetsLost: number | null;
roundTripTime: number | null;
availableOutgoingBitrate: number | null;
};
type ConnectOptions = {
visionEnabled?: boolean;
videoStream?: MediaStream | null;
};
export type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
/** 后端给的 ISO 时间戳 */
timestamp: string;
sequence: number;
turnId?: string;
streaming?: boolean;
};
// http→ws、https→wss,自动跟随 API 基址(同源反代时也对)
function wsBaseUrl(): string {
const url = new URL(API_BASE, window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString().replace(/\/$/, "");
}
function generatePcId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return (
"PC-" +
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
);
}
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function messageOrder(message: ChatMessage): number {
const timestamp = Date.parse(message.timestamp);
return Number.isNaN(timestamp) ? Number.MAX_SAFE_INTEGER : timestamp;
}
function sortMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.sort(
(a, b) => messageOrder(a) - messageOrder(b) || a.sequence - b.sequence,
);
}
2026-06-10 12:36:18 +08:00
function microphoneErrorMessage(error: unknown): string {
if (error instanceof DOMException) {
if (error.name === "NotAllowedError") {
return "麦克风权限被拒绝,请在浏览器网站设置中允许 localhost 使用麦克风。";
}
if (error.name === "NotFoundError") {
return "未检测到可用麦克风,请连接或启用麦克风后重试。";
}
if (error.name === "NotReadableError") {
return "麦克风正被其他应用占用,或系统未允许浏览器访问麦克风。";
}
}
return errorMessage(error, "无法访问麦克风。");
}
async function setVideoBitrate(
sender: RTCRtpSender,
bitrate: number,
): Promise<boolean> {
try {
const parameters = sender.getParameters();
if (!parameters.encodings.length) parameters.encodings = [{}];
parameters.encodings[0].maxBitrate = bitrate;
parameters.degradationPreference = "maintain-resolution";
await sender.setParameters(parameters);
return true;
} catch {
// 不支持发送参数的浏览器继续使用 WebRTC 自带的拥塞控制。
return false;
}
}
function readVideoNetworkMetrics(report: RTCStatsReport): VideoNetworkMetrics {
const metrics: VideoNetworkMetrics = {
packetsSent: null,
packetsLost: null,
roundTripTime: null,
availableOutgoingBitrate: null,
};
report.forEach((stat) => {
const values = stat as RTCStats & Record<string, unknown>;
const kind = values.kind ?? values.mediaType;
if (values.type === "outbound-rtp" && kind === "video") {
if (typeof values.packetsSent === "number") {
metrics.packetsSent = values.packetsSent;
}
} else if (values.type === "remote-inbound-rtp" && kind === "video") {
if (typeof values.packetsLost === "number") {
metrics.packetsLost = values.packetsLost;
}
if (typeof values.roundTripTime === "number") {
metrics.roundTripTime = values.roundTripTime;
}
} else if (
values.type === "candidate-pair" &&
values.state === "succeeded" &&
(values.nominated === true || values.selected === true)
) {
if (typeof values.currentRoundTripTime === "number") {
metrics.roundTripTime = values.currentRoundTripTime;
}
if (typeof values.availableOutgoingBitrate === "number") {
metrics.availableOutgoingBitrate = values.availableOutgoingBitrate;
}
}
});
return metrics;
}
function classifyNetworkQuality(
metrics: VideoNetworkMetrics,
previous: NetworkStatsSample | null,
): NetworkQuality {
const hasPacketSample =
previous !== null &&
metrics.packetsSent !== null &&
metrics.packetsLost !== null;
const sentDelta = hasPacketSample
? Math.max(0, metrics.packetsSent! - previous.packetsSent)
: 0;
const lostDelta = hasPacketSample
? Math.max(0, metrics.packetsLost! - previous.packetsLost)
: 0;
const lossRate = sentDelta > 0 ? lostDelta / sentDelta : null;
const { roundTripTime: rtt, availableOutgoingBitrate: bandwidth } = metrics;
if (lossRate === null && rtt === null && bandwidth === null) return "unknown";
if (
(lossRate !== null && lossRate >= 0.08) ||
(rtt !== null && rtt >= 0.45) ||
(bandwidth !== null && bandwidth < 350_000)
) {
return "poor";
}
if (
(lossRate !== null && lossRate >= 0.03) ||
(rtt !== null && rtt >= 0.25) ||
(bandwidth !== null && bandwidth < 550_000)
) {
return "fair";
}
return "good";
}
export function useVoicePreview(
assistantId: string | null,
onNodeActive?: (nodeId: string | null) => void,
) {
const [status, setStatus] = useState<VoicePreviewStatus>("idle");
const [error, setError] = useState<string | null>(null);
2026-06-10 12:36:18 +08:00
const [micWarning, setMicWarning] = useState<string | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
// 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [networkQuality, setNetworkQuality] =
useState<NetworkQuality>("unknown");
// 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [selectedOutputDeviceId, setSelectedOutputDeviceId] =
useState<string>("");
const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const startingRef = useRef(false);
const negotiationInProgressRef = useRef(false);
const messageSeqRef = useRef(0);
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
const videoBitrateRef = useRef(INITIAL_VIDEO_BITRATE);
// 后端主动结束(工作流走到结束节点)标记:据此把随后的断开当作正常结束而非报错
const endedByServerRef = useRef(false);
// 工作流激活节点回调存进 ref,避免把它挂进 connect 依赖反复重建连接
const onNodeActiveRef = useRef(onNodeActive);
useEffect(() => {
onNodeActiveRef.current = onNodeActive;
}, [onNodeActive]);
const supportsOutputSelection =
typeof HTMLAudioElement !== "undefined" &&
"setSinkId" in HTMLAudioElement.prototype;
const applyOutputDevice = useCallback(async (deviceId: string) => {
const audio = audioRef.current;
if (!audio || !supportsOutputSelection) return;
try {
await audio.setSinkId(deviceId);
} catch {
/* 忽略切换失败(设备不可用或不支持) */
}
}, [supportsOutputSelection]);
// 枚举可用麦克风与扬声器。未授权前 label 为空,授权(连接)后再刷新即可拿到名称。
const refreshDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
// 未授权时设备可能没有 deviceId(空串),无法选择,直接过滤掉
const inputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId);
const outputs = devices.filter(
(d) => d.kind === "audiooutput" && d.deviceId,
);
setAudioInputs(inputs);
setAudioOutputs(outputs);
// 选中的设备已被拔出时,回退到浏览器默认设备
if (
selectedDeviceIdRef.current &&
!inputs.some((d) => d.deviceId === selectedDeviceIdRef.current)
) {
setSelectedDeviceId("");
}
if (
selectedOutputDeviceIdRef.current &&
!outputs.some((d) => d.deviceId === selectedOutputDeviceIdRef.current)
) {
setSelectedOutputDeviceId("");
void applyOutputDevice("");
}
} catch {
/* 忽略枚举失败 */
}
}, [applyOutputDevice]);
// connect/refreshDevices 在回调里读最新选择,避免把它们挂进依赖反复重建
useEffect(() => {
selectedDeviceIdRef.current = selectedDeviceId;
}, [selectedDeviceId]);
useEffect(() => {
selectedOutputDeviceIdRef.current = selectedOutputDeviceId;
}, [selectedOutputDeviceId]);
useEffect(() => {
void applyOutputDevice(selectedOutputDeviceId);
}, [applyOutputDevice, selectedOutputDeviceId, remoteStream]);
useEffect(() => {
if (!navigator.mediaDevices?.enumerateDevices) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
void refreshDevices();
navigator.mediaDevices.addEventListener("devicechange", refreshDevices);
return () => {
navigator.mediaDevices.removeEventListener("devicechange", refreshDevices);
};
}, [refreshDevices]);
const releaseResources = useCallback(() => {
const ws = wsRef.current;
wsRef.current = null;
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
}
const channel = dataChannelRef.current;
dataChannelRef.current = null;
if (channel) {
channel.onopen = null;
channel.onmessage = null;
channel.close();
}
const pc = pcRef.current;
pcRef.current = null;
if (pc) {
pc.onconnectionstatechange = null;
pc.onicecandidate = null;
pc.oniceconnectionstatechange = null;
pc.ontrack = null;
pc.close();
}
localStreamRef.current?.getTracks().forEach((track) => track.stop());
localStreamRef.current = null;
if (audioRef.current) audioRef.current.srcObject = null;
startingRef.current = false;
negotiationInProgressRef.current = false;
onNodeActiveRef.current?.(null);
}, []);
const disconnect = useCallback(() => {
releaseResources();
setLocalStream(null);
setRemoteStream(null);
setMessages([]);
messageSeqRef.current = 0;
setError(null);
2026-06-10 12:36:18 +08:00
setMicWarning(null);
setNetworkQuality("unknown");
networkStatsRef.current = null;
videoBitrateRef.current = INITIAL_VIDEO_BITRATE;
setStatus("idle");
}, [releaseResources]);
const fail = useCallback(
(message: string) => {
releaseResources();
setLocalStream(null);
setRemoteStream(null);
setError(message);
setNetworkQuality("unknown");
networkStatsRef.current = null;
videoBitrateRef.current = INITIAL_VIDEO_BITRATE;
setStatus("failed");
},
[releaseResources],
);
// 连接断开时:若是后端主动收尾(call-ended),按正常结束处理(不报错);
// 否则按异常失败处理。
const closeOnRemoteEnd = useCallback(
(message: string) => {
if (endedByServerRef.current) {
disconnect();
} else {
fail(message);
}
},
[disconnect, fail],
);
const connect = useCallback(async (options: ConnectOptions = {}) => {
if (startingRef.current || pcRef.current || wsRef.current) return;
if (!assistantId) {
setError("请先保存助手,再开始语音预览。");
setStatus("failed");
return;
}
startingRef.current = true;
setError(null);
2026-06-10 12:36:18 +08:00
setMicWarning(null);
setMessages([]); // 新会话清空上一轮聊天记录
endedByServerRef.current = false;
setStatus("connecting");
2026-06-10 12:36:18 +08:00
// 麦克风是可选的:获取失败时继续建立仅接收后端音频的 WebRTC 会话。
let stream: MediaStream | null = null;
if (window.isSecureContext && navigator.mediaDevices?.getUserMedia) {
try {
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
// 指定了具体设备就强制用它,否则交给浏览器选默认麦克风
if (selectedDeviceIdRef.current) {
audioConstraints.deviceId = { exact: selectedDeviceIdRef.current };
}
2026-06-10 12:36:18 +08:00
stream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
2026-06-10 12:36:18 +08:00
});
// 授权后 label 才可见,刷新列表把真实设备名补上
void refreshDevices();
2026-06-10 12:36:18 +08:00
} catch (mediaError) {
setMicWarning(microphoneErrorMessage(mediaError));
}
} else {
setMicWarning("当前页面无法访问麦克风,已进入仅收听模式。");
}
if (stream) {
localStreamRef.current = stream;
setLocalStream(stream);
}
const pcId = generatePcId();
const ws = new WebSocket(`${wsBaseUrl()}/ws/voice`);
wsRef.current = ws;
ws.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "answer") {
const currentPc = pcRef.current;
if (!currentPc) return;
try {
await currentPc.setRemoteDescription({
type: "answer",
sdp: msg.payload.sdp,
});
} finally {
if (pcRef.current === currentPc) {
negotiationInProgressRef.current = false;
}
}
} else if (msg.type === "ice-candidate" && msg.payload?.candidate) {
// 后端当前不主动 trickle,留兼容
try {
await pcRef.current?.addIceCandidate(msg.payload.candidate);
} catch {
/* 忽略迟到/重复 candidate */
}
} else if (msg.type === "error") {
fail(msg.payload?.message || "后端无法启动语音会话。");
}
} catch {
/* 非 JSON / 未知消息,忽略 */
}
};
try {
// 1) 等 ws 连上
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = (e) => reject(e);
ws.onclose = () => reject(new Error("语音信令连接已关闭。"));
});
// 连上后,信令异常或关闭都结束当前会话并保留失败状态。
ws.onerror = () => {
if (wsRef.current === ws) fail("语音信令连接失败。");
};
ws.onclose = () => {
if (wsRef.current === ws) closeOnRemoteEnd("语音信令连接已断开。");
};
// 2) 建 PeerConnection(STUN;公网跨网时后端会下发 TURN)
const iceServers = await webrtcApi
.iceServers()
.then((r) => r.iceServers)
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
const pc = new RTCPeerConnection({ iceServers });
pcRef.current = pc;
const sendOffer = async () => {
if (
pcRef.current !== pc ||
ws.readyState !== WebSocket.OPEN ||
pc.signalingState !== "stable" ||
negotiationInProgressRef.current
) {
return;
}
negotiationInProgressRef.current = true;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const localDescription = pc.localDescription;
if (!localDescription?.sdp) {
throw new Error("浏览器无法创建 WebRTC offer。");
}
ws.send(
JSON.stringify({
type: "offer",
payload: {
pc_id: pcId,
sdp: localDescription.sdp,
type: localDescription.type,
assistant_id: assistantId,
vision_enabled: Boolean(options.visionEnabled),
},
}),
);
} catch (offerError) {
negotiationInProgressRef.current = false;
throw offerError;
}
};
pc.onicecandidate = (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: "ice-candidate",
payload: {
pc_id: pcId,
candidate: e.candidate
? {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
}
: null,
},
}),
);
};
// 应用消息通道:收后端转写(聊天记录),发文字输入。
const channel = pc.createDataChannel("chat");
dataChannelRef.current = channel;
channel.onopen = () => {
channel.send(JSON.stringify({ type: "client-ready" }));
};
channel.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (
msg?.type === "signalling" &&
msg?.message?.type === "renegotiate"
) {
await sendOffer();
} else if (
msg?.type === "assistant-text-start" &&
typeof msg.turn_id === "string"
) {
messageSeqRef.current += 1;
const next: ChatMessage = {
id: `assistant-${msg.turn_id}`,
role: "assistant",
content: "",
timestamp:
typeof msg.timestamp === "string"
? msg.timestamp
: new Date().toISOString(),
sequence: messageSeqRef.current,
turnId: msg.turn_id,
streaming: true,
};
setMessages((prev) => sortMessages([...prev, next]));
} else if (
msg?.type === "assistant-text-delta" &&
typeof msg.turn_id === "string" &&
typeof msg.delta === "string"
) {
setMessages((prev) =>
prev.map((message) =>
message.turnId === msg.turn_id
? { ...message, content: message.content + msg.delta }
: message,
),
);
} else if (
msg?.type === "assistant-text-end" &&
typeof msg.turn_id === "string"
) {
setMessages((prev) =>
prev.map((message) =>
message.turnId === msg.turn_id
? {
...message,
content:
typeof msg.content === "string"
? msg.content
: message.content,
streaming: false,
}
: message,
),
);
} else if (
msg?.type === "transcript" &&
(msg.role === "user" || msg.role === "assistant") &&
typeof msg.content === "string" &&
msg.content.trim()
) {
messageSeqRef.current += 1;
const next: ChatMessage = {
id: `msg-${messageSeqRef.current}`,
role: msg.role,
content: msg.content,
timestamp:
typeof msg.timestamp === "string"
? msg.timestamp
: new Date().toISOString(),
sequence: messageSeqRef.current,
};
setMessages((prev) => sortMessages([...prev, next]));
} else if (
msg?.type === "node-active" &&
typeof msg.nodeId === "string"
) {
// 工作流:后端报告当前激活节点,交给画布高亮
onNodeActiveRef.current?.(msg.nodeId);
} else if (msg?.type === "call-ended") {
// 后端走到结束节点、正常收尾:随后的断开按正常结束处理,不报错
endedByServerRef.current = true;
onNodeActiveRef.current?.(null);
}
} catch {
/* 非 JSON / 未知消息,忽略 */
}
};
pc.ontrack = (e) => {
if (e.track.kind !== "audio") return;
const remote = e.streams[0] ?? new MediaStream([e.track]);
setRemoteStream(remote);
if (audioRef.current) {
audioRef.current.srcObject = remote;
void applyOutputDevice(selectedOutputDeviceIdRef.current).then(() =>
audioRef.current?.play().catch(() => {}),
);
}
};
pc.onconnectionstatechange = () => {
if (pcRef.current !== pc) return;
if (pc.connectionState === "connected") setStatus("connected");
else if (pc.connectionState === "failed")
closeOnRemoteEnd("WebRTC 音频连接失败。");
else if (pc.connectionState === "closed")
closeOnRemoteEnd("WebRTC 音频连接已断开。");
};
pc.oniceconnectionstatechange = () => {
if (pcRef.current !== pc) return;
const st = pc.iceConnectionState;
if (st === "connected" || st === "completed") setStatus("connected");
else if (st === "failed") closeOnRemoteEnd("WebRTC 音频连接失败。");
else if (st === "disconnected")
closeOnRemoteEnd("WebRTC 音频连接已断开。");
};
2026-06-10 12:36:18 +08:00
// 3) 有麦克风时双向音频;否则明确声明只接收后端音频。
if (stream) {
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
} else {
pc.addTransceiver("audio", { direction: "recvonly" });
}
if (options.videoStream) {
for (const track of options.videoStream.getVideoTracks()) {
const sender = pc.addTrack(track, options.videoStream);
await setVideoBitrate(sender, INITIAL_VIDEO_BITRATE);
}
}
// 4) 生成 offer 并发给后端;同一方法也响应 SmallWebRTC 的重新协商请求。
await sendOffer();
} catch (connectionError) {
fail(errorMessage(connectionError, "无法连接语音服务。"));
} finally {
startingRef.current = false;
}
}, [assistantId, applyOutputDevice, fail, closeOnRemoteEnd, refreshDevices]);
useEffect(() => {
if (status !== "connected") return;
const pc = pcRef.current;
if (!pc) return;
let cancelled = false;
const sampleNetwork = async () => {
try {
const metrics = readVideoNetworkMetrics(await pc.getStats());
if (cancelled || pcRef.current !== pc) return;
const quality = classifyNetworkQuality(metrics, networkStatsRef.current);
if (metrics.packetsSent !== null && metrics.packetsLost !== null) {
networkStatsRef.current = {
packetsSent: metrics.packetsSent,
packetsLost: metrics.packetsLost,
};
}
setNetworkQuality(quality);
if (quality === "unknown") return;
const bitrate = VIDEO_BITRATE_BY_QUALITY[quality];
if (bitrate === videoBitrateRef.current) return;
const sender = pc
.getSenders()
.find((item) => item.track?.kind === "video");
if (!sender) return;
if (await setVideoBitrate(sender, bitrate)) {
videoBitrateRef.current = bitrate;
}
} catch {
// 个别浏览器不提供完整 WebRTC 统计信息时,保留底层拥塞控制。
}
};
void sampleNetwork();
const interval = window.setInterval(
() => void sampleNetwork(),
NETWORK_SAMPLE_INTERVAL_MS,
);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [status]);
const replaceVideoStream = useCallback(
async (videoStream: MediaStream | null) => {
const pc = pcRef.current;
if (!pc) return;
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
if (!sender) return;
await sender.replaceTrack(videoStream?.getVideoTracks()[0] ?? null);
},
[],
);
// 选择麦克风:更新选择;若会话正在发送麦克风音频,则用 WebRTC replaceTrack
// 热切换轨道(无需重新协商),并把波形可视化重新接到新流。
// 未连接时仅记下选择,留待下次 connect 生效。
const selectDevice = useCallback(
async (deviceId: string) => {
setSelectedDeviceId(deviceId);
selectedDeviceIdRef.current = deviceId;
const pc = pcRef.current;
if (!pc) return;
// 只有本就在发送麦克风音频(存在 audio sender 轨道)时才热切换;
// 仅收听模式下加麦克风需重新协商,这里不处理,留到下次连接。
const sender = pc
.getSenders()
.find((s) => s.track?.kind === "audio");
if (!sender) return;
try {
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) audioConstraints.deviceId = { exact: deviceId };
const newStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
});
// 切换期间可能已断开,丢弃刚拿到的流
if (pcRef.current !== pc) {
newStream.getTracks().forEach((t) => t.stop());
return;
}
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) {
newStream.getTracks().forEach((t) => t.stop());
return;
}
await sender.replaceTrack(newTrack);
// 旧轨道停掉,新流替换(波形/分析器随 localStream 变化自动重连)
localStreamRef.current?.getTracks().forEach((t) => t.stop());
localStreamRef.current = newStream;
setLocalStream(newStream);
setMicWarning(null);
} catch (mediaError) {
setMicWarning(microphoneErrorMessage(mediaError));
}
},
[],
);
const selectOutputDevice = useCallback(
(deviceId: string) => {
setSelectedOutputDeviceId(deviceId);
selectedOutputDeviceIdRef.current = deviceId;
void applyOutputDevice(deviceId);
},
[applyOutputDevice],
);
// 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。
// 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。
const sendText = useCallback((text: string): boolean => {
const trimmed = text.trim();
const channel = dataChannelRef.current;
if (!trimmed || !channel || channel.readyState !== "open") return false;
channel.send(JSON.stringify({ type: "user-text", text: trimmed }));
return true;
}, []);
// 卸载时收尾
useEffect(() => releaseResources, [releaseResources]);
2026-06-10 12:36:18 +08:00
return {
status,
error,
micWarning,
localStream,
remoteStream,
messages,
networkQuality,
audioInputs,
audioOutputs,
selectedDeviceId,
selectedOutputDeviceId,
setSelectedDeviceId,
selectDevice,
selectOutputDevice,
supportsOutputSelection,
sendText,
2026-06-10 12:36:18 +08:00
connect,
replaceVideoStream,
2026-06-10 12:36:18 +08:00
disconnect,
audioRef,
};
}
export type VoicePreview = ReturnType<typeof useVoicePreview>;