← ForeA Technologies Blog index
Field Notes · OpenAudio Suite

The Checkpoint That Never Existed

We spent a day debugging a voice-cloning model that was never going to work — not because of anything we shipped, but because the checkpoint the vendor trained on was still sitting on their laptop.

7 bugs fixed, 1 unfixable · IndicF5 → DhVaani-0.5 · RunPod Flash

We run a voice platform with several self-hosted TTS and cloning models behind RunPod Flash GPU workers — Kokoro, Chatterbox, StyleTTS2, Stable Audio. Every one of them shipped the same way: deploy, hit a wall of environment bugs specific to that model, fix them one at a time, verify with a real generated sample, ship. By the third model we had a genuine playbook for the recurring failure classes on this stack.

So when a user asked for Kannada voice cloning, we reached for ai4bharat/IndicF5 — an 11-language Indic zero-shot cloner on HuggingFace, MIT-tagged, with Kannada listed as a supported language on the model card. It looked like a normal deploy. It was not.

The playbook worked exactly as expected, which was the problem

Every failure fit a pattern we already knew:

Bug 01 · Meta-device crash on load

AutoModel.from_pretrained() wraps construction in an accelerate meta-init context that this model’s custom __init__ wasn’t built to run under. Fix: AutoModel.from_config() instead — no such wrapping, since it’s meant for from-scratch construction.

Bug 02 · The NumPy ABI crash, again

RunPod’s worker harness imports torch for its own GPU health check before your function ever runs, binding it to the base image’s NumPy 2.x. A numpy<2 pin then creates two conflicting NumPy identities. We’d already root-caused this deploying a previous model; the fix (pin numba/scipy instead, leave NumPy alone) transferred over unchanged.

Bug 03 · torchaudio.load() wants a CUDA 13 library that doesn’t exist

The container’s torchaudio defaults to a torchcodec backend that dlopens an NVRTC shared library with no matching PyPI wheel. Fix: monkeypatch torchaudio.load/save to route through soundfile instead.

Bug 04 · flash deploy fails where flash dev didn’t

Dev’s incremental installs tolerate building sdists; deploy’s build enforces a strict wheel-only pip resolution. Three of the model’s transitive dependencies — a Chinese-tokenizer package, a training-config library, an audio codec — had never published a wheel. Direct URL pins to each sdist worked around it, since a URL-pinned requirement bypasses the wheel-only filter entirely.

None of this was surprising. This is what deploying a research model onto infra it was never tested on looks like, and we had a fix for each shape of failure before we’d finished reading the traceback.

Then we found a bug in the model’s own published code

The model’s custom __init__ called the framework’s load_model(DiT, model_cfg, device=device) — no checkpoint path. That function’s ckpt_path parameter is required, with no default, in every release of the underlying framework we checked. This call had never worked. Two lines above it, in a comment, was the ghost of an unfinished migration: a hf_hub_download + load_state_dict path to load weights straight from the published model.safetensors, written and then commented out.

Fix

We finished what the comment started: patched the framework’s model-construction function to build the architecture without a checkpoint, then loaded model.safetensors ourselves once construction returned. Key names lined up 1:1, matched shapes — 447 checkpoint tensors, 447 matched, zero missing, zero unexpected.

A clean weight load. Every layer of the stack checked out. We generated a Kannada sample and sent it over.

“I played it, it’s just noise”

That was the whole message. No stack trace to chase — the pipeline had run cleanly end to end and produced a WAV file with a plausible duration. It just wasn’t speech.

The instinct at this point is to trust the numbers: valid file, correct sample rate, right length for the input text. All of that was true, and none of it was evidence the content was right. We’d built a full acoustic sanity check — RMS, peak, clipping — and every one of those checks is measuring the audio’s shape, not whether a human would recognize it as language. A statistically normal-looking waveform of pure static passes an RMS/peak check exactly as well as real speech does.

Acoustic sanity checks verify structure. Only listening — or a content-aware check — verifies content. We had been reporting synthesis pipelines as “verified” from stats alone; this was the run that made that stop.

First real bug this surfaced: the model’s forward pass returns raw int16 PCM samples via pydub, not normalized float audio. We were casting straight to float32 and writing it, which soundfile treats as already-normalized [-1, 1] — so a sample value of 32767 gets treated as 32767× full scale and clipped flat. Fixed the normalization, regenerated, and it was still wrong. Quieter now, technically not clipping, but still not Kannada. Still not anything.

Ruling things out one at a time, in the one language it should have been easiest in

The zero-shot cloning contract here is language-agnostic between the reference clip and the target text by design — that’s the whole feature. So we stopped testing Kannada and tested English against an English reference, the simplest possible case for this architecture. Still garbled. That single test eliminated an entire category of hypothesis: nothing about Indic-script tokenization could be the culprit, because English text never touches that code path.

From there it was systematic:

Check 1

Captured the model’s own preprocessed reference audio (post silence-trim, pre-synthesis) and played it directly. Perfect, clean English. The reference pipeline wasn’t the problem.

Check 2

Instrumented the vocoder’s decode() call to log the generated mel spectrogram and output waveform: shape correct, no NaNs, mean/std/range all inside plausible bounds for real speech. Statistically normal, semantically empty.

Check 3

Dumped the tokenizer’s actual vocabulary mapping. 2,545 tokens, space at index zero, ASCII letters mapped where expected. Nothing wrong with the text encoding either.

Every individual layer of the system was demonstrably correct. The output was still noise. At that point the only remaining explanation was the one we’d been avoiding: the weights themselves.

The vendor’s own GitHub issues were the evidence

We checked the model’s GitHub repo. Twenty-eight open issues. Several were the exact bugs we’d already independently found and fixed — the missing checkpoint argument, the meta-device crash, reported by other users months earlier with no response.

Then one open, unmerged pull request against the training repo’s infer_gradio.py made the actual story explicit:

def load_f5tts(ckpt_path=str(cached_path( "hf://.../F5TTS_Base/model_1200000.safetensors"))): ... ckpt_path = "/home/tts/ttsteam/repos/F5-TTS/runs/indic_5/model_1176000.pt" vocab_path = "/home/tts/ttsteam/repos/F5-TTS/runs/indic_5/vocab.txt"

The function’s real default argument pointed at a generic, non-Indic base checkpoint. The function body then silently overwrote it with an absolute path on the author’s own training machine — a file that has never been published anywhere: not on HuggingFace, not in the git repo, not anywhere reachable by anyone outside that one workstation. The load_checkpoint() call that would have applied it was also commented out in their released inference code.

One more open issue sealed it. Someone had independently hit the exact same idea we had — substitute the public model.safetensors for the missing .pt file — and reported back:

“i couldnt find this file in your project, and i replaced with model_1200000.safetensors location in my local system. im getting output audio in some other language.”
Root cause

The model’s real trained checkpoint was never released. The public weights are not it. Every one of our seven infrastructure fixes had been correct. The model was never going to speak, because the only copy of it that could was still on someone’s laptop.

Ninety minutes to a working replacement

We found ARTPARK-IISc/DhVaani-0.5 — a 27-language Indic zero-shot cloner from the same lab that already provided our transcription model, Apache-2.0 down to its vendored backend license file, with a much more legible reference implementation: normal from_pretrained() weight loading, an explicit note in its own requirements file pinning transformers<5 because “5.x meta-init not yet supported” — the exact bug class we’d just spent hours diagnosing, already known and worked around by its maintainers.

The second deployment took a fraction of the time, because the failure patterns transferred directly: the torchcodec/NVRTC gap, same fix, minutes. The wheel-only deploy resolution, same fix, minutes. Two missing transitive dependencies, found and added. We generated real audio across Kannada, Tamil, Telugu, Bengali, Marathi, and Malayalam, cloning from a single English reference clip each time, and played every one of them before calling it done. All eight languages tested, including English and Hindi, came back clear and intelligible.

What we’re changing about how we ship these

Acoustic stats are necessary, not sufficient. Duration, sample rate, RMS, peak, clipping — all of it verifies that a pipeline ran without crashing. None of it verifies the output means anything. A synthesis pipeline is not “verified working” until a human, or a content-aware check, has confirmed what came out is actually the thing you asked for.

A vendor’s open issues are forensic evidence. Before assuming your integration is at fault, read the model’s own bug tracker. Unmerged PRs and unanswered issues are often a complete, dated record of exactly which parts of a release never worked — for anyone, including the people who trained it.

strict=False hides total failure and partial mismatch behind the same code path. Calling load_state_dict(..., strict=False) and not inspecting the returned missing_keys/unexpected_keys means a checkpoint that silently failed to load a single tensor and one that failed to load every tensor look identical from the call site. Always check the counts.

Early-binding imports make a monkeypatch order-dependent. from module import function binds a name at import time, not call time. Patching the module attribute afterward is a silent no-op if that import already ran — which, on a warm worker process reused across requests, it usually already has. Evict from sys.modules when a patch needs to apply to code that might already be cached.

A permissive dev loop can hide a strict deploy failure, and commented-out loading code is a release-completeness signal — a model’s own reference implementation with dead, half-finished checkpoint-loading logic still in the file is not cosmetic. It’s a direct sign the release was never run end-to-end by the people who shipped it.

Every fix here came from an actual traceback, checked against the real infrastructure, never guessed at from a hunch — and the one failure no fix could touch turned out to be documented, in public, by the vendor’s own contributors, months before we ever opened the repo.