Code for preventing hallucinations only failed during hallucinations

작성자

카테고리:

← 피드로
DEV Community · orca_forge · 2026-09-03 개발(SW)

📝 Originally published (in Japanese) at forge.workstyle.tech.

Automated Pipeline for Generating Training Corpora for TTS Voice Models

I’ve been operating a pipeline that automatically generates training corpora for voice synthesis models. The process involves having TTS read text, transcribing it with Whisper, comparing the transcription to the script, and saving only the clips that pass the quality check as training material. It’s a straightforward setup.

While running a batch to produce 12 voices, two presenters failed to generate properly.

error: Too many rejected base corpus clips:
       ['base_07', 'base_11', 'base_46', 'base_51', 'base_55']

Enter fullscreen mode Exit fullscreen mode

There were 20 and 18 rejected clips respectively, while other voices had only 0–10 rejections. Something was clearly wrong.

After digging deeper, I discovered that the trimming process I had written to prevent tail hallucinations wasn’t doing anything when hallucinations actually occurred. I hadn’t noticed this issue for months.

Short Sentences Were Being Rejected

When I listed the rejected sentences, a pattern emerged.

base_07 (17 mora): 駅前のカフェで待ち合わせをしましょう。
base_11 (15 mora): 猫が窓辺で丸くなって眠っている。
base_46 (16 mora): えっと、それってどういう意味ですか。
base_51 (14 mora): じゃあ、また明日ここで会いましょう。
base_55 (16 mora): 大丈夫ですよ、気にしないでください。

Enter fullscreen mode Exit fullscreen mode

The base corpus consists of 65 sentences with mora counts ranging from 13 (minimum) to 33 (maximum), with a median of 21. All the rejected sentences were on the shorter end.

I reproduced the issue locally using the presenter’s caption (“clear and expressive speech with emphasis on important points while addressing the audience”) and processed it through Whisper.

Original: 猫が窓辺で丸くなって眠っている。
Transcription: 猫が窓辺で丸くなって眠っている。たふにボンジュそのごう。

Original: 大丈夫ですよ、気にしないでください。
Transcription: 大丈夫ですよ。気にしないでください。プラチュアフォールド・フレンズ

Original: 駅前のカフェで待ち合わせをしましょう。
Transcription: 駅前のカフェで待ち合わせをしましょう。どうしようと不安。

Enter fullscreen mode Exit fullscreen mode

After the script ended, meaningless speech continued. This is a classic case of tail hallucination.

When I tried it twice, the output was identical. Since the diffusion TTS I’m using returns deterministic audio when the caption and seed are the same, this wasn’t a fluke. The same input would always produce the same hallucination.

The Countermeasure Was Already in the Code

The tricky part was that I had already implemented a countermeasure for this phenomenon. The clip generation retry system has three fallback levels:

  1. Generate → Whisper → Compare to script. If it passes, save the clip
  2. If it fails, change num_steps and retry (keeping caption and seed fixed so the voice doesn’t change, only the diffusion trajectory is reset)
  3. If it still fails, trim at Whisper’s segment end and re-evaluate

The third step is specifically designed to handle tail hallucinations.

# Final fallback: Trim the best take at Whisper's segment end and re-evaluate
if best is not None:
    wav, tr, _ = best
    segs = tr.get("segments") or []
    if segs:
        trimmed = verify.trim_to_segment_end(wav, float(segs[-1].get("end", 0.0)))
        ...

Enter fullscreen mode Exit fullscreen mode

def trim_to_segment_end(wav_bytes, end_sec, pad_ms=40, fade_ms=30):
    """Trim at Whisper segment end + padding with short fade (final measure for tail hallucinations)."""

Enter fullscreen mode Exit fullscreen mode

The intention was clear, the implementation worked, and unit tests would pass. Yet the hallucinations weren’t being removed.

segments[-1].end Was the Culprit

When I looked at what Whisper was actually returning, I understood why.

Original: 猫が窓辺で丸くなって眠っている。
Full transcription: '猫が窓辺で丸くなって眠っている。たふにボンジュそのごう。'
Number of segments: 2
  [0] 0.00–3.50s : '猫が窓辺で丸くなって眠っている。'
  [1] 3.50–5.10s : 'たふにボンジュそのごう。'

Enter fullscreen mode Exit fullscreen mode

Whisper was returning the hallucination as a separate segment. The script ended at segments[0] at 3.50 seconds, while the hallucination continued until 5.10 seconds in segments[1].

And my code was using segments[-1].end—which is 5.10 seconds, the very end of the audio.

