fix(voice): release mic on cancel/unmount + recorder polish
Address findings from 2 review agents (pause/resume duration model + meter lifecycle were verified correct): - Mic-stream leak (HIGH, pre-existing but in this change's blast radius): the mic tracks were only stopped inside mr.onstop, which cancel() nulls and the unmount effect never triggered — so cancelling or unmounting mid-recording/pause left the mic live (OS indicator on). Hold the stream in a ref and release its tracks explicitly (stopStream) on cancel and on unmount, independent of onstop. Normal stop still releases via onstop. - Defensive: startMeters now cancels any existing rAF/interval before starting, so it can never spawn a second loop. - a11y/UX: the finish button (checkmark, advances to the preview step) is relabeled "Finish recording"/"Finish" so the label matches the check glyph (was "Stop recording" with the old pause icon). The three recording-control buttons get flexShrink:0 so they don't squish the waveform at narrow composer widths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
@@ -62,6 +63,9 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
const startMeters = useCallback(() => {
|
const startMeters = useCallback(() => {
|
||||||
const analyser = analyserRef.current;
|
const analyser = analyserRef.current;
|
||||||
if (!analyser) return;
|
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 buf = new Uint8Array(analyser.frequencyBinCount);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!analyserRef.current) return;
|
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(() => {
|
const stopAll = useCallback(() => {
|
||||||
stopMeters();
|
stopMeters();
|
||||||
if (audioCtxRef.current) {
|
if (audioCtxRef.current) {
|
||||||
@@ -98,6 +110,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
|
|
||||||
useEffect(
|
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();
|
stopAll();
|
||||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
},
|
},
|
||||||
@@ -115,6 +136,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
|
|
||||||
const mr = new MediaRecorder(stream, { mimeType });
|
const mr = new MediaRecorder(stream, { mimeType });
|
||||||
mediaRecorderRef.current = mr;
|
mediaRecorderRef.current = mr;
|
||||||
|
streamRef.current = stream;
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
rawSamplesRef.current = [];
|
rawSamplesRef.current = [];
|
||||||
accumulatedMsRef.current = 0;
|
accumulatedMsRef.current = 0;
|
||||||
@@ -136,6 +158,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
|
|
||||||
mr.onstop = () => {
|
mr.onstop = () => {
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
setPreviewBlob(blob);
|
setPreviewBlob(blob);
|
||||||
setPreviewUrl((prev) => {
|
setPreviewUrl((prev) => {
|
||||||
@@ -186,9 +209,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
const mr = mediaRecorderRef.current;
|
const mr = mediaRecorderRef.current;
|
||||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||||
mr.ondataavailable = null;
|
mr.ondataavailable = null;
|
||||||
|
// onstop (which would release the mic) is cleared, so release it here.
|
||||||
mr.onstop = null;
|
mr.onstop = null;
|
||||||
mr.stop();
|
mr.stop();
|
||||||
}
|
}
|
||||||
|
stopStream();
|
||||||
setPreviewBlob(null);
|
setPreviewBlob(null);
|
||||||
setPreviewUrl((prev) => {
|
setPreviewUrl((prev) => {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
@@ -200,7 +225,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
||||||
setDurationMs(0);
|
setDurationMs(0);
|
||||||
setState('idle');
|
setState('idle');
|
||||||
}, [stopAll]);
|
}, [stopAll, stopStream]);
|
||||||
|
|
||||||
const sendVoice = useCallback(() => {
|
const sendVoice = useCallback(() => {
|
||||||
if (!previewBlob) return;
|
if (!previewBlob) return;
|
||||||
@@ -299,17 +324,19 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
title={paused ? 'Resume' : 'Pause'}
|
title={paused ? 'Resume' : 'Pause'}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
|
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={stopRecording}
|
onClick={stopRecording}
|
||||||
aria-label="Stop recording"
|
aria-label="Finish recording"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
title="Stop recording"
|
title="Finish"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Check} size="100" />
|
<Icon src={Icons.Check} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -320,6 +347,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Cross} size="100" />
|
<Icon src={Icons.Cross} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user