feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components

This commit is contained in:
2026-05-13 03:37:32 +10:00
parent 5d4d518af8
commit 4fdedb87c8
8 changed files with 187 additions and 35 deletions

View File

@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micLevel, setMicLevel] = useState(0);
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micMonitoring, setMicMonitoring] = useState(false);
const micAnimFrameRef = useRef<number | null>(null);
const micMonitorContextRef = useRef<AudioContext | null>(null);
const micMonitorStreamRef = useRef<MediaStream | null>(null);
const micMonitorGainRef = useRef<GainNode | null>(null);
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
useEffect(() => {
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
}
}, [speakerTestState]);
const handleMicMonitoringToggle = useCallback(async () => {
if (micMonitoring) {
// Stop monitoring
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
micAnimFrameRef.current = null;
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
micMonitorStreamRef.current = null;
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
micMonitorContextRef.current = null;
}
micMonitorGainRef.current = null;
setMicLevel(0);
setMicMonitoring(false);
} else {
// Start monitoring
try {
const constraints: MediaStreamConstraints = {
audio: settings.microphoneId
? {
deviceId: { exact: settings.microphoneId },
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
}
: {
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const microphone = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain();
// Connect microphone to analyser for level monitoring
microphone.connect(analyser);
// Connect microphone to speakers for playback
microphone.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.gain.value = 1.0; // Full volume playback
micMonitorContextRef.current = audioContext;
micMonitorStreamRef.current = stream;
micMonitorGainRef.current = gainNode;
const tick = () => {
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
micAnimFrameRef.current = requestAnimationFrame(tick);
};
micAnimFrameRef.current = requestAnimationFrame(tick);
setMicMonitoring(true);
} catch (e) {
console.error('Microphone monitoring failed:', e);
}
}
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
// Cleanup monitoring on unmount or when settings change
useEffect(() => {
return () => {
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
}
};
}, []);
// Restart monitoring when mic settings change
useEffect(() => {
if (micMonitoring) {
handleMicMonitoringToggle(); // Stop
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
return (
<Page>
<PageHeader>
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
onSelect={handleMicrophoneSelect}
/>
<Box direction="Column" gap="100" alignItems="End">
<Button
size="300"
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button>
{micTestState === 'testing' && (
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} />
<Box gap="200" alignItems="Center">
<Button
size="300"
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
disabled={micTestState === 'testing' || micMonitoring}
>
<Text size="T300">
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button>
<Button
size="300"
variant={micMonitoring ? 'Primary' : 'Secondary'}
fill={micMonitoring ? 'Solid' : 'Soft'}
radii="300"
onClick={handleMicMonitoringToggle}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micMonitoring ? 'Stop' : 'Monitor'}
</Text>
</Button>
</Box>
{(micTestState === 'testing' || micMonitoring) && (
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
</Box>
)}
</Box>