The trimming wasn’t cutting anything. More precisely, it was only removing trailing silence after the last segment with 40ms of padding left. Since no audio was being removed, the clip would fail again and be excluded.

Another example:

Original: 大丈夫ですよ、気にしないでください。
  [0] 0.00–2.50s : '大丈夫ですよ。気にしないでください。'
  [1] 3.98–5.84s : 'プラチュアフォールド・フレンズ'

Enter fullscreen mode Exit fullscreen mode

The script ended at 2.50 seconds, followed by nearly 1.5 seconds of silence before the hallucination began. Despite this clear separation, choosing the wrong trimming position made the entire countermeasure ineffective.

The frustrating part was that this implementation worked correctly when no hallucination occurred. If there’s only one segment, trimming the trailing silence is a harmless operation. It only fails when hallucinations occur—precisely when the countermeasure is needed.

Find Where the Script Content Ends

The correct approach isn’t to use “the end of the last segment” but to find “where the script content ends.” By summing segments from the beginning and selecting the boundary where the match rate with the script is maximized, we can solve this.

def script_end_sec(script: str, segments: list) -> float | None:
    """Returns the position (in seconds) where the script content ends.

    Since Whisper often splits tail hallucinations into separate segments,
    using the last segment's end would leave the hallucination intact (disabling trimming).
    Instead, we sum segments from the start and choose the position where the match
    rate with the script is maximized.
    """
    best_end, best_ratio = None, -1.0
    acc = ""
    for seg in segments:
        acc += str(seg.get("text", "") or "")
        ratio = judge_transcript(script, acc, min_ratio=0.0, max_inserted=10 ** 9).ratio
        if ratio > best_ratio:
            best_ratio, best_end = ratio, float(seg.get("end", 0.0) or 0.0)
    return best_end

Enter fullscreen mode Exit fullscreen mode

For 猫が窓辺で〜, adding up to segments[0] results in a perfect match with the script (ratio 1.00), while including segments[1] mixes in the hallucination, dropping the ratio to 0.73. The maximum value is selected at 3.50 seconds.

The Full Retry Ladder

Here’s the corrected generation logic. The key points are that there are three fallback levels and each handles different types of failure.

_RETRY_STEPS = [None, 32, 48, 24]   # None=default. Subsequent steps reset diffusion steps

async def make_clip(text, caption, seed, min_ratio):
    best = None                      # Keep the best take
    for steps in _RETRY_STEPS:
        wav = await generate(text, caption, seed, num_steps=steps)
        tr  = await transcribe(wav)
        res = judge_transcript(text, tr["text"], min_ratio=min_ratio)

        # Check for trailing elongation by comparing raw transcriptions
        # (kana normalization discards long vowels)
        if trailing_elongation_mismatch(text, tr["text"]):
            continue                 # Retry immediately if trailing elongation detected

        if res.ok:
            return save(wav, text)

        if best is None or res.ratio > best[2]:
            best = (wav, tr, res.ratio)

    # Final fallback: Trim at where the script ends and re-evaluate
    if best:
        wav, tr, _ = best
        segs = tr.get("segments") or []
        end_sec = script_end_sec(text, segs) if segs else None
        if end_sec:
            trimmed = trim_to_segment_end(wav, end_sec)
            tr2 = await transcribe(trimmed)
            if judge_transcript(text, tr2["text"], min_ratio=min_ratio).ok:
                return save(trimmed, text)
    return None                      # Exclude

Enter fullscreen mode Exit fullscreen mode

The key was preserving the best take. Even if all retries fail, we can apply trimming to the best available take. Without this, we wouldn’t have any material to pass to the trimming function.

The trimming function itself simply cuts at the specified time and applies a short fade.

def trim_to_segment_end(wav_bytes, end_sec, pad_ms=40, fade_ms=30):
    with wave.open(io.BytesIO(wav_bytes)) as w:
        params, sr = w.getparams(), w.getframerate()
        frames = w.readframes(w.getnframes())
    samples = array.array("h"); samples.frombytes(frames)
    end = min(len(samples), int((end_sec + pad_ms / 1000.0) * sr))
    samples = samples[:end]
    # Abrupt cuts cause click noise, so apply 30ms fade-out
    fade_n = min(len(samples), int(sr * fade_ms / 1000.0))
    for i in range(fade_n):
        idx = len(samples) - fade_n + i
        samples[idx] = int(samples[idx] * (1.0 - i / max(fade_n, 1)))
    ...

Enter fullscreen mode Exit fullscreen mode

The 40ms padding ensures we don’t cut off consonant bursts or releases. Without the fade, the cut would produce a “pop” sound that could end up in the training material.

Same Audio, Different Judgment

