Verifying a run
Run both receipts. They answer different questions, and a take can pass one while failing the other in a way that costs a review cycle.
The content receipt
Section titled “The content receipt”fxdub-dialogue scene.json words.json [--only-speaker NAME] [--json out.json]| Check | Fails when | Traces to |
|---|---|---|
line_present:N:SPEAKER |
a scripted line is missing or out of order | the scene script is the contract |
no_invented_speech |
the take contains words no line asked for | reference-mode models reproduce their reference’s dialogue, not just its timbre |
no_overlap |
two lines’ spans intersect | two characters talking over each other is the worst-sounding failure |
no_internal_straggle |
a silence inside one line exceeds its budget | a mid-line pause eats the next character’s slot |
turn_gaps |
a turn change is tighter than min_gap_between_speakers_s |
scene pacing |
one_voice_per_character |
one character is rendered by more than one voice | a character re-cast between renders is not a character |
characters_are_distinct |
two characters share a voice | node-global pitch collapses two speakers into one |
fits_clip |
speech runs past clip_duration_s |
the dub may not outrun the picture |
Reading a failure
Section titled “Reading a failure”$ fxdub-dialogue scene.json words.json --only-speaker VOICE9/10 checks pass| FAIL | no_invented_speech | 4 unscripted word(s): not bad can't complainFour words appear in the VOICE stem that belong to MAC. The stem is contaminated; mixing it produces two men saying the same line a quarter-second apart.
The container receipt
Section titled “The container receipt”fxdub-receipt <run_dir> [--scene SCENE.json] [--bed-gain-db N] [--json out.json]| Group | Checks |
|---|---|
| Deliverables | mix, both stems, three LUFS manifests, caption, dubbed MP4 all present |
| Rates | mix and bed at 48 kHz; VO stem at 48 kHz or a known TTS-native rate; delivered rates agree |
| Loudness | mix within ±2.0 LU of −18.0; dialogue 8–20 LU above the bed |
| Video | the dub carries both a video and an audio track; frames intact; duration matches |
| Caption | the semantic intermediate reached the manifest and is non-empty, and — with --scene — claims as many people as the contract says are visible |
The bed meter needs its gain
Section titled “The bed meter needs its gain”fxdub-receipt runs/my-run --bed-gain-db -12EBU R128 integrated loudness gates a quiet bed out of the mix master entirely, so ducking depth is unmeasurable without a meter on the bed stem — and that stem reads pre-gain. Pass the mix gain you applied, or the separation figure is wrong.
The caption is not decoration
Section titled “The caption is not decoration”fxdub-receipt runs/my-run --scene docs/scenes/night-street.jsonThe pipeline captions a frame and that caption feeds the audio prompt. Nothing ever compared it back to the picture — so a captioner that wrote “two men standing in a city at night, facing each other” over a one-man shot rode through an entire delivered run green.
--scene closes that. The check counts the people the caption claims and compares
it against the cast members the contract marks on_frame. It is deliberately
shallow: it reads "<count> <person-word>" and nothing cleverer, and it stays
silent when the caption never counts people, because silence is not a claim.
A package with no dependencies cannot look at pixels. It does not have to — the contract already states who is in the shot.
Gain-stage from the meter, never from memory
Section titled “Gain-stage from the meter, never from memory”The most expensive mixing mistake available here is reusing a working recipe’s numbers after changing engines.
| VO source | Measured on the same line |
|---|---|
| ElevenLabs | −18.34 LUFS |
| ByteDance | −25.03 LUFS |
6.7 dB apart. Applying the first recipe’s gain to the second stem buries the dialogue — while sample rate, duration and frame count all stay green.
Measure the stem, then compute:
VO gain = target_vo − measured_vobed gain = (target_vo − desired_separation) − measured_bedWorked example from the delivered run: VO measured −25.03, target −18.0, so
+7 dB. Bed measured −17.20, wanted ~11 LU below the VO, so −12 dB. Result:
mix −18.09 LUFS, separation +11.17 LU. Both inside contract, first try.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
every check passed |
1 |
the audio failed its contract — read the receipt |
2 |
the tool could not run |
1 and 2 are deliberately distinct. In CI the first wants its receipt read and
a human to hear the audio; the second means the invocation is wrong and no audio
was ever examined. Collapsing them turns a broken pipeline into a silent green
build.
Errors print the structured shape on stderr:
{ "error": { "code": "words_not_found", "message": "No transcript at 'run/words.json'.", "hint": "Produce one with vo_graphs.transcribe() and save output slot 2 (the word list) via SaveText." }}Pass --debug to re-raise instead of formatting.
The receipt JSON
Section titled “The receipt JSON”--json out.json writes the full result: every check with its ok flag, detail
string and traces_to provenance, plus a measured block with the raw numbers.
That file is designed to be archived beside the run — it is the artifact that lets
someone six months later see not just that a take passed, but what it measured.