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:
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user