Our interview practice product records your answers and marks what you said. It does not mark how you looked, as of last night. That turns a one-line question into a design: if the picture is never analysed, why is the picture being uploaded?

It should not be, and it no longer is. But the camera cannot simply be switched off, because watching your own answer back is most of the value of practising. So one getUserMedia stream now feeds two MediaRecorder instances: one that records video and stays in the tab, and one that records audio and is the only thing that goes to storage.

The second recorder is three lines

const replay = new MediaRecorder(stream, { mimeType: replayMime.current }); // ... const audioOnly = new MediaRecorder(new MediaStream(stream.getAudioTracks()), { mimeType: upload.current.recorderMime, }); 

new MediaStream(stream.getAudioTracks()) is the whole trick: a second stream over the same live audio track, with no video track in it, so its recorder cannot encode a picture even by accident. Both recorders are started with start(1000) on the same tick and collect into separate chunk arrays.

Three fallback layers, because MediaRecorder support is a patchwork

The container is chosen once, at module scope, by asking rather than by sniffing:

if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) ... if (MediaRecorder.isTypeSupported('audio/webm')) ... if (MediaRecorder.isTypeSupported('audio/mp4')) ... return { contentType: detectReplayMimeType(), recorderMime: null }; 

That last line is the Safari path: detectReplayMimeType is still a user-agent check (Safari present, Chrome absent) returning video/mp4, because this value has to be decided before any recorder exists, for reasons in the next section.

isTypeSupported returning true is not a promise that construction succeeds, so the audio recorder is built inside a try, and a throw leaves uploadRecorderRef null. And a successfully constructed recorder can still produce nothing, so the stop path checks:

const uploadBlob = uploadRecorderRef.current && uploadChunksRef.current.length > 0 ? await fixDuration(new Blob(uploadChunksRef.current, ...)) : replayBlob; 

Feature test, construction test, output test. Each one falls back to uploading the video recording, which is what every client did before this change, and which the server still accepts.

The content type is chosen before the recording exists

Answers are uploaded straight from the browser to Cloudflare R2 with presigned PUT URLs, and a presigned PUT signs its ContentType. The URLs are minted when the session is created, which is before the first question has even been shown. So the browser has to tell the server which MIME type it will produce, and the server validates it rather than trusting it:

mimeType: z .enum(['audio/webm', 'audio/mp4', 'video/webm', 'video/mp4'], { ... }) .default('video/webm'), 

Four accepted values: the two audio ones are what current pages send, and the two video ones stay accepted for a page that was loaded before the deploy and is still open. The default is the old value for the same reason. Junk is rejected rather than defaulted, because a type we cannot process would mint a presigned URL that no upload can satisfy.

Now the fun part. If the audio recorder fails at construction, the upload is the video blob, and the PUT still sends the header that was signed:

headers: { 'Content-Type': upload.current.contentType }, 

So the object in the bucket can be video bytes labelled audio/webm. Nothing downstream notices, because nothing downstream reads the label: the worker runs ffprobe and looks at the actual streams in the container. That is the right way round. A stored content type is a claim made by a client, and a pipeline that branches on it is trusting the least trustworthy field in the request.

Stopping two recorders is not stop(); buildBlob()

MediaRecorder.stop() is asynchronous; the last dataavailable arrives after it. Assembling the blob on the next line quietly loses the tail of every answer, and the bug looks like "the last second is missing" rather than like a race. So stopping is wrapped:

function stopRecorder(rec: MediaRecorder | null): Promise<void> { return new Promise((resolve) => { if (!rec || rec.state === 'inactive') { resolve(); return; } rec.onstop = () => resolve(); rec.stop(); }); } await Promise.all([ stopRecorder(replayRecorderRef.current), stopRecorder(uploadRecorderRef.current), ]); 

Both blobs then go through fix-webm-duration, because Chrome's MediaRecorder writes a WebM header with an unknown duration. Without the patch the replay cannot seek and ffprobe has to fall back to counting packet timestamps. We keep that fallback anyway, and it learned a new argument.

The worker flipped its requirement

The processing side used to require a video stream and treat the audio as something it extracted. Now it requires audio and treats video as optional:

const audioStream = streams.find((s) => s.codec_type === 'audio'); const videoStream = streams.find((s) => s.codec_type === 'video'); if (!audioStream) { throw new InterviewError( `No audio stream found in file: ${videoPath}`, ErrorCodes.PROCESSING_FAILED, false, 'One of your recordings has no sound. Please check your microphone and try again.' ); } 

That fourth argument is the user-facing message, and it is the real improvement: "the uploaded file does not contain a valid video stream" told a candidate nothing they could act on, and a recording with no sound is nearly always a muted or unplugged microphone.

The packet-timestamp fallback took a stream selector, 'v:0' | 'a:0', chosen by whether a video stream exists, because -select_streams v:0 finds no packets at all in an audio-only file and returns NaN duration for a perfectly good recording. And the server-side duration ceiling went from 180 to 240 seconds, because the new interview track allows three-minute answers and that ceiling needs headroom for clock skew and container overhead rather than being the limit itself.

See it

  • cogniprep.app/interview is the product. Starting a session needs an account, and when you run one, open DevTools and watch the Network tab at submit: there is one PUT per answer straight to an R2 host, each with Content-Type: audio/webm on a modern Chrome or Firefox, and nothing resembling a video upload.
  • Before that, the replay between questions is served from a blob: URL, which is the other half of the claim: open the Network tab during the replay and there is no request at all.
  • The promise this implements is written down at cogniprep.app/privacy, under Answer recordings: the camera picture is used for the preview and the replay, which stay on your device, and only the audio is uploaded.