The new interview track in our practice product is modelled on a live structured interview rather than a recorded one-way screen. In a live interview you hear the question and start talking: no question on screen to re-read, no preparation window. So the question has to be spoken, which means text-to-speech, which means a cache, an endpoint and a pile of callbacks that can fire after the thing they belong to is over.
The cache key is everything that shapes the audio
Questions come from a fixed authored bank, so a clip is worth generating once for every candidate who ever gets that question. The key is a hash of all four inputs:
export function voiceCacheKey(text: string): string { const hash = createHash('sha256') .update([VOICE_MODEL, VOICE, VOICE_INSTRUCTIONS, text].join('\n')) .digest('hex') .slice(0, 32); return `tts/${hash}.mp3`; } Not just the text. The model, the voice name and the delivery instructions are all in there, because every one of them changes what comes out of the speaker. The consequence is the reason to do it this way: there is no cache invalidation step anywhere in this feature. Reword a question, switch the voice, soften the delivery instructions, and the key changes, so a stale clip is not stale, it is simply unreachable. Nobody has to remember to purge anything, which is exactly the kind of thing nobody remembers to do.
The bucket now holds two different kinds of thing
Answer recordings live in a private R2 bucket and are deleted as soon as the processing job has consumed them. The clips live in the same bucket under tts/, and they are permanent and shared. That is a privacy-relevant difference, so it is written at the top of the storage module rather than being left to be inferred from key names:
User data in the bucket is only ever the raw answer recordings, and only until the processing job has consumed them. Extracted audio stays on the worker's local disk and is never persisted here. The one other prefix is
tts/: synthesised audio of the interview questions, which contains no user data and is shared by every session that asks the same question.
Both cache operations are non-fatal. A read failure logs and generates instead; a write failure logs and returns the audio anyway, so the next request simply regenerates it. A cache whose failure modes can break the product is not a cache, it is a dependency.
The endpoint cannot say anything you want
The obvious way to build this is to post the text and get back audio. That ships a free text-to-speech proxy on your own OpenAI key. So the route takes an index, not a string:
GET /api/interview/sessions/[interviewId]/questions/[index]/audio and the text comes out of the stored session:
const interview = await getInterviewById(interviewId); if (!interview) return apiError('Interview not found', 404); if (interview.userId !== user!.id) return apiError('Forbidden', 403); const i = Number(index); const text = Number.isInteger(i) ? interview.questionTexts[i] : undefined; if (!text) return apiError('Question not found', 404); The worst a caller can do with a valid session of their own is play their own questions out of order. The response is audio/mpeg with an explicit Content-Length and Cache-Control: private, max-age=3600: private because the URL is per session even though the bytes are shared, and an hour because a session does not last one.
Three layers of fallback, and a counter that stops the past from talking
The browser prefetches the clips in order as soon as the session exists, into object URLs:
/** Fetches each question's clip in order, so the next one is always ready * first. A failed fetch leaves the slot empty and playback falls back to * streaming the clip directly when the question is reached. */ Playing a question then has three outs: the prefetched object URL, or the route streamed directly into the <audio> element, or speechSynthesis with the question text. If even that errors, a voiceFailed flag flips and the UI shows the question as text with the button relabelled from "Answer now" to "Start my answer". A candidate who has paid for a session is never stuck behind a voice.
The hard part is none of that. It is that every one of those paths can report back late:
const askId = ++askIdRef.current; const isCurrent = () => askIdRef.current === askId; let fellBack = false; A failed clip reports through both the element's error event and the rejected play(). A cancelled SpeechSynthesisUtterance can still fire its own end or error after cancel(). The candidate can press "Answer now" to interrupt the interviewer, or re-record an answer, which asks the question again. Without the counter, the onended of a question the user interrupted thirty seconds ago starts a recording in the middle of the next one. With it, every handler checks isCurrent() and does nothing if the world has moved on, and fellBack makes the fallback itself fire at most once.
stopVoice() increments the same counter, which is the trick that makes this cheap: cancellation and completion use one mechanism, so there is no separate "cancelled" state that handlers have to consult.
One more detail that only exists because the upload is audio only:
const onSpoken = () => { if (!isCurrent()) return; askIdRef.current++; // A short beat so the tail of the voice is never in the recording. setTimeout(() => startRecording(), 300); }; 300 milliseconds between the interviewer finishing and the recorder starting. Without it, on a laptop with no echo cancellation, the last syllable of the question lands in the candidate's own answer, gets transcribed as though they said it, and shows up in their feedback.
See it
- The track that uses it: cogniprep.app/interview, where the experienced track card says the interviewer asks and you answer live. Running a session needs an account.
- The disclosure, which is the checkable part:
curl -s https://cogniprep.app/privacy | grep -o "interviewer voice that reads[^<]*"returns the OpenAI processor entry, including the clause "(only the question text is sent)". No part of your recording goes to the voice model, and the policy says so rather than leaving you to assume it. - The guides the same commit shipped are readable without an account: Interview questions for experienced candidates and Phone screen questions.

![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)