diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx new file mode 100644 index 00000000..3bedaeb3 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from 'react'; +import { useVoiceStore } from '../../../stores/voiceStore'; +import { AudioManager } from '../../../audio/AudioManager'; +import { useAudioDevices } from '../../../hooks/useAudioDevices'; + +export function AudioInputSection() { + const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); + const setInputDevice = useVoiceStore((s) => s.setInputDevice); + const inputVolume = useVoiceStore((s) => s.inputVolume); + const setInputVolume = useVoiceStore((s) => s.setInputVolume); + const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); + + const [listOpen, setListOpen] = useState(false); + const [micLevel, setMicLevel] = useState(0); + const [activeUpstreamId, setActiveUpstreamId] = useState(null); + const dropdownRef = useRef(null); + const animFrameRef = useRef(0); + + // Click-outside-to-close. + useEffect(() => { + if (!listOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setListOpen(false); + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [listOpen]); + + // Live mic-level meter. Reuses AudioManager's analyser node, which is part + // of the canonical pipeline — no extra getUserMedia required if the user is + // already in voice OR the AudioContext is active. + useEffect(() => { + if (permState !== 'granted') return; + let stopped = false; + const am = AudioManager.getInstance(); + const ctx = am.getContext(); + if (!ctx) return; // nothing to measure until the user joins voice or hits Test + const analyser = am.getAnalyserNode(); + analyser.fftSize = 256; + const data = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + if (stopped) return; + analyser.getByteFrequencyData(data); + const avg = data.reduce((a, b) => a + b, 0) / data.length; + setMicLevel(Math.min(avg / 128, 1)); + animFrameRef.current = requestAnimationFrame(tick); + }; + tick(); + return () => { + stopped = true; + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + }; + }, [permState]); + + // Track the resolved upstream deviceId for the "System Default · X" hint. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const id = am.getCurrentInputDeviceId(); + setActiveUpstreamId(id === 'default' ? null : id); + }, [permState, inputDeviceId]); + + if (permState === 'unknown') { + return ( + +
Checking microphone access…
+
+ ); + } + + if (permState === 'denied') { + return ( + +
+
⚠ Microphone access denied
+
+ Grant microphone permission to choose an input device. +
+ +
+
+ ); + } + + if (permState === 'prompt') { + return ( + +
+
+ Microphone permission needed to list and choose an input device. +
+ +
+
+ ); + } + + // permState === 'granted' + const selectedLabel = inputDeviceId === 'default' + ? 'System Default' + : inputLabels.get(inputDeviceId) ?? 'System Default'; + const resolvedHint = inputDeviceId === 'default' && activeUpstreamId + ? inputLabels.get(activeUpstreamId) + : null; + + const handleSelect = (id: string) => { + setInputDevice(id); + AudioManager.getInstance().setInputDevice(id).catch(() => {}); + setListOpen(false); + }; + + const micBars = 20; + const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); + + return ( + +
+
+ + {listOpen && ( +
+ handleSelect('default')} /> + {inputs.filter(d => d.deviceId !== 'default').map((d) => ( + handleSelect(d.deviceId)} + /> + ))} +
+ )} +
+ {resolvedHint && ( +
Currently using: {resolvedHint}
+ )} + {inputs.length === 0 && ( +
No microphones detected.
+ )} + +
+
+
Input Volume
+
{inputVolume}%
+
+ setInputVolume(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, + }} + /> +
+ {Array.from({ length: micBars }).map((_, i) => ( +
+ ))} +
+
+ The level meter is live whenever an audio session is active. Join a voice channel to test mic input. +
+
+
+ + ); +} + +function SectionShell({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +interface DropdownItemProps { + label: string; + active: boolean; + onClick: () => void; +} + +function DropdownItem({ label, active, onClick }: DropdownItemProps) { + return ( + + ); +}