diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 458bcb0a58..87968e3116 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -102,6 +102,10 @@ module.exports = [ AnalyserNode: 'readonly', MediaRecorder: 'readonly', MediaStream: 'readonly', + AudioWorkletNode: 'readonly', + DOMException: 'readonly', + MediaDeviceInfo: 'readonly', + MediaTrackConstraints: 'readonly', Blob: 'readonly', FormData: 'readonly', }, diff --git a/ui/desktop/src/components/settings/dictation/DictationSettings.tsx b/ui/desktop/src/components/settings/dictation/DictationSettings.tsx index 07a3a5e84e..d8a7775fd6 100644 --- a/ui/desktop/src/components/settings/dictation/DictationSettings.tsx +++ b/ui/desktop/src/components/settings/dictation/DictationSettings.tsx @@ -6,6 +6,7 @@ import { Input } from '../../ui/input'; import { Button } from '../../ui/button'; import { trackSettingToggled } from '../../../utils/analytics'; import { LocalModelManager } from './LocalModelManager'; +import { MicrophoneSelector } from './MicrophoneSelector'; import { DICTATION_ALLOWED_PROVIDERS } from '../../../updates'; import { DropdownMenu, @@ -20,6 +21,7 @@ export const DictationSettings = () => { const [providerStatuses, setProviderStatuses] = useState>( {} ); + const [preferredMic, setPreferredMic] = useState(null); const [apiKey, setApiKey] = useState(''); const [isEditingKey, setIsEditingKey] = useState(false); const { read, upsert, remove } = useConfig(); @@ -44,6 +46,10 @@ export const DictationSettings = () => { } setProvider(loadedProvider); + + const micValue = await read('voice_dictation_preferred_mic', false); + setPreferredMic((micValue as string) || null); + await refreshStatuses(); }; @@ -57,6 +63,11 @@ export const DictationSettings = () => { trackSettingToggled('voice_dictation', newProvider !== null); }; + const handleMicChange = (deviceId: string | null) => { + setPreferredMic(deviceId); + upsert('voice_dictation_preferred_mic', deviceId || '', false); + }; + const handleSaveKey = async () => { if (!provider) return; const providerConfig = providerStatuses[provider]; @@ -194,6 +205,11 @@ export const DictationSettings = () => { )} )} + + )} diff --git a/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx new file mode 100644 index 0000000000..6776c92333 --- /dev/null +++ b/ui/desktop/src/components/settings/dictation/MicrophoneSelector.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronDown, Mic } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '../../ui/dropdown-menu'; + +interface MicrophoneSelectorProps { + selectedDeviceId: string | null; + onDeviceChange: (deviceId: string | null) => void; +} + +const TEST_DURATION_MS = 5000; + +export const MicrophoneSelector = ({ selectedDeviceId, onDeviceChange }: MicrophoneSelectorProps) => { + const [devices, setDevices] = useState([]); + const [hasPermission, setHasPermission] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [vuLevel, setVuLevel] = useState(0); + + const testStreamRef = useRef(null); + const testCtxRef = useRef(null); + const rafRef = useRef(0); + const testTimerRef = useRef | null>(null); + + const enumerate = useCallback(async () => { + try { + const all = await navigator.mediaDevices.enumerateDevices(); + const inputs = all.filter((d) => d.kind === 'audioinput'); + setHasPermission(inputs.some((d) => d.label !== '')); + setDevices(inputs); + } catch (e) { + console.error('Failed to enumerate devices:', e); + } + }, []); + + useEffect(() => { + enumerate(); + navigator.mediaDevices.addEventListener('devicechange', enumerate); + return () => navigator.mediaDevices.removeEventListener('devicechange', enumerate); + }, [enumerate]); + + const requestPermission = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach((t) => t.stop()); + await enumerate(); + } catch (e) { + console.error('Microphone permission denied:', e); + } + }; + + const stopTest = useCallback(() => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + if (testTimerRef.current) clearTimeout(testTimerRef.current); + testTimerRef.current = null; + testCtxRef.current?.close(); + testCtxRef.current = null; + testStreamRef.current?.getTracks().forEach((t) => t.stop()); + testStreamRef.current = null; + setIsTesting(false); + setVuLevel(0); + }, []); + + const startTest = async () => { + stopTest(); + try { + const constraints: MediaTrackConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }; + if (selectedDeviceId) { + constraints.deviceId = { exact: selectedDeviceId }; + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints }); + testStreamRef.current = stream; + + const ctx = new AudioContext(); + testCtxRef.current = ctx; + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const poll = () => { + analyser.getByteTimeDomainData(dataArray); + let sum = 0; + for (let i = 0; i < dataArray.length; i++) { + const v = (dataArray[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / dataArray.length); + setVuLevel(Math.min(1, rms * 5)); + rafRef.current = requestAnimationFrame(poll); + }; + + setIsTesting(true); + rafRef.current = requestAnimationFrame(poll); + testTimerRef.current = setTimeout(stopTest, TEST_DURATION_MS); + } catch (e) { + console.error('Mic test failed:', e); + stopTest(); + } + }; + + useEffect(() => { + return () => stopTest(); + }, [stopTest]); + + const getDeviceLabel = (device: MediaDeviceInfo, index: number): string => { + return device.label || `Microphone ${index + 1}`; + }; + + const selectedLabel = (): string => { + if (!selectedDeviceId) return 'System Default'; + const device = devices.find((d) => d.deviceId === selectedDeviceId); + if (device) return device.label || 'Selected Microphone'; + return 'System Default'; + }; + + if (!hasPermission) { + return ( +
+
+

Microphone

+

+ Grant access to see available microphones +

+
+ +
+ ); + } + + return ( +
+
+
+

Microphone

+

+ Choose which microphone to use for dictation +

+
+
+ + + {selectedLabel()} + + + + onDeviceChange(v === 'system_default' ? null : v)} + > + + System Default + + {devices.map((device, i) => ( + + {getDeviceLabel(device, i)} + + ))} + + + + +
+
+ + {isTesting && ( +
+
+
+
+

+ Speak to test your microphone ({Math.ceil(TEST_DURATION_MS / 1000)}s) +

+
+ )} +
+ ); +}; diff --git a/ui/desktop/src/hooks/useAudioRecorder.ts b/ui/desktop/src/hooks/useAudioRecorder.ts index 5e53cac177..579b309986 100644 --- a/ui/desktop/src/hooks/useAudioRecorder.ts +++ b/ui/desktop/src/hooks/useAudioRecorder.ts @@ -202,9 +202,32 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO } try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, - }); + const preferredMic = await read('voice_dictation_preferred_mic', false); + + const audioConstraints: MediaTrackConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }; + if (preferredMic && typeof preferredMic === 'string') { + audioConstraints.deviceId = { exact: preferredMic }; + } + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }); + } catch (e) { + if ( + preferredMic && + e instanceof DOMException && + (e.name === 'NotFoundError' || e.name === 'OverconstrainedError') + ) { + delete audioConstraints.deviceId; + stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }); + } else { + throw e; + } + } streamRef.current = stream; const ctx = new AudioContext({ sampleRate: SAMPLE_RATE }); @@ -213,7 +236,6 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO await ctx.audioWorklet.addModule(WORKLET_URL); const source = ctx.createMediaStreamSource(stream); - // eslint-disable-next-line no-undef const worklet = new AudioWorkletNode(ctx, 'audio-capture'); worklet.port.onmessage = (e: MessageEvent) => handleSamples(e.data); @@ -230,7 +252,7 @@ export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderO stopRecording(); onError(errorMessage(error)); } - }, [isEnabled, onError, handleSamples, stopRecording]); + }, [isEnabled, onError, handleSamples, stopRecording, read]); useEffect(() => { return () => {