Skip to content

0023 — Voice take workflow: subset re-roll + take screening

Status: IMPLEMENTED — R-GEN-36/37 (subset re-roll + voice takes listing) landed 2026-07-02; stilt screening (R-GEN-38/38b/39 — voice takes --screen, voice pick --best) landed 2026-07-03 on the optional provider.SilenceDetector seam (ffmpeg silencedetect; afmpeg degrades to duration-only ranking). Date: 2026-06-29 Source: REPORT-keryx-in-anger-afmpeg-reel.md (request F, pinch point #6). Related: 0022 (voice configurability), 0025 (locked VO).

1. Goal

Killing a stilted VO read meant a curl loop generating N takes per line and an ffmpeg silencedetect script to screen them — none of it expressible in keryx. Make the "re-roll these few lines and let me choose the best" loop first-class.

2. What already exists (do not rebuild)

Verified 2026-06-29 (the report ran an older binary):

  • keryx voice gen --workspace <slug> --line N --takes M generates M candidates into vo/takes/NN-T.mp3 (R-GEN-6/7).
  • keryx voice gen --text "…" --out <path> is the standalone single-line synth the report praised and asked to "first-class" — it already exists and honours the theme voice.
  • keryx voice pick <line> <take> promotes a candidate to vo/NN.mp3 (R-GEN-9).
  • reel make auto-generates + auto-picks take 1 and skips lines that already have a selection (resume-idempotent).

So the missing pieces are (a) re-rolling a subset of lines in one invocation, and (b) screening takes for stiltedness — not the single-line or per-line basics.

3. Design

3.1 Subset re-roll

Let one invocation target several lines:

  • keryx voice gen --workspace <slug> --lines 3,5,7 --takes M (and a range form --lines 3-6). Each listed line gets M fresh candidates in vo/takes/. --line N stays as the single-line alias.
  • Pairs with voice pick <line> <take> per line to choose. (Locking a chosen take is spec 0025.)

3.2 Take inspection

To choose without leaving the CLI you need to see what's there:

  • keryx voice takes --workspace <slug> [--line N] lists candidate takes per line with duration and (when screening is on) a stilt score + the selected marker. Text + JSON.
  • Actual listening stays out-of-band (or the studio) — keryx surfaces the metadata that ranks them.

3.3 Take screening (stilt detection)

The reads that fail are over-paused / stumbling ("ef… ef… um-peg"). The observable symptoms are spurious internal pauses, syllable restarts, and — as a consequence — a take that runs longer than a clean read of the same line. Screening triages those so the human can skip listening to every take; it is a heuristic aid, never an auto-decision (the human still picks — --best is the only path that acts on the score, and only when asked). Two signals with two distinct jobs:

(a) Within-line ranking — a combined score. For a line's M takes, rank each by a blended score over two normalised components:

  • Duration deviation from the line's take median — weighted asymmetrically: a longer-than-median take is the stumble signal and penalised most; a much shorter take is penalised too (likely a dropped word), but less. Self-calibrating per line — no ground-truth pause count needed.
  • Silence excess — detected internal silence (a silencedetect-style pass) minus the scripted-pause allowance (the line's / <break> tags), so a deliberately dramatic read isn't flagged for pauses the author asked for.

The blend uses documented, adjustable weight constants. To keep it from becoming an untrustworthy magic number, voice takes --output json surfaces the per-component sub-scores, not just the blended total — you (and we, during calibration) can see why a take ranked where it did.

(b) Whole-line flag — the uniformly-bad case. Within-line ranking is blind when every take is bad (a genuinely hard line — no in-cluster outlier). So a line whose takes as a set exceed an absolute stilt threshold (excess silence and/or duration-per-character) is flagged "all takes look stilted — reword/respell" — more actionable than ranking, since the fix is upstream (a respelling or <break> edit), not another re-roll.

  • The silence analysis reuses the render backend (afmpeg/ffmpeg) — no new dependency.
  • Ellipsis/<break> awareness is only a refinement to the silence thresholds (subtract scripted pauses), not the primary ranking mechanism — a plain-prose line with no ellipses must still screen correctly on duration deviation alone.

4. Requirements

  • R-GEN-36 (MUST) voice gen --lines <list|range> re-rolls exactly the named lines (M takes each), leaving other lines' takes and selections untouched (--line N is the single-line alias).
  • R-GEN-37 (SHOULD) voice takes [--line N] lists candidates with duration + selected marker (text + JSON).
  • R-GEN-38 (SHOULD) Within-line take screening ranks each candidate by a combined score over duration deviation from the line's take median (asymmetric — long penalised most) and silence excess (detected internal silence minus scripted /<break> pauses). The blend uses documented weight constants; voice takes --output json surfaces the per-component sub-scores alongside the total. Ranking must work on a plain-prose line (no ellipses) on duration deviation alone.
  • R-GEN-38b (SHOULD) A line whose takes as a set exceed an absolute stilt threshold (excess silence and/or duration-per-character) is flagged "all takes look stilted — reword/respell" in voice takes, distinct from per-take ranking.
  • R-GEN-39 (MAY) voice pick --best <line> promotes the top-ranked take; screening never auto-promotes otherwise.

5. Testing (TDD)

  • --lines 3,5,7 / --lines 3-6 parsing + that only those lines' take slots change (fake TTS).
  • voice takes JSON lists the right candidates + durations (fake renderer probe).
  • Stilt scoring (combined): synthetic takes with known durations + detected-silence counts yield the expected within-line ranking; the long outlier ranks last, --best picks the top. A plain-prose line (zero scripted pauses) still ranks on duration deviation alone. Per-component sub-scores appear in the JSON. Silence detection is faked at the seam.
  • Whole-line flag: a line whose takes all exceed the absolute threshold is flagged reword/respell; a line with one clean take is not.

6. Resolved decisions

Reviewed with Matt 2026-06-29:

  • D1 (was Q1) — subset surface: --lines <list|range> on the existing voice gen (one mental model), --line N kept as the single-line alias.
  • D2 (was Q2) — phasing: subset re-roll + voice takes listing first (R-GEN-36/37, MUST); stilt screening is a fast-follow (R-GEN-38/38b/39) once the silence-detection seam exists. voice takes with duration is already useful for eyeballing before any screening lands.
  • D4 (2026-07-02) — stilt signal: ranking is a combined score (duration deviation + silence excess), not raw silence-count-vs-ellipses — the ellipsis count is too fragile a reference (over-flags clean prose) to be primary; it survives only as a threshold refinement. A separate whole-line "all takes stilted" flag (R-GEN-38b) catches the uniformly-bad line that within-line ranking can't. Sub-scores are surfaced so the blend stays auditable.
  • D3 (was Q3) — the studio already covers the "choose" half. Spec 0016 (studio audio takes) already provides generate / audition / pick over the worktree, and notes no ListVOTakes core exists yet. So this spec's CLI adds subset re-roll + a headless take listing (the new ListVOTakes core benefits both CLI and studio) + screening; it does not build a CLI audition UI (that's the studio's job).