Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-voice-recorder-start-races.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Stop the microphone when a voice recording is cancelled, deleted, restarted or unmounted before the permission prompt resolves.
59 changes: 59 additions & 0 deletions src/app/plugins/voice-recorder-kit/useVoiceRecorder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,63 @@ describe('useVoiceRecorder', () => {
expect(destinationTrack.stop).toHaveBeenCalledTimes(1);
expect(recordingContext?.close).toHaveBeenCalledTimes(1);
});

it('stops a stream that resolves after recording is cancelled', async () => {
let resolveGetUserMedia!: (stream: MockStream) => void;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn<() => Promise<MockStream>>(
() =>
new Promise<MockStream>((resolve) => {
resolveGetUserMedia = resolve;
})
),
},
});

const { result } = renderHook(() => useVoiceRecorder({ autoStart: false }));
act(() => {
result.current.start();
result.current.handleStop();
});

const lateTrack = createMockTrack();
await act(async () => {
resolveGetUserMedia({ getTracks: () => [lateTrack] } as unknown as MockStream);
await Promise.resolve();
});

expect(lateTrack.stop).toHaveBeenCalledTimes(1);
expect(result.current.isRecording).toBe(false);
});

it('stops a stream that resolves after the hook unmounts', async () => {
let resolveGetUserMedia!: (stream: MockStream) => void;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn<() => Promise<MockStream>>(
() =>
new Promise<MockStream>((resolve) => {
resolveGetUserMedia = resolve;
})
),
},
});

const { result, unmount } = renderHook(() => useVoiceRecorder({ autoStart: false }));
act(() => {
result.current.start();
});
unmount();

const lateTrack = createMockTrack();
await act(async () => {
resolveGetUserMedia({ getTracks: () => [lateTrack] } as unknown as MockStream);
await Promise.resolve();
});

expect(lateTrack.stop).toHaveBeenCalledTimes(1);
});
});
97 changes: 80 additions & 17 deletions src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ function getSharedAudioContext(): AudioContext {
return sharedAudioContext;
}

function stopMediaStream(stream: MediaStream): void {
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
}

