Interview Practice
A pipeline for practicing coding interviews out loud: a browser recorder captures audio and every keystroke, transcription and an LLM evaluation turn the session into scored, timestamped feedback, and a review app replays it all in sync.
- TypeScript
- React
- LLM APIs
- ffmpeg
- Nix
Overview
A browser recorder captures audio and every editor keystroke while a coding problem is solved out loud. The recording is transcribed, corrected, and scored by an LLM against a rubric, each observation tied to a timestamp. A review app plays the session back, with code, audio, and transcript locked to one timeline and the evaluation’s key moments as seek points.
Live demo
A real 15.6-minute practice session, reviewable in the actual Playback app: an account-sharing detection problem solved in Python, with the ElevenLabs Scribe transcript and an LLM evaluation loaded alongside the audio and the code timeline.
Before pressing play: the audio is an unedited close microphone, and every lip smack survived. Apologies in advance to anyone with misophonia. The page also loads about 21 MB, most of it the recording.
The app is the unmodified production build, and the session, transcript, and evaluation are unedited pipeline artifacts served statically.
The pipeline
The three JSON files are the entire interface between the apps: each stage reads the previous artifact and writes its own, their shapes defined once in a shared types package. Each file is shown below in its stage’s section, trimmed from the demo session.
Tape Deck
Tape Deck records the session in the browser: the microphone, and the
full contents of the editor at every keystroke. Both are saved incrementally
as they arrive, so an interrupted session can be picked back up, and export
bundles the whole thing, code timeline and audio, into a single
session.json.
Three consecutive events from the demo session, 195 ms apart in total:
{ "timestamp": 299055, "content": "# connections " }
{ "timestamp": 299148, "content": "# connections =" }
{ "timestamp": 299250, "content": "# connections = " }
There are 579 of these in the session. An event is the whole buffer rather than a delta, which is what lets Playback seek anywhere in the recording without replaying the keystrokes to get there.
session.json: the problem, 579 editor states, and the audio
{
"version": 2,
"startedAt": "2026-04-05T23:04:27.329Z",
"durationMs": 935221,
"language": "python",
"problem": "# Account Sharing Detection\n\nYou've compiled a list of IP addresses…",
"codeEvents": [
…
{
"timestamp": 469946,
"content": "# connections = [(\"1.1.1.1\", \"mike\"), …]\n\n# mike in set(mike, bob) return c"
},
… 579 events, each the full editor contents at one keystroke …
],
"audio": {
"segments": [
{
"startedAt": 10,
"mimeType": "audio/webm;codecs=opus",
"data": "GkXfowEAAAA…"
}
]
}
}The plan is caught mid-keystroke: at 7:50 the editor holds a half-typed
planning comment, mike in set(mike, bob) return c, sketching the approach
narrated out loud a minute earlier.
Rosetta
Rosetta turns that audio into a timestamped transcript. The speech-to-text itself is a hosted model’s job; Rosetta’s work is everything around it: deciding which audio is worth paying to transcribe, and repairing what the transcriber gets wrong.
The first problem is silence. A coding interview is full of it, and Whisper does not idle through a long quiet stretch; it invents repeats of the last thing it heard. So audio is silence-stripped with ffmpeg before transcription, and the transcriber only ever hears speech:
The second problem is jargon. A transcriber has no idea the candidate’s
variables are called t1 and find_first_missing, but the session’s own
code knows. A correction pass hands an LLM the transcript plus a vocabulary
of identifiers scraped from the code timeline:
find_first_missingmidt2t1| Whisper heard | after correction | |
|---|---|---|
| 0:07 | You're given the API called is stolen, which takes a timestamp… | You're given the API called isStolenAtTime, which takes a timestamp… |
| 4:42 | find first missing of t1 and t2 | find_first_missing of t1 and t2 |
| 5:10 | mid equals one plus t2 divided by two | mid equals t1 plus t2 divided by two |
| 5:25 | f is stolen mid | if is_stolen mid |
| 9:42 | is stolen t1 equals false for | is_stolen(t1) equals false for |
transcript.json: timestamped segments and the code vocabulary
{
"vocabulary": ["account_sharing_detection", "seen", …],
"correctedSegments": [
…
{
"start": 398034,
"end": 408174,
"text": "And so in terms of data structures I want to use, I'm thinking of either HashSet or a dictionary."
},
{
"start": 412734,
"end": 422114,
"text": "Because we can early exit, we can exit on the IP we're currently looking at, meaning we don't need to store old IPs."
},
…
],
"stats": { "totalAudioMs": 934934, "speechMs": 526874, … }
}Lens
Lens grades the session, and most of its work is building the document the grader reads. 579 editor states are too many to hand to a model, so the timeline is compressed to 24 snapshots and 23 diffs (a snapshot every ninety seconds, or sooner when enough lines change), and each diff is interleaved with the speech that overlapped it. The evaluator reads one document with everything on one clock. This is the interval covering the moment traced in the excerpts above, verbatim from the demo evaluation’s prompt:
### Interval 5 -> 6 [6:38 -> 7:13]
Elapsed: 35s | Speech: 24s | Silence: 11s | Words: 48 | Code: +1/-1 lines
```diff
--- snapshot-5 6:38
+++ snapshot-6 7:13
@@ -1,3 +1,3 @@
# connections = [("1.1.1.1", "mike"), ("1.1.1.1", "mike")]
-#
+# h
```
**Overlapping speech:**
[6:38 -> 6:48] And so in terms of data structures I want to use, I'm thinking of either HashSet or a dictionary.
[6:52 -> 7:02] Because we can early exit, we can exit on the IP we're currently looking at, meaning we don't need to store old IPs.
[7:08 -> 7:13] So a HashSet would be fine.
One of 23 such intervals; the full prompt runs 31,236 characters, about
7,800 tokens, for the 15:35 session. The rubric that follows it is
versioned, seven versions in so far. Back comes evaluation.json:
evaluation.json: scored dimensions and timestamped key moments
{
"promptVersion": "v1",
"summary": "The candidate demonstrated an incredibly methodical…",
"dimensions": [
…
{
"dimension": "approach_narration",
"score": 5,
"evidence": "At 06:38, they outline the approach: \"in terms of data structures I want to use, I'm thinking of either HashSet or a dictionary.\" They then justify the HashSet at 06:52…"
},
…
],
"keyMoments": [
…
{
"timestampMs": 412000,
"valence": "positive",
"description": "Candidate accurately justifies why a HashSet rather than a dictionary is sufficient for early exit logic."
},
…
]
}The evidence quotes the transcript segments above, and the key moment points back at 6:52; clicking it in Playback jumps there.
A transcript is frozen once written, so evaluations re-run cheaply against it, and the two demo links above are exactly that: two graders given the same session and the same transcript.
Scores as a table
| dimension | Gemini 3.1 Pro | Claude Opus 4.6 |
|---|---|---|
| problem restatement | 5 | 4 |
| approach narration | 5 | 5 |
| code and speech alignment | 5 | 5 |
| silence management | 4 | 3 |
| decomposition | not scored | 3 |
| example usage | 5 | 5 |
| debugging strategy | not scored | not scored |
| edge case awareness | 4 | 3 |
| correctness | 5 | 5 |
| optimality awareness | 5 | 5 |
| time allocation | 4 | 4 |
| recovery from mistakes | not scored | not scored |
Playback
Playback, a React app, puts it all back together. The audio is reassembled from the recorded segments; the code pane and the transcript follow the same clock, the editor shown exactly as it stood at the playhead. The evaluation sits alongside, and clicking a key moment jumps everything to that instant. It is the app embedded in the live demo above, byte for byte.
The four apps share one TypeScript monorepo and the types the three JSON files obey. An experiment harness re-runs the pipeline under different configurations (transcription model, correction model, prompt version, and evaluator model), recording each run’s configuration and the git SHA of the pipeline code that produced it; the repository holds 126 such runs.