0030 — Studio live VO recording (manual take, from the script)¶
Status: DRAFT — proposed; awaiting review.
Date: 2026-07-12
Source: ideation 2026-07-12 — a manual alternative to ElevenLabs for when TTS just can't
land a line. Read the card's narration ("the script") and record your own VO take in the
studio.
Depends on: the VO takes model — 0016-studio-audio-takes (generate/audition/pick),
0023-voice-take-workflow (takes listing + screening), 0025-locked-vo. Cross-cuts
0027-trusted-ca-and-studio-https (secure-context requirement, §6).
1. Goal & scope¶
ElevenLabs occasionally can't nail a line (a name it mangles, an emphasis it won't hit). The
only escape today is out-of-band recording + voice gen --out. Make it first-class in the
studio: for a card, record a VO take live from the browser, reading the card's narration
as a teleprompter, and have that recording enter the existing take list — auditioned and
picked exactly like a TTS take.
Key framing: this is a manual take source, not a new selection mechanism. It slots into
the 0016 takes model (vo/takes/NN-T.mp3 → takes.SelectVO → vo/NN.mp3, line ≡ card
index). Nothing downstream changes: the recorded clip's duration drives card timing
exactly like a TTS clip (VO drives timing — the parity invariant), the reel mixes it as VO at
full gain with the bed ducked under.
In scope: browser capture, upload, server-side transcode/normalise into a take slot, the record UI, a CLI-parity import core. Out of scope: multi-track editing, waveform editing (trim beyond auto lead/tail), noise removal beyond basic normalisation, changing the render mix.
2. Design¶
2.1 Capture (browser)¶
getUserMedia({audio})+MediaRecorderon the card's VO block. The card narration is shown as a teleprompter/script; record → stop → local preview (<audio>); re-take (discard) or save. Optional 3-2-1 countdown and a live level meter.- MediaRecorder yields
audio/webm;codecs=opus(or platform equivalent); the blob is POSTed to the server. Multiple recordings are just multiple takes. - Mic permission handled explicitly (prompt, and a clear "microphone blocked" state).
2.2 Secure-context constraint (cross-link to 0027)¶
getUserMedia requires a secure context — HTTPS or localhost. So:
- On a localhost studio (the default single-user bind) recording works today over
http (localhost is a secure context).
- On a LAN-exposed studio (--host 0.0.0.0, phone/other machine) the mic is blocked
until the studio is served over HTTPS — i.e. it depends on 0027. The UI must detect
a non-secure context and explain ("recording needs HTTPS on a networked studio — see …")
rather than fail opaquely.
2.3 Ingest (server)¶
- Endpoint:
POST /api/v1/workspace/{slug}/cards/{n}/vo/record(multipart audio) → transcode + normalise → write the next take slotvo/takes/NN-T.mp3→ 200 with the take path. It then appears in the existingGET …/vo/takeslist and is picked via the existing…/vo/pick. - Transcode/normalise uses the existing render backend (afmpeg in-memory / ffmpeg —
already a dependency; no new one): webm/opus → mp3, to the same sample-rate/mono shape as
TTS takes, with loudness normalisation so a recorded read sits at a comparable level to
ElevenLabs output in the mix (the reel already limits to avoid clipping). Optional lead/
tail silence trim (bounded) so timing isn't padded by dead air — reusing the
silencedetect-style pass from0023where available (afmpeg degrades gracefully). - Writes go to the active worktree fs (in-memory capable), consistent with
0016's FS-explicit audio gen; in-memory takes get the same 0013-D7 grace-TTL prune.
2.4 CLI parity (the seam)¶
Mirror cards set (bring-your-own media): a headless core keryx voice import --line N
--file read.wav [--workspace slug] transcodes/normalises an existing audio file into a VO
take slot. The studio record endpoint and the CLI both call this one core (parity-tested),
so a recorded take and an imported file are the same thing. --select optionally promotes it
straight to vo/NN.mp3.
2.5 Recorded takes & the rest of the workflow¶
- Screening (
0023): a human read is inherently un-stilted, so stilt-ranking is moot for recorded takes — but duration still matters (it drives timing) and is listed like any take.voice takesmarks a take's source (tts | recorded) so the list is honest. - Locked VO (
0025): a hand-recorded read is the canonical "final, don't regenerate" take — recording should make it easy to pick + lock in one step.
3. Requirements¶
- R-GEN-40 (MUST) Record a VO take in the studio for a card, from its narration, landing as a new candidate in that card's take list (auditioned + picked via the existing 0016 flow); the recorded clip's duration drives card timing identically to a TTS take.
- R-GEN-41 (MUST)
keryx voice import --line N --file <audio>ingests an external audio file into a VO take slot (transcode + normalise), shared by the studio record endpoint;--selectpromotes it. - R-GEN-42 (SHOULD) The studio detects a non-secure context (networked http bind) and
explains that recording needs HTTPS (
0027), rather than failing silently. - R-GEN-43 (SHOULD) Take listings mark source (tts | recorded); a recorded take can be
picked-and-locked (
0025) in one action.
4. Testing / DoD¶
Failing test → code → green just ci. Server: the record/import core against a fake
transcoder seam (no ffmpeg/network in unit tests) — assert a take slot is written, sample/
normalise params applied, next-index chosen, in-memory fs honoured; a parity test that
studio-record and voice import produce identical output. The real transcode path stays
INT_TEST=1-gated. Frontend: @testing-library/svelte over the record component with a
mocked MediaRecorder/getUserMedia (state machine: idle→record→preview→save/retake,
permission-denied, non-secure-context). E2E: extend the Playwright harness from 0028
§5.3 with a record flow, faking getUserMedia/MediaRecorder (an injected fake stream
— a headless browser has no real mic), asserting the take lands in the mocked take list. Docs: a how-to ("Record a line yourself when TTS
won't cooperate") + the studio audio-takes component page.
5. Open questions / decisions¶
- D1 — transcode home. Reuse the render backend seam (rec — no new dep) vs a dedicated audio-transcode path. Confirm afmpeg can decode webm/opus in-memory (else materialise, or require ffmpeg for this path with graceful messaging).
- D2 — normalisation target. Match ElevenLabs perceived loudness how? A fixed LUFS/peak target vs normalise-to-take-median. *Rec: a fixed conservative peak/LUFS target, documented
- configurable.*
- D3 — auto lead/tail trim. Trim by default (cleaner timing) vs preserve as recorded. Rec: bounded auto-trim on by default, with an off switch.
- D4 — input format acceptance. Studio always sends opus/webm;
voice importshould accept common formats (wav/mp3/m4a/webm) — confirm the decoder coverage. - D5 — capture UX depth. MVP = record/preview/retake/save + teleprompter. Later: waveform, level meter, punch-in re-record of a phrase. Rec: MVP first.