fix frontend voice preview fallback

This commit is contained in:
Xin Wang
2026-06-10 12:36:18 +08:00
parent 4a948ee609
commit e94d98e947
3 changed files with 72 additions and 43 deletions

View File

@@ -37,22 +37,30 @@ function generatePcId(): string {
);
}
type UseVoicePreviewOptions = {
/** 取麦克风失败(权限/无设备)时回调,供 UI 提示。 */
onMicError?: () => void;
};
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
return fallback;
}
export function useVoicePreview(
assistantId: string | null,
{ onMicError }: UseVoicePreviewOptions = {},
) {
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, "无法访问麦克风。");
}
export function useVoicePreview(assistantId: string | null) {
const [status, setStatus] = useState<VoicePreviewStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [micWarning, setMicWarning] = useState<string | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
@@ -90,6 +98,7 @@ export function useVoicePreview(
releaseResources();
setLocalStream(null);
setError(null);
setMicWarning(null);
setStatus("idle");
}, [releaseResources]);
@@ -110,11 +119,33 @@ export function useVoicePreview(
setStatus("failed");
return;
}
startingRef.current = true;
setError(null);
setMicWarning(null);
setStatus("connecting");
// 麦克风是可选的:获取失败时继续建立仅接收后端音频的 WebRTC 会话。
let stream: MediaStream | null = null;
if (window.isSecureContext && navigator.mediaDevices?.getUserMedia) {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
} 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;
@@ -205,24 +236,12 @@ export function useVoicePreview(
else if (st === "disconnected") fail("WebRTC 音频连接已断开。");
};
// 3) 麦克风 → 加入连接
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
} catch (mediaError) {
onMicError?.();
fail(errorMessage(mediaError, "无法访问麦克风。"));
return;
// 3) 麦克风时双向音频;否则明确声明只接收后端音频。
if (stream) {
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
} else {
pc.addTransceiver("audio", { direction: "recvonly" });
}
localStreamRef.current = stream;
setLocalStream(stream);
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
const offer = await pc.createOffer();
@@ -247,10 +266,18 @@ export function useVoicePreview(
} finally {
startingRef.current = false;
}
}, [assistantId, fail, onMicError]);
}, [assistantId, fail]);
// 卸载时收尾
useEffect(() => releaseResources, [releaseResources]);
return { status, error, localStream, connect, disconnect, audioRef };
return {
status,
error,
micWarning,
localStream,
connect,
disconnect,
audioRef,
};
}