Skip to content

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.mp3takes.SelectVOvo/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}) + MediaRecorder on 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).

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 slot vo/takes/NN-T.mp3 → 200 with the take path. It then appears in the existing GET …/vo/takes list 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 from 0023 where 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 takes marks 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; --select promotes 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 import should 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.