Generative Pipelines Need a Harness: Seven Silent Failures From Building an AI Melodrama Channel
I’ve been building krama.tv, a channel of AI-generated Khmer-language vertical micro-dramas — 90-second melodramas, phone-shaped, each episode in two languages. It’s a solo project, mid-build. The pipeline is unremarkable on paper: a script in YAML, narration cloned locally by a voice model (VoxCPM2), picture generated by paid video models (Veo, Kling, Wan) through fal.ai at $0.084–$0.20 per second, and an assembled MP4 with score, subtitles, and lip-sync at the end. The first episode came in around $52 against a $60 hard stop. Everything is versioned in git — scripts, shot lists, pacing files, the cost ledger.
The reason is plain enough: I think there should be content made for the Khmer diaspora, and content that puts native Khmer people, figures, and voices on screen — and there isn’t much of either. That’s the gap the project exists to fill.
What’s been remarkable is how it fails.
In software we have types, tests, CI, diffs, code review — decades of machinery whose entire job is to make failure loud. A generative pipeline ships with none of that, and the unifying lesson up front: the characteristic failure of a generative pipeline is not a crash — it is plausible output. A wrong reference transcript doesn’t throw. A script that halves your audio on the second run prints a cheerful summary. A voice that is a different person in every build still says the lines. Every failure below is real, measured, and exited zero. This post is the harness I had to build, because nobody ships one for this.
rejects it"] --> S3["build goes red
you cannot ship"] end subgraph GE["Generative — failure is plausible"] direction LR G1["transcript no longer
matches the clip"] --> G2["clone degrades
slightly"] --> G3["exit 0
it ships"] end SW ~~~ GE style S3 fill:#14532d,stroke:#22c55e,color:#ffffff style G3 fill:#7f1d1d,stroke:#ef4444,color:#ffffff
Failure 1: The reference audio and its transcript drifted apart — and shipped
Voice cloning needs two inputs: a reference clip, and a transcript of what it says. My voices.yaml records both per character, plus the take number and measurements. At some point I re-locked a clip — chose a new take — and didn’t update the YAML. For a full day, every cloned line for that character generated against a transcript belonging to a different take. Nothing errored. The clone just got quietly worse.
There was also a dumber cousin of the same drift: the same file described twice in one config, an operative soriya_en: entry and a documentation soriya_en_lock: block at the bottom. The lock block was correct all along. The code read the stale one.
When I finally added a check — record a sha1 beside every locked clip and re-hash it on every lint — it found two more entries in the same state. Both had already shipped in an approved English cut. Their recorded pitches were out by 68 Hz and 57 Hz.
node bin/sreytv.js lint pg-ep01 # re-hashes every locked clip, fails on drift

