fix frontend voice preview fallback
This commit is contained in:
@@ -32,4 +32,4 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
# 前端开发地址,允许跨域
|
# 前端开发地址,允许跨域
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|||||||
@@ -1790,9 +1790,15 @@ function DebugVoicePanel({
|
|||||||
vizStyle: VizStyle;
|
vizStyle: VizStyle;
|
||||||
assistantId: string | null;
|
assistantId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [micError, setMicError] = useState(false);
|
const {
|
||||||
const { status, error, localStream, connect, disconnect, audioRef } =
|
status,
|
||||||
useVoicePreview(assistantId, { onMicError: () => setMicError(true) });
|
error,
|
||||||
|
micWarning,
|
||||||
|
localStream,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
audioRef,
|
||||||
|
} = useVoicePreview(assistantId);
|
||||||
// 连接中或已连通都视作"会话进行中"
|
// 连接中或已连通都视作"会话进行中"
|
||||||
const recording = status === "connecting" || status === "connected";
|
const recording = status === "connecting" || status === "connected";
|
||||||
|
|
||||||
@@ -1827,15 +1833,10 @@ function DebugVoicePanel({
|
|||||||
|
|
||||||
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
||||||
{(() => {
|
{(() => {
|
||||||
const onVizError = () => {
|
|
||||||
setMicError(true);
|
|
||||||
disconnect();
|
|
||||||
};
|
|
||||||
const shared = {
|
const shared = {
|
||||||
active: Boolean(localStream),
|
active: Boolean(localStream),
|
||||||
stream: localStream,
|
stream: localStream,
|
||||||
className: "relative shrink-0",
|
className: "relative shrink-0",
|
||||||
onError: onVizError,
|
|
||||||
} as const;
|
} as const;
|
||||||
if (vizStyle === "aura")
|
if (vizStyle === "aura")
|
||||||
return <AuraVisualizer {...shared} size={200} />;
|
return <AuraVisualizer {...shared} size={200} />;
|
||||||
@@ -1852,17 +1853,19 @@ function DebugVoicePanel({
|
|||||||
{status === "connecting"
|
{status === "connecting"
|
||||||
? "连接中…"
|
? "连接中…"
|
||||||
: status === "connected"
|
: status === "connected"
|
||||||
? "我在聆听"
|
? micWarning
|
||||||
|
? "仅收听模式"
|
||||||
|
: "我在聆听"
|
||||||
: "开始一次语音对话"}
|
: "开始一次语音对话"}
|
||||||
</div>
|
</div>
|
||||||
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
||||||
{micError
|
{status === "failed"
|
||||||
? "无法访问麦克风,请检查浏览器权限后重试。"
|
|
||||||
: status === "failed"
|
|
||||||
? error ||
|
? error ||
|
||||||
"连接失败,请确认后端已启动且助手已保存后重试。"
|
"连接失败,请确认后端已启动且助手已保存后重试。"
|
||||||
: !assistantId
|
: !assistantId
|
||||||
? "请先保存助手,再开始语音预览。"
|
? "请先保存助手,再开始语音预览。"
|
||||||
|
: micWarning
|
||||||
|
? `${micWarning} 可接收助手播报,但无法发送语音。`
|
||||||
: recording
|
: recording
|
||||||
? "直接说话即可。助手会在您停顿后自然回应。"
|
? "直接说话即可。助手会在您停顿后自然回应。"
|
||||||
: "测试语音识别、响应速度与助手的播报效果。"}
|
: "测试语音识别、响应速度与助手的播报效果。"}
|
||||||
@@ -1872,7 +1875,6 @@ function DebugVoicePanel({
|
|||||||
<Button
|
<Button
|
||||||
disabled={!assistantId || status === "connecting"}
|
disabled={!assistantId || status === "connecting"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMicError(false);
|
|
||||||
if (recording) {
|
if (recording) {
|
||||||
disconnect();
|
disconnect();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -37,22 +37,30 @@ function generatePcId(): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseVoicePreviewOptions = {
|
|
||||||
/** 取麦克风失败(权限/无设备)时回调,供 UI 提示。 */
|
|
||||||
onMicError?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
if (error instanceof Error && error.message) return error.message;
|
if (error instanceof Error && error.message) return error.message;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVoicePreview(
|
function microphoneErrorMessage(error: unknown): string {
|
||||||
assistantId: string | null,
|
if (error instanceof DOMException) {
|
||||||
{ onMicError }: UseVoicePreviewOptions = {},
|
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 [status, setStatus] = useState<VoicePreviewStatus>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [micWarning, setMicWarning] = useState<string | null>(null);
|
||||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
@@ -90,6 +98,7 @@ export function useVoicePreview(
|
|||||||
releaseResources();
|
releaseResources();
|
||||||
setLocalStream(null);
|
setLocalStream(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMicWarning(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}, [releaseResources]);
|
}, [releaseResources]);
|
||||||
|
|
||||||
@@ -110,11 +119,33 @@ export function useVoicePreview(
|
|||||||
setStatus("failed");
|
setStatus("failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMicWarning(null);
|
||||||
setStatus("connecting");
|
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 pcId = generatePcId();
|
||||||
const ws = new WebSocket(`${wsBaseUrl()}/ws/voice`);
|
const ws = new WebSocket(`${wsBaseUrl()}/ws/voice`);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
@@ -205,24 +236,12 @@ export function useVoicePreview(
|
|||||||
else if (st === "disconnected") fail("WebRTC 音频连接已断开。");
|
else if (st === "disconnected") fail("WebRTC 音频连接已断开。");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) 取麦克风 → 加入连接
|
// 3) 有麦克风时双向音频;否则明确声明只接收后端音频。
|
||||||
let stream: MediaStream;
|
if (stream) {
|
||||||
try {
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
} else {
|
||||||
audio: {
|
pc.addTransceiver("audio", { direction: "recvonly" });
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (mediaError) {
|
|
||||||
onMicError?.();
|
|
||||||
fail(errorMessage(mediaError, "无法访问麦克风。"));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
localStreamRef.current = stream;
|
|
||||||
setLocalStream(stream);
|
|
||||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
|
||||||
|
|
||||||
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
|
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
@@ -247,10 +266,18 @@ export function useVoicePreview(
|
|||||||
} finally {
|
} finally {
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [assistantId, fail, onMicError]);
|
}, [assistantId, fail]);
|
||||||
|
|
||||||
// 卸载时收尾
|
// 卸载时收尾
|
||||||
useEffect(() => releaseResources, [releaseResources]);
|
useEffect(() => releaseResources, [releaseResources]);
|
||||||
|
|
||||||
return { status, error, localStream, connect, disconnect, audioRef };
|
return {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
micWarning,
|
||||||
|
localStream,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
audioRef,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user