Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -186,95 +186,136 @@ const useChunkedAudioRecorder = ({

const chunkBufferRef = useRef<Blob[]>([]);

// 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<boolean> => {
if (startingRef.current || isRecordingRef.current) {
Expand Down Expand Up @@ -398,7 +439,7 @@ const useChunkedAudioRecorder = ({

if (isRecording) {
log("Restarting recording chunk");
startRecordingChunk();
startRecordingChunk(true);
}
}
}, timeslice);
Expand Down
Loading