That red line is the entire point of this post. It is the only way I have of learning that a generator is being fed something other than what its config claims — and before I wrote it, the same condition produced a finished, shippable, slightly-worse episode instead.
Lesson: content-address every asset a generator depends on. The hash check is the only thing in this repo that fails loud, and it caught three drifted references — two of them already in an approved cut.
Failure 2: A character who was a different person in every build
If a character has a locked reference clip, the voice model clones it. If not, it silently falls back to sampling a fresh voice from a text description — on every single run. Nothing tells you which mode you’re in.
One character shipped that way. Measured across four lines in one build, the voice ranged 120–188 Hz. In an earlier English read, 24 of 41 lines came from a different synthetic person. The note that surfaced it: “her English voice doesn’t sound natural” — which understates it. It wasn’t a bad performance. It was no performance at all — there was no consistent “her” to perform.
The fix is one sentence in voices/README.md that took a while to earn: a description is casting; a clip is the cast. The lint now reports any speaker in the script with no locked clip, because that character is being re-cast from scratch on every build.
Lesson: determinism is opt-in, and the sampling default still produces finished-looking output. Anything that must persist across builds needs a pinned artifact, not a prompt.
Failure 3: The re-timing script halved my audio and summarized it politely
A script that trims line timings to match the narration audio rewrote the timing data it had just read. Run once: a 130.8-second read trimmed to 97.6 seconds. Run twice — and why wouldn’t you re-run a build step — and it measured the already-shortened durations against the untrimmed audio and kept about half of every line: 69.4 seconds. No error. A plausible summary line both times.
The same script had a second trap: it defaulted its episode argument to ep01. Running it for a second series applied series 1’s pacing file — 11 timing holds keyed to a completely different script — and cheerfully reported “11 beats held”. Every assembly before that fix was paced by the wrong show’s file.
Lesson: when every output looks plausible, idempotence and required arguments stop being style points. A default that selects a plausible wrong input is worse than an error.
Failure 4: I mixed two measurement scales and diagnosed with total confidence
To compare voices, I measure them — pitch, and 99% spectral rolloff as a proxy for how bright or muffled a clip is. Two scripts in the repo measured that same property with different methods, and their numbers run roughly 2× apart on identical audio. I compared a number from one against a number from the other and produced a confident, fully wrong diagnosis: a take declared “muffled at 6105” that, measured consistently, was 12727 — wider than the clip I was holding up as better.
And even with one method, the metric was measuring the wrong thing. Rolloff turned out to be substantially a property of the sentence, not the voice — a line full of sibilants measures bright, a nasal line measures dark, same speaker. Cloning one reference across three lines gave 4767 / 5840 / 5276. On one short nasal line, every reference collapsed to 3400–4200, including one that measured 8479. I had been comparing characters on numbers derived from different audition sentences — that is, comparing the sentences.
Lesson: one measurement method, or no comparison. And before trusting a metric, check whether it’s a property of the artifact or of the input you happened to feed it.
Failure 5: I concluded from one sample, twice, and once wrote the conclusion before the data
This one is entirely me. A single clone test said “the reference clip’s bandwidth doesn’t transfer to the clone.” At n=4 per cell, it clearly did:
wide reference → clone median 6563
narrow reference → clone median 4681
narrow reference → clone median 4948
One sample reversed the conclusion. Then I did it again in the same sitting: I had a hypothesis — higher-pitched voices come out narrower — and wrote it into the output text before the correlation came back. Actual correlation: r = −0.03. Zero. I had to go back and correct it explicitly.
The uncomfortable part is that these have exactly the same shape as the pipeline’s failures. I generated a plausible conclusion and didn’t check it. The tooling wasn’t the only unharnessed generator in the room.
Lesson: generative output is a distribution, not a value — n≥4 before you conclude anything. And write the number down before you write the sentence about it.
Failure 6: My pass/fail filter deleted the best voice in the project
A casting tool generated 18 voice candidates and auditioned only the 3 that matched the target spec. The other 15 were never played to a human. When I finally listened, the verdict was “they all sound clean” — and the best-fitting take for the stated brief got rejected by ear as “more of a 40–50 year old.” It became the project’s first working-age male voice in either language, a roster slot that had sat empty. A pass/fail sieve would have deleted it. In a roster round, out-of-spec isn’t a reject; it’s a different category.
The same tool committed a second destruction: it wrote <archetype>-d1t01.wav on every run, so the second run silently overwrote the first run’s candidates — including takes I had already listened to and described. Generation is cheap. A human opinion about a specific artifact is the scarce resource, and that’s what got destroyed. Every run is namespaced now, and the tool refuses to reuse a namespace.
Failure 7: The metrics were unanimous, and the ear was right anyway
One clip measured best on every axis I track — 10957 Hz cloned bandwidth, pitch stable within ±3 Hz. I rejected it in seconds: it carried an English accent into Khmer. No metric in the repo can see an accent.
Perceived age fooled the numbers the same way. The take that read as oldest was 96 Hz and raspy; two cleaner takes at 104 and 107 Hz read as middle-aged. Age is texture, not pitch — so faking depth by pitch-shifting a clip down was always going to fail, and it did: shifting drags the formants down too, and the result got described as “like a villain,” “not real.” DSP could not manufacture a person.
Lesson: the human is the gate; metrics are only a sieve. Use numbers to order what a human listens to — never to decide instead of them.
What I’d tell you to steal
- Content-address your references. A hash beside every asset a generator depends on, verified on every build, is the cheapest loud failure you can buy. (Gotcha: quote the hash — an all-digit hash parses as a YAML number and reads as missing.)
- Generated files must be generated. Every hand-typed measurement in my config was wrong within a month. The voice catalogue is now regenerated by a script with a
--checkmode that fails when it’s stale.

- One measurement method, or no comparison. Mixing two produced my most confident wrong answer.
- n≥4 before you conclude anything. Single samples reversed two of my conclusions in one day.
- Measure the note before fixing it. Every vague human note had a countable cause: “choppy” was a 3.4-word mean sentence length; “it doesn’t look like her” was a shot with an empty reference list — no reference had reached the model at all. A symptom fixed without its measurement comes back.
- Never destroy a reviewed artifact. Namespace every run. Regenerating output is cheap; regenerating a human’s opinion of a specific take is impossible.
- Separate drafting from judging, and cap the loop. The worst judge of a draft is whatever just wrote it — true of my scripts, my subagents, and me. My review loop runs two automatic rounds, then a human decides, because an auditor always finds something and a draft always looks finished to the thing that made it.
The lifecycle doc in the repo now calls this apparatus a GCLC — a generative content lifecycle, the SDLC’s counterpart — with one inversion that makes it its own discipline rather than SDLC with new nouns: in software the deploy is cheap and reversible; here, generation is the only irreversible step, so the entire lifecycle is fences built in front of it.
requirements"] --> P["scene plan
design"] P --> R["drafts
dev builds"] R --> L["lint + rubrics
CI"] L --> A["two audit passes
code review"] A --> G["gates
release approval"] G --> X["generation
$0.084–$0.20 per second
the only irreversible step"] style X fill:#7f1d1d,stroke:#ef4444,color:#ffffff style L fill:#1e3a5f,stroke:#60a5fa,color:#ffffff style A fill:#1e3a5f,stroke:#60a5fa,color:#ffffff style G fill:#1e3a5f,stroke:#60a5fa,color:#ffffff
Everything blue is a fence. In an SDLC they’d be spread either side of the deploy, because you can roll a deploy back. Here they all crowd in front of the one step that spends money and cannot be undone.
This is one person’s pipeline, not a theory of AI. But if you’re building on generative models and your tooling has never once stopped you with an error, that’s not a sign it’s working. It’s a sign you can’t yet tell when the plausible thing is wrong.
The channel itself is at krama.tv — very much a work in progress. But it’s live, and the voices in it are the same people from build to build now, which is the harness doing its job.