Implement microphone selection feature in voice preview

- Add audio input selection to DebugVoicePanel, allowing users to choose their microphone device.
- Update useVoicePreview hook to manage available audio inputs and selected device state.
- Enhance device enumeration and selection handling to ensure a seamless user experience during voice interactions.
This commit is contained in:
Xin Wang
2026-06-10 15:26:33 +08:00
parent 2c2af1f2cd
commit c69dec04e0
2 changed files with 84 additions and 6 deletions

View File

@@ -75,6 +75,10 @@ export function useVoicePreview(assistantId: string | null) {
const [micWarning, setMicWarning] = useState<string | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
// 可选麦克风列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const selectedDeviceIdRef = useRef("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -83,6 +87,40 @@ export function useVoicePreview(assistantId: string | null) {
const startingRef = useRef(false);
const messageSeqRef = useRef(0);
// 枚举可用麦克风。未授权前 label 为空,授权(连接)后再刷新即可拿到名称。
const refreshDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter((d) => d.kind === "audioinput");
setAudioInputs(inputs);
// 选中的设备已被拔出时,回退到浏览器默认设备
if (
selectedDeviceIdRef.current &&
!inputs.some((d) => d.deviceId === selectedDeviceIdRef.current)
) {
setSelectedDeviceId("");
}
} catch {
/* 忽略枚举失败 */
}
}, []);
// connect/refreshDevices 在回调里读最新选择,避免把它们挂进依赖反复重建
useEffect(() => {
selectedDeviceIdRef.current = selectedDeviceId;
}, [selectedDeviceId]);
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;
@@ -151,13 +189,20 @@ export function useVoicePreview(assistantId: string | null) {
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 };
}
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
audio: audioConstraints,
});
// 授权后 label 才可见,刷新列表把真实设备名补上
void refreshDevices();
} catch (mediaError) {
setMicWarning(microphoneErrorMessage(mediaError));
}
@@ -319,7 +364,7 @@ export function useVoicePreview(assistantId: string | null) {
} finally {
startingRef.current = false;
}
}, [assistantId, fail]);
}, [assistantId, fail, refreshDevices]);
// 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。
// 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。
@@ -340,6 +385,9 @@ export function useVoicePreview(assistantId: string | null) {
micWarning,
localStream,
messages,
audioInputs,
selectedDeviceId,
setSelectedDeviceId,
sendText,
connect,
disconnect,