Two small pieces of our interview practice product exist entirely because the platform lies to us in predictable ways. One is speech-to-text on a recording with no speech in it. The other is getUserMedia telling a user "Permission denied" when the actual problem is that Zoom still has the camera.
Silence does not transcribe as nothing
Whisper does not return an empty string for a silent recording. It returns whatever it was primed for. We send a short priming prompt that asks for a verbatim transcript including fillers and false starts, because a practice tool that silently cleans up "um" cannot then tell you how often you said it. The cost of that prompt is that a silent answer comes back as one of:
- a run of the fillers we asked it to preserve, as in
So, um, um, um, um, um, um, you - a stock YouTube-ish phrase:
Thank you.orThanks for watching! - an echo of the priming prompt itself, with our own list of filler words read back to us as though the candidate had said them
The third one is the surprising one, and it is the one that makes a plain emptiness check useless: response.text.trim().length > 0 is true for all three, so every silent answer looks like speech.
So the transcription step asks a different question. Strip the fillers we know about and a short list of the words Whisper produces out of nothing, then count what is left:
/** Fewer real words than this is not an answer, whatever Whisper returned. */ const MIN_SPOKEN_WORDS = 3; export function isEffectivelySilent(text: string): boolean { // lowercase, drop punctuation, remove known filler phrases and words // plus the stock phrases, then: return spoken.length < MIN_SPOKEN_WORDS; } The call site throws the hallucination away rather than trying to repair it:
if (isEffectivelySilent(response.text ?? '')) return { text: '', words: [] }; Downstream, an empty transcript means "no answer", which is a thing the product already knows how to say.
The test is mostly about what must not be silence
A heuristic like this earns its keep in the direction nobody thinks about. The 28-line test file has three blocks, and the third is the important one:
it('keeps any real answer, however short or hesitant', () => { expect(isEffectivelySilent("I don't know.")).toBe(false); expect(isEffectivelySilent('Um, so, I led the team, uh, through it.')).toBe(false); }); "I don't know." is a real answer. It is a bad answer, and telling a candidate so is the entire point of the product, but a tool that responded "we could not hear you" to an honest three-word answer would be worse than useless. Three words is also exactly the floor, so that case sits on the boundary deliberately, and it is pinned by a test so a future tweak to MIN_SPOKEN_WORDS cannot quietly swallow it.
The second assertion guards the other edge: an answer that is mostly filler with a real sentence inside it is an answer.
What happens to a silent answer
Nothing model-shaped. The marking path has an early return with a comment that is the whole design:
// A silent answer is a fact, not something to ask a model about. if (answer.replace(/\s/g, '').length === 0) { return { ...base, score: 1, star: null, levelRead: 'below', levelNote: 'There was no answer to compare with the level.', tip: 'Check your microphone, and if you need a moment to think, say so and then answer.', strongerAnswer: '', }; } Two reasons. A model given an empty answer will happily write a paragraph of feedback about it, and that paragraph will be invented. And asking it anyway costs a request per silent answer for an outcome we already know.
The tip names both causes, because the function genuinely cannot tell them apart: a muted microphone and a candidate who froze produce the same empty transcript. Pretending to know which happened would be the lie here.
Four DOMException names, four instructions
The other piece is at the opposite end of the same recording. getUserMedia rejects with a DOMException whose name is the useful part and whose message is not:
/** The browser's own messages ("Permission denied") say what failed but not * what to do about it. */ function mediaErrorMessage(e: unknown): string { const name = e instanceof DOMException || e instanceof Error ? e.name : ''; if (name === 'NotAllowedError' || name === 'SecurityError') return 'Camera and microphone access is blocked. Allow both for this site in your browser settings, then try again.'; if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'We could not find a camera and microphone. Connect them, then try again.'; if (name === 'NotReadableError' || name === 'AbortError') return 'Your camera or microphone is in use by another app. Close it, then try again.'; return 'We could not access your camera or microphone. Check your browser settings, then try again.'; } The pairings are the content. SecurityError is an older spelling of a blocked permission. OverconstrainedError means no device satisfied the constraints, which for a plain { video: true, audio: true } request means there is effectively no device. NotReadableError and AbortError are both "the hardware exists and something else has it", which is the case users resolve fastest once you name it, and the case the browser's own wording hides completely.
It is applied at the boundary rather than at the UI, so no component has to know about DOMException at all:
const stream = await navigator.mediaDevices .getUserMedia({ video: true, audio: true }) .catch((e: unknown) => { throw new Error(mediaErrorMessage(e)); }); The catch-all branch exists because this list will go stale. Browsers add names, and a vendor-specific one should produce a vague but true sentence rather than an empty error card.
See it
- The product: cogniprep.app/interview. Running a session needs an account; the fastest way to see the error path is to start one and click Block on the permission prompt, which gives you the sentence above rather than the browser's.
- You can check the
DOMExceptionnames yourself in any console on an HTTPS page:navigator.mediaDevices.getUserMedia({ video: true }).catch(e => e.name), then deny the prompt, then try again with a video call open in another tab. - A thing you cannot see, which is the point: none of this ships to a visitor. Grep the 19 JavaScript chunks the interview hub loads (1,129,697 bytes as of today) for
isTypeSupportedor any of the four messages above and you get zero hits, because the session runner is behind the sign-in and is not in the public page's bundle.