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 Mgenerates M candidates intovo/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 tovo/NN.mp3(R-GEN-9).reel makeauto-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 invo/takes/.--line Nstays 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 Nis 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 jsonsurfaces 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-6parsing + that only those lines' take slots change (fake TTS).voice takesJSON 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,
--bestpicks 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 existingvoice gen(one mental model),--line Nkept as the single-line alias. - D2 (was Q2) — phasing: subset re-roll +
voice takeslisting first (R-GEN-36/37, MUST); stilt screening is a fast-follow (R-GEN-38/38b/39) once the silence-detection seam exists.voice takeswith 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
ListVOTakescore exists yet. So this spec's CLI adds subset re-roll + a headless take listing (the newListVOTakescore benefits both CLI and studio) + screening; it does not build a CLI audition UI (that's the studio's job).