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,
|
micWarning,
|
||||||
localStream,
|
localStream,
|
||||||
messages,
|
messages,
|
||||||
|
audioInputs,
|
||||||
|
selectedDeviceId,
|
||||||
|
setSelectedDeviceId,
|
||||||
sendText,
|
sendText,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -1942,6 +1945,33 @@ function DebugVoicePanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
disabled={!assistantId || status === "connecting"}
|
disabled={!assistantId || status === "connecting"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export function useVoicePreview(assistantId: string | null) {
|
|||||||
const [micWarning, setMicWarning] = 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 [messages, setMessages] = useState<ChatMessage[]>([]);
|
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 audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
@@ -83,6 +87,40 @@ export function useVoicePreview(assistantId: string | null) {
|
|||||||
const startingRef = useRef(false);
|
const startingRef = useRef(false);
|
||||||
const messageSeqRef = useRef(0);
|
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 releaseResources = useCallback(() => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
@@ -151,13 +189,20 @@ export function useVoicePreview(assistantId: string | null) {
|
|||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
if (window.isSecureContext && navigator.mediaDevices?.getUserMedia) {
|
if (window.isSecureContext && navigator.mediaDevices?.getUserMedia) {
|
||||||
try {
|
try {
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
};
|
||||||
|
// 指定了具体设备就强制用它,否则交给浏览器选默认麦克风
|
||||||
|
if (selectedDeviceIdRef.current) {
|
||||||
|
audioConstraints.deviceId = { exact: selectedDeviceIdRef.current };
|
||||||
|
}
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: audioConstraints,
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
// 授权后 label 才可见,刷新列表把真实设备名补上
|
||||||
|
void refreshDevices();
|
||||||
} catch (mediaError) {
|
} catch (mediaError) {
|
||||||
setMicWarning(microphoneErrorMessage(mediaError));
|
setMicWarning(microphoneErrorMessage(mediaError));
|
||||||
}
|
}
|
||||||
@@ -319,7 +364,7 @@ export function useVoicePreview(assistantId: string | null) {
|
|||||||
} finally {
|
} finally {
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [assistantId, fail]);
|
}, [assistantId, fail, refreshDevices]);
|
||||||
|
|
||||||
// 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。
|
// 发送文字消息:后端先打断当前播报,再按用户输入触发新回复。
|
||||||
// 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。
|
// 成功返回 true;通道未就绪(未开始对话/连接中)返回 false。
|
||||||
@@ -340,6 +385,9 @@ export function useVoicePreview(assistantId: string | null) {
|
|||||||
micWarning,
|
micWarning,
|
||||||
localStream,
|
localStream,
|
||||||
messages,
|
messages,
|
||||||
|
audioInputs,
|
||||||
|
selectedDeviceId,
|
||||||
|
setSelectedDeviceId,
|
||||||
sendText,
|
sendText,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
|||||||
Reference in New Issue
Block a user