I tested the fix on the same audio clips.

Sentence Before Fix After Fix 駅前のカフェで〜 ratio 0.82 NG (cut at 5.67s) ratio 1.00 OK (cut at 2.44s) 猫が窓辺で〜(female) ratio 0.73 NG (cut at 5.10s) ratio 1.00 OK (cut at 3.50s) 大丈夫ですよ〜(female) ratio 0.71 NG (cut at 5.84s) ratio 1.00 OK (cut at 2.50s) 猫が窓辺で〜(male) ratio 0.75 NG (cut at 4.92s) ratio 1.00 OK (cut at 3.16s) えっと、それって〜 0.74 NG 0.65 NG 大丈夫ですよ〜(male) 0.70 NG 0.70 NG

Four out of six cases passed after the fix. The remaining two cases had Whisper merging the hallucination into the same segment as the script, so no boundary existed to cut at. This was expected—those clips should simply be discarded.

After deploying the fix and rerunning the actual jobs, here are the results:

Voice Before Fix After Fix Female Presenter error・20 rejections completed・3 rejections Male Presenter error・18 rejections completed・3 rejections

The rejection limit is 3 clips, so they didn’t just barely pass—they passed with room to spare. The effect was even better than expected from the six local tests (four rescued). Nearly all short sentences in the base corpus were saved.

Why Did It Take Months to Notice?

Failures Were Absorbed into “Exclusion”

When trimming didn’t work, the clip was simply rejected and excluded. The pipeline didn’t stop. If only a few clips were rejected, it was easy to dismiss it as “one of those things.”

In reality, narrator-style voices had 0–4 rejections and completed normally. The narrator’s caption (“slow, careful reading of long sentences in a relaxed tone”) rarely triggered hallucinations, so the trimming function wasn’t even needed.

The issue only surfaced when a caption that easily triggered hallucinations (presenter’s “clear and expressive speech with emphasis on important points”) was combined with short sentences. Only when these conditions aligned did the rejections exceed the limit and cause the job to fail.

The Code Didn’t Look Wrong

trimmed = verify.trim_to_segment_end(wav, float(segs[-1].get("end", 0.0)))

Enter fullscreen mode Exit fullscreen mode

Looking at this line alone, I don’t think anyone would immediately recognize it as wrong. The function name, argument types, and intent all seem correct. It wasn’t until I knew that Whisper splits hallucinations into separate segments that I realized using [-1] was a mistake.

I Confused “Implemented” with “Working”

This was the biggest issue. When describing this pipeline, I had written multiple times that “tail hallucinations are removed by trimming.” I wrote the same thing in docstrings. I treated the fact that I had implemented something as equivalent to the fact that it was working.

Fallbacks Rot

In the same batch, I found two other bugs of the same type.

The clip download had no retry mechanism (70 minutes of training material was lost due to a momentary network blip). When retrieving over 200 clips continuously before training, a single transient storage disconnection caused the entire 70-minute production to fail. I only noticed after it actually failed.

The script comparison had an insertion tolerance of only 3 characters. Hallucinations of 3 characters or less would pass and be included in the training material. After training, the model started reproducing short appended sounds like “〜です。ヴェー”, and the size of the reproduced sound matched the size of the gate that had allowed it (the 3-character quality gate had become the model’s catchphrase).

What these three issues have in common is that they aren’t executed or observed as long as the normal path succeeds. The retry mechanism wasn’t triggered until the network blip occurred, the trimming wasn’t needed until the conditions aligned, and the insertion tolerance issue only became apparent after training the model and listening to it.

Fallback code rots the moment it’s written. The normal path is executed hundreds of times with each deployment, but fallbacks may go months without being called. Untested fallback code can’t keep up with changes in the surrounding environment.

How to Verify That Fallbacks Are Working

Here’s what I actually did to verify the fix, written in a reproducible format.

1. Fix Inputs as Fixed Assets

To verify this bug, we need inputs that “always produce hallucinations.” Fortunately, since diffusion TTS is deterministic, recording the combination of caption + seed + text allows us to reproduce the same hallucination anytime.

# Combinations that reliably produce hallucinations (empirically verified)
HALLUCINATION_CASES = [
    ("F", 123456, "駅前のカフェで待ち合わせをしましょう。"),
    ("F", 123456, "猫が窓辺で丸くなって眠っている。"),
    ("F", 123456, "大丈夫ですよ、気にしないでください。"),
    ("M", 864200, "猫が窓辺で丸くなって眠っている。"),
    ...
]

Enter fullscreen mode Exit fullscreen mode

For non-deterministic generation systems, save the audio file itself when hallucinations occur. The important thing is to have the “input that triggers the fallback” on hand, because without it, fallbacks can never be verified.

2. Pass the Same Audio Through Before and After the Fix

for g, seed, text in HALLUCINATION_CASES:
    wav  = gen(text, CAPTION[g], seed)
    segs = transcribe(wav)["segments"]

    old_end = float(segs[-1]["end"])          # Old implementation
    new_end = script_end_sec(text, segs)      # New implementation

    before = judge_transcript(text, transcribe(wav)["text"])
    after  = judge_transcript(text, transcribe(trim(wav, new_end))["text"])

    print(f"{text}")
    print(f"  Old: ratio={before.ratio:.2f} {'OK' if before.ok else 'NG'} (cut at {old_end:.2f}s)")
    print(f"  New: ratio={after.ratio:.2f} {'OK' if after.ok else 'NG'} (cut at {new_end:.2f}s)")

Enter fullscreen mode Exit fullscreen mode

The key was outputting both cut positions. If you only look at the judgment results, it ends with “somehow it’s not fixed.” Only when you see old_end=5.10s / new_end=3.50s side by side do you realize the old implementation was pointing to the end of the audio.

3. Classify Cases That Couldn’t Be Saved

Two out of six cases still failed after the fix. Whether to treat this as “there’s still a bug” or “as expected” changes the response, so I examined the content.

えっと、それってどういう意味ですか。
  Transcription: 'えっと、それってどういうユミですか?えっと、フォロードファンの'

Enter fullscreen mode Exit fullscreen mode

Whisper had merged the hallucination into the same segment as the script. Since no segment boundary existed, this method couldn’t cut it. I decided to “exclude and move on” rather than pursue this further.

When verifying fallbacks, explicitly state the “range that can be saved.” It’s not a failure if not everything is saved. In this case, with a rejection limit of 3 clips, saving 4 out of 6 cases was enough to pass. I made the go/no-go decision for the fix based on that calculation.

4. Mechanically Search for the Same Type of Bug

Using [-1] is dangerous in situations where abnormalities might be mixed at the end of a list. In this case, the countermeasure was using [-1] for a phenomenon where “abnormalities are appended at the end.”

# Search for [-1] in fallback code
grep -rn "\[-1\]" --include=*.py apps/ | grep -iE "fallback|最終手段|last|retry|except"

Enter fullscreen mode Exit fullscreen mode

Similarly, these patterns are worth inventorying:

  • except: pass — swallowing errors silently
  • if not X: return — silently giving up with early returns
  • “last resort” measures outside retry loops

5. Distinguish When Writing Explanations

When writing “we’re removing it with trimming,” distinguish whether this is a verified fact from real data or just a fact that code exists. In this case, it was the latter. Docstrings are the same—writing “does X” in documentation is a specification, not a guarantee that the implementation meets that specification.

I started writing documentation like “verified: 4/6 cases changed from rejection to acceptance (2026-08)” with dates and numbers that can be confirmed. If numbers can’t be written, that means it hasn’t been verified.

Series: Producing Practical Voices from Diffusion TTS

A record of designing voices from a single caption line, manufacturing training corpora, and mass-producing role-specific practical voices. This article is Part 3: Quality Gates.

← Previous: “少々” becomes “しょも” — Permission character list was deleting Japanese
→ Next: The 3-character gate that the quality gate allowed became the model’s catchphrase

Full series (18 parts)

  1. Selected a TTS for audio quality, but it was too slow for conversation
  2. Drawing voices like a gacha
  3. Having machines select “narrator-like voices” from 24 candidates
  4. The stricter the quality gate, the more flat readings survive
  5. Speaking rate can’t be changed after training
  6. TTS that changes “recording location” every time it generates
  7. The roughness of a single clip ruins the entire style
  8. Where did AI pick up the habit of elongating “konnichiwa~”?
  9. “少々” becomes “しょも” — Permission character list was deleting Japanese 10. Hallucination countermeasure code that never fired when hallucinations occurred ← Now reading
  10. The 3-character gate that the quality gate allowed became the model’s catchphrase
  11. Rejecting candidates for fixable defects
  12. There are defects that can’t be found in transcriptions
  13. 70 minutes of training material lost due to a momentary network blip
  14. Changing “ja” to “JP” until the babbling model works
  15. Four registration paths, zero management screens
  16. Deploying would erase each other’s work
  17. Pushing thresholds on things you can’t measure will always fail

The notes that formed the basis of this knowledge are compiled in Diffusion TTS Manufacturing Pipeline: Producing Practical Voices.

원문에서 계속 ↗