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

@@ -1857,6 +1857,9 @@ function DebugVoicePanel({
micWarning,
localStream,
messages,
audioInputs,
selectedDeviceId,
setSelectedDeviceId,
sendText,
connect,
disconnect,
@@ -1942,6 +1945,33 @@ function DebugVoicePanel({
</p>
</div>
<div className="relative w-full max-w-xs">
<Select
value={selectedDeviceId || "default"}
onValueChange={(value) =>
setSelectedDeviceId(value === "default" ? "" : value)
}
disabled={recording}
>
<SelectTrigger
size="sm"
className="w-full justify-center gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
aria-label="选择麦克风"
>
<Mic size={13} className="shrink-0 text-muted-soft" />
<SelectValue placeholder="默认麦克风" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
{audioInputs.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `麦克风 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
disabled={!assistantId || status === "connecting"}
onClick={() => {

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,