// downsample an array of samples to a target count by averaging blocks of samples together
function downsampleWaveform(samples: number[], targetCount: number): number[] {
if (samples.length === 0) return Array.from({ length: targetCount }, () => 0.15);
Expand Down Expand Up @@ -90,6 +94,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
const isResumingRef = useRef(false);
const isRestartingRef = useRef(false);
const isTemporaryStopRef = useRef(false);
const startGenerationRef = useRef(0);
const temporaryPreviewUrlRef = useRef<string | null>(null);
/**
* waveform samples collected during recording, used to generate waveform on stop.
Expand All @@ -106,7 +111,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic

const cleanupStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track: MediaStreamTrack) => track.stop());
stopMediaStream(streamRef.current);
streamRef.current = null;
}
}, []);
Expand Down Expand Up @@ -166,6 +171,33 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
}
}, []);

const invalidatePendingStart = useCallback(() => {
startGenerationRef.current += 1;
}, []);

const cleanupRecordingResources = useCallback(() => {
cleanupAudioContext();
cleanupStream();
cleanupMediaRecorder();
stopTimer();
}, [cleanupAudioContext, cleanupMediaRecorder, cleanupStream, stopTimer]);

const stopMediaRecorder = useCallback(
(mediaRecorder: MediaRecorder) => {
let stopThrew = false;
try {
mediaRecorder.stop();
} catch {
stopThrew = true;
}

if (stopThrew || !mediaRecorder.onstop) {
cleanupRecordingResources();
}
},
[cleanupRecordingResources]
);

const startRecordingTimer = useCallback(() => {
startTimeRef.current = Date.now() - pausedTimeRef.current * 1000;
stopTimer();
Expand Down Expand Up @@ -315,15 +347,20 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
return;
}

const startGeneration = ++startGenerationRef.current;
setError(null);
isResumingRef.current = false;

try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (startGeneration !== startGenerationRef.current) {
stopMediaStream(stream);
return;
}
const codec = getSupportedAudioCodec();
if (!codec) {
setError('No supported audio codec found for recording.');
cleanupStream();
stopMediaStream(stream);
return;
}
streamRef.current = stream;
Expand Down Expand Up @@ -418,6 +455,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
setIsStopped(false);
pausedTimeRef.current = 0;
} catch {
if (startGeneration !== startGenerationRef.current) return;
setError('Microphone access denied or an error occurred.');
cleanupAudioContext();
cleanupStream();
Expand Down Expand Up @@ -467,6 +505,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
}, [seconds, stopTimer]);

const handleStopTemporary = useCallback(() => {
invalidatePendingStart();
const mediaRecorder = mediaRecorderRef.current;

if (mediaRecorder && mediaRecorder.state !== 'inactive') {
Expand All @@ -481,11 +520,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
}
}

try {
mediaRecorder.stop();
} catch {
// ignore
}
stopMediaRecorder(mediaRecorder);

// Let cleanupStream() be handled by mediaRecorder.onstop
// Calling it synchronously here can kill the stream before Safari finishes emitting data
Expand Down Expand Up @@ -517,11 +552,14 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
cleanupMediaRecorder,
cleanupStream,
emitStopPayload,
invalidatePendingStart,
stopTimer,
stopMediaRecorder,
waveform,
]);

const handleStop = useCallback(() => {
invalidatePendingStart();
const mediaRecorder = mediaRecorderRef.current;

if (mediaRecorder && mediaRecorder.state !== 'inactive') {
Expand All @@ -536,11 +574,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
}
}

try {
mediaRecorder.stop();
} catch {
// ignore
}
stopMediaRecorder(mediaRecorder);

// Let cleanupStream() be handled by mediaRecorder.onstop
// Calling it synchronously here can kill the stream before Safari finishes emitting data
Expand Down Expand Up @@ -572,7 +606,9 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
cleanupMediaRecorder,
cleanupStream,
emitStopPayload,
invalidatePendingStart,
stopTimer,
stopMediaRecorder,
waveform,
]);

Expand Down Expand Up @@ -707,11 +743,16 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
return;
}

const startGeneration = ++startGenerationRef.current;
setError(null);
isResumingRef.current = true;

try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (startGeneration !== startGenerationRef.current) {
stopMediaStream(stream);
return;
}
streamRef.current = stream;
const recordedStream = setupAudioGraph(stream);

Expand Down Expand Up @@ -795,6 +836,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
// So it keeps the correct total time from previous Pause
startTimeRef.current = Date.now() - pausedTimeRef.current * 1000;
} catch {
if (startGeneration !== startGenerationRef.current) return;
setError('Microphone access denied or an error occurred.');
cleanupAudioContext();
cleanupStream();
Expand All @@ -816,9 +858,10 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
]);

const handleDelete = useCallback(() => {
invalidatePendingStart();
const mediaRecorder = mediaRecorderRef.current;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
stopMediaRecorder(mediaRecorder);
}

if (audioRef.current) {
Expand Down Expand Up @@ -852,14 +895,23 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
if (onDelete) {
onDelete();
}
}, [cleanupAudioContext, cleanupMediaRecorder, cleanupStream, onDelete, stopTimer]);
}, [
cleanupAudioContext,
cleanupMediaRecorder,
cleanupStream,
invalidatePendingStart,
onDelete,
stopMediaRecorder,
stopTimer,
]);

const handleRestart = useCallback(() => {
invalidatePendingStart();
isRestartingRef.current = true;
const mediaRecorder = mediaRecorderRef.current;

if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
stopMediaRecorder(mediaRecorder);
}

if (audioRef.current) {
Expand Down Expand Up @@ -900,16 +952,25 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
setAudioUrl(null);
setAudioFile(null);
internalStartRecording();
}, [cleanupAudioContext, cleanupMediaRecorder, cleanupStream, internalStartRecording, stopTimer]);
}, [
cleanupAudioContext,
cleanupMediaRecorder,
cleanupStream,
internalStartRecording,
invalidatePendingStart,
stopMediaRecorder,
stopTimer,
]);

useEffect(() => {
if (autoStart) {
internalStartRecording();
}
return () => {
invalidatePendingStart();
const mediaRecorder = mediaRecorderRef.current;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
stopMediaRecorder(mediaRecorder);
} else {
cleanupMediaRecorder();
}
Expand All @@ -935,6 +996,8 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic
cleanupMediaRecorder,
cleanupStream,
internalStartRecording,
invalidatePendingStart,
stopMediaRecorder,
stopTimer,
]);

Expand Down