diff --git a/src/app/components/VoiceMessageRecorder.tsx b/src/app/components/VoiceMessageRecorder.tsx index 7f0e00240..06ca5f74f 100644 --- a/src/app/components/VoiceMessageRecorder.tsx +++ b/src/app/components/VoiceMessageRecorder.tsx @@ -41,6 +41,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const [previewUrl, setPreviewUrl] = useState(null); const mediaRecorderRef = useRef(null); + const streamRef = useRef(null); const chunksRef = useRef([]); const analyserRef = useRef(null); const audioCtxRef = useRef(null); @@ -62,6 +63,9 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const startMeters = useCallback(() => { const analyser = analyserRef.current; if (!analyser) return; + // Guard against ever running two loops. + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + if (timerRef.current) clearInterval(timerRef.current); const buf = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (!analyserRef.current) return; @@ -87,6 +91,14 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { } }, []); + // Release the microphone. The mic tracks are independent of the MediaRecorder + // and the AudioContext — neither mr.stop() nor audioCtx.close() releases them — + // so they must be stopped explicitly (else the OS mic indicator stays on). + const stopStream = useCallback(() => { + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + }, []); + const stopAll = useCallback(() => { stopMeters(); if (audioCtxRef.current) { @@ -98,6 +110,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { useEffect( () => () => { + // Unmounting mid-recording/pause must release the mic — stopAll() only + // closes the AudioContext and cancels the meters, not the mic tracks. + const mr = mediaRecorderRef.current; + if (mr && (mr.state === 'recording' || mr.state === 'paused')) { + mr.ondataavailable = null; + mr.onstop = null; + mr.stop(); + } + stopStream(); stopAll(); if (previewUrl) URL.revokeObjectURL(previewUrl); }, @@ -115,6 +136,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const mr = new MediaRecorder(stream, { mimeType }); mediaRecorderRef.current = mr; + streamRef.current = stream; chunksRef.current = []; rawSamplesRef.current = []; accumulatedMsRef.current = 0; @@ -136,6 +158,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { mr.onstop = () => { stream.getTracks().forEach((t) => t.stop()); + streamRef.current = null; const blob = new Blob(chunksRef.current, { type: mimeType }); setPreviewBlob(blob); setPreviewUrl((prev) => { @@ -186,9 +209,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { const mr = mediaRecorderRef.current; if (mr && (mr.state === 'recording' || mr.state === 'paused')) { mr.ondataavailable = null; + // onstop (which would release the mic) is cleared, so release it here. mr.onstop = null; mr.stop(); } + stopStream(); setPreviewBlob(null); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -200,7 +225,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { setWaveformBars(Array(WAVEFORM_BARS).fill(0)); setDurationMs(0); setState('idle'); - }, [stopAll]); + }, [stopAll, stopStream]); const sendVoice = useCallback(() => { if (!previewBlob) return; @@ -299,17 +324,19 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { size="300" radii="300" title={paused ? 'Resume' : 'Pause'} + style={{ flexShrink: 0 }} > @@ -320,6 +347,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { size="300" radii="300" title="Cancel" + style={{ flexShrink: 0 }} >