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:
2026-07-10 14:04:31 -04:00
co-authored by Claude Opus 4.8
parent 13304f1571
commit b38df58b68
+31 -3
View File
@@ -41,6 +41,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(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 }}
>
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
</IconButton>
<IconButton
onClick={stopRecording}
aria-label="Stop recording"
aria-label="Finish recording"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Stop recording"
title="Finish"
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Check} size="100" />
</IconButton>
@@ -320,6 +347,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
size="300"
radii="300"
title="Cancel"
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>