diff --git a/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.test.tsx b/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.test.tsx index 96ef8fb7..0b789a52 100644 --- a/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.test.tsx +++ b/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.test.tsx @@ -274,3 +274,40 @@ it("recovers when recorder.start() fails after the ref was assigned", async () = }); consoleError.mockRestore(); }); + +it("interrupts instead of throwing when a chunk restart fails", async () => { + vi.useFakeTimers(); + const first = makeStream(); + getUserMedia.mockResolvedValue(first.stream); + const onRecordingInterrupted = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => + useChunkedAudioRecorder({ onChunk: vi.fn(), onRecordingInterrupted }), + ); + + await act(async () => { + await result.current.startRecording(); + }); + expect(result.current.isRecording).toBe(true); + + // the mic dies between chunks, so the next chunk's start() throws + recorderFailure.failNextStart = true; + + // the chunk timer stops the active recorder; its onstop spins up the next + // chunk, whose start() now throws inside the async stop handler + await act(async () => { + vi.advanceTimersByTime(30000); + await Promise.resolve(); + await Promise.resolve(); + }); + + // the failure surfaces as an interruption, not a stuck "still recording" state + expect(onRecordingInterrupted).toHaveBeenCalled(); + expect(result.current.isRecording).toBe(false); + // the dead mic is released + expect(first.track.stop).toHaveBeenCalled(); + + vi.useRealTimers(); + consoleError.mockRestore(); +}); diff --git a/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.ts b/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.ts index efb3f179..9da2d7b4 100644 --- a/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.ts +++ b/echo/frontend/src/components/participant/hooks/useChunkedAudioRecorder.ts @@ -186,95 +186,136 @@ const useChunkedAudioRecorder = ({ const chunkBufferRef = useRef([]); - // biome-ignore lint/correctness/useExhaustiveDependencies: needs to be looked at - const startRecordingChunk = useCallback(() => { - log("startRecordingChunk", { - isRecording, - mediaRecorderRefState: mediaRecorderRef.current?.state, - }); - if (!streamRef.current) { - log("startRecordingChunk: no stream found"); - return; - } - - // Ensure that any previous MediaRecorder instance is stopped before creating a new one - if (mediaRecorderRef.current) { - log("startRecordingChunk: stopping previous MediaRecorder instance"); - mediaRecorderRef.current.stop(); - mediaRecorderRef.current = null; + // Play the pre-unlocked alert so the participant hears the recording was + // interrupted. Best-effort: playback can be blocked and must not throw. + const playInterruptionAlert = useCallback(() => { + if (audioAlertRef.current) { + audioAlertRef.current.muted = false; + audioAlertRef.current.currentTime = 0; + audioAlertRef.current.play().catch((error) => { + console.error("Failed to play notification sound:", error); + }); } + }, []); - log("startRecordingChunk: creating new MediaRecorder instance"); - const recorder = new MediaRecorder(streamRef.current, { - mimeType: MediaRecorder.isTypeSupported(mimeType) - ? mimeType - : "audio/webm", - }); - mediaRecorderRef.current = recorder; - - recorder.ondataavailable = (event) => { - log("ondataavailable", event.data.size, "bytes"); - if (event.data.size > 0) { - chunkBufferRef.current.push(event.data); + // biome-ignore lint/correctness/useExhaustiveDependencies: needs to be looked at + const startRecordingChunk = useCallback( + (isRestart = false) => { + log("startRecordingChunk", { + isRecording, + mediaRecorderRefState: mediaRecorderRef.current?.state, + }); + if (!streamRef.current) { + log("startRecordingChunk: no stream found"); + return; } - }; - recorder.onstop = () => { - log("MediaRecorder stopped"); - const chunkBlob = new Blob(chunkBufferRef.current, { type: mimeType }); - const chunkSize = chunkBlob.size; + // Ensure that any previous MediaRecorder instance is stopped before creating a new one. + // A restart runs from onstop, where the previous recorder is already inactive; stop() + // on an inactive recorder throws, so only stop one still recording (as stopRecording and + // pauseRecording do). + if (mediaRecorderRef.current) { + log("startRecordingChunk: stopping previous MediaRecorder instance"); + if (mediaRecorderRef.current.state === "recording") { + mediaRecorderRef.current.stop(); + } + mediaRecorderRef.current = null; + } - // Track chunk history for interruption reporting - chunkHistoryRef.current.push({ - size: chunkSize, - timestamp: Date.now(), + log("startRecordingChunk: creating new MediaRecorder instance"); + const recorder = new MediaRecorder(streamRef.current, { + mimeType: MediaRecorder.isTypeSupported(mimeType) + ? mimeType + : "audio/webm", }); + mediaRecorderRef.current = recorder; - // Check if this is a suspicious chunk (< 1KB) - if (chunkSize < MIN_CHUNK_SIZE_BYTES) { - suspiciousChunkCountRef.current++; + recorder.ondataavailable = (event) => { + log("ondataavailable", event.data.size, "bytes"); + if (event.data.size > 0) { + chunkBufferRef.current.push(event.data); + } + }; - // If 2 consecutive suspicious chunks, recording has failed - if ( - suspiciousChunkCountRef.current >= 2 && - !hasCalledInterruptionCallbackRef.current - ) { - hadConsecutiveSuspiciousChunksRef.current = true; - hasCalledInterruptionCallbackRef.current = true; + recorder.onstop = () => { + log("MediaRecorder stopped"); + const chunkBlob = new Blob(chunkBufferRef.current, { type: mimeType }); + const chunkSize = chunkBlob.size; - // Play notification sound for interruption using pre-unlocked audio - if (audioAlertRef.current) { - audioAlertRef.current.muted = false; - audioAlertRef.current.currentTime = 0; - audioAlertRef.current.play().catch((error) => { - console.error("Failed to play notification sound:", error); - }); + // Track chunk history for interruption reporting + chunkHistoryRef.current.push({ + size: chunkSize, + timestamp: Date.now(), + }); + + // Check if this is a suspicious chunk (< 1KB) + if (chunkSize < MIN_CHUNK_SIZE_BYTES) { + suspiciousChunkCountRef.current++; + + // If 2 consecutive suspicious chunks, recording has failed + if ( + suspiciousChunkCountRef.current >= 2 && + !hasCalledInterruptionCallbackRef.current + ) { + hadConsecutiveSuspiciousChunksRef.current = true; + hasCalledInterruptionCallbackRef.current = true; + + // Play notification sound for interruption using pre-unlocked audio + playInterruptionAlert(); + + // Don't upload suspicious chunk, don't restart recording + chunkBufferRef.current = []; + onRecordingInterrupted?.(); + return; } - // Don't upload suspicious chunk, don't restart recording + // First suspicious chunk - don't upload it, but continue recording chunkBufferRef.current = []; - onRecordingInterrupted?.(); + startRecordingChunk(true); return; } - // First suspicious chunk - don't upload it, but continue recording - chunkBufferRef.current = []; - startRecordingChunk(); - return; - } + // Good chunk - reset suspicious counter and upload + suspiciousChunkCountRef.current = 0; + onChunk(chunkBlob); - // Good chunk - reset suspicious counter and upload - suspiciousChunkCountRef.current = 0; - onChunk(chunkBlob); - - // flush the buffer and restart - chunkBufferRef.current = []; - startRecordingChunk(); - }; + // flush the buffer and restart + chunkBufferRef.current = []; + startRecordingChunk(true); + }; - // allow for some room to restart so all is just one chunk as per mediarec - recorder.start(timeslice * 2); - }, [isRecording]); + // allow for some room to restart so all is just one chunk as per mediarec + try { + recorder.start(timeslice * 2); + } catch (error) { + // The initial start propagates to startRecording, which releases the + // mic and returns false. A restart has no such caller, so the throw + // would escape onstop or the restart timer and leave isRecording stuck + // true while no more audio is captured and the participant is never + // told. The mic track can die between chunks: the participant takes a + // call, or another app grabs the device. Reset here and raise the same + // interruption the participant gets for a dead mic. + if (!isRestart) { + throw error; + } + console.error("Failed to restart recording chunk", error); + mediaRecorderRef.current = null; + isRecordingRef.current = false; + setIsRecording(false); + streamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + if (!hasCalledInterruptionCallbackRef.current) { + hasCalledInterruptionCallbackRef.current = true; + chunkBufferRef.current = []; + playInterruptionAlert(); + onRecordingInterrupted?.(); + } + } + }, + [isRecording], + ); const startRecording = async (initialTime = 0): Promise => { if (startingRef.current || isRecordingRef.current) { @@ -398,7 +439,7 @@ const useChunkedAudioRecorder = ({ if (isRecording) { log("Restarting recording chunk"); - startRecordingChunk(); + startRecordingChunk(true); } } }, timeslice);