Dropping Candidates Due to Fixable Defects โ€” A Story of Measuring Factory Flaws as Product Personality

์ž‘์„ฑ์ž

์นดํ…Œ๊ณ ๋ฆฌ:

โ† ํ”ผ๋“œ๋กœ
DEV Community ยท orca_forge ยท 2026-09-03 ๊ฐœ๋ฐœ(SW)

๐Ÿ“ Originally published (in Japanese) at forge.workstyle.tech.

I Built a System to Mechanically Select Voice Model Candidates

I created a system to mechanically select from voice model candidates. It reads probe sentences with 24 candidate voices, automatically measures and scores them based on Whisper match rate, vowel elongation, speech rate, intonation, and jitter, then selects the top candidates for manual review.

The system allows weighting to be adjusted based on role. For narrators, slower speech rates are given positive weight, while for MCs, wider intonation ranges are favored. As a common penalty, Whisper match rate was weighted between 1.0 and 1.2. Candidates that couldn’t read the script accurately were eliminated. This seemed reasonable at the time.

It turns out this was fundamentally wrong in terms of metric design.

There Was a Role with Abnormally Low Match Rates

When generating candidates for presenters, the match rate was significantly different. While other roles had rates of 0.93โ€“1.00, presenters only achieved 0.70โ€“0.93, with greater variability in speech rate.

Upon inspection, hallucinations appeared at the end of generated speech:

Original: ใชใœใ ใจๆ€ใ„ใพใ™ใ‹ใ€‚
Transcription: ใชใœใ ใจๆ€ใ„ใพใ™ใ‹?ใใ†ใ !ใ“ใ‚Œใซไผผใฆใ‚‹ๅ††ๅฝขใจๆ€ใ„ใพใ—ใŸ

Original: ใ”ๆธ…่ดใ€ใ‚ใ‚ŠใŒใจใ†ใ”ใ–ใ„ใพใ—ใŸใ€‚
Transcription: ใ”ๆธ…่ดใ‚ใ‚ŠใŒใจใ†ใ”ใ–ใ„ใพใ—ใŸใ€‚ๅ…ƒๆฐ—ใ‚’ใ”ใ–ใ„ใพใ—ใŸใ€‚

Enter fullscreen mode Exit fullscreen mode

When short sentences (9โ€“14 mora) were combined with emphasis captions (“with good pacing, emphasizing important points”), extraneous speech was added. When the probe sentences were rewritten to 18 mora or longer, the match rate returned to 0.93โ€“0.96, and the top ranking changed.

Initially, I thought “the measurement was broken and needed fixing.” But the fact that rankings changed was concerning. If a measurement error could change the top ranking, that meant the metric was effectively determining candidate superiority. So what exactly was this metric measuring?

Revisiting the Pipeline Structure

This system uses two different engines:

[Design Phase] Diffusion TTS โ†’ Generate voice with caption+seed โ†’ Training corpus (~200 samples)
                                                      โ†“
                                                    Training
                                                      โ†“
[Runtime] Trained model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Actual voice heard by users

Enter fullscreen mode Exit fullscreen mode

Diffusion TTS is specialized for voice design. The voice is determined by the caption and random seed, producing identical voices from the same combination. However, generation is slow (RTF 0.25โ€“0.54) and unsuitable for conversation. Therefore, the generated audio is used as training material to bake a lightweight model, which handles runtime playback.

This means hallucinations occur in the diffusion TTS stageโ€”the corpus creation processโ€”while users actually hear the trained modelโ€”the product. These two are different things.

Do Trained Models Hallucinate?

I prepared six short sentences that always produced hallucinations in diffusion TTS and trained three models from them.

Model Hallucinations Female Presenter 0/6 Male Presenter 0/6 Female MC 0/6
Diffusion TTS:       "ใชใœใ ใจๆ€ใ„ใพใ™ใ‹?ใใ†ใ !ใ“ใ‚Œใซไผผใฆใ‚‹ๅ††ๅฝขใจๆ€ใ„ใพใ—ใŸ" (transcription length 2.9x)
Trained model:       "ใชใœใ ใจๆ€ใ„ใพใ™ใ‹" (ratio 1.00)

Enter fullscreen mode Exit fullscreen mode

With the same 9-mora sentence, one version fabricates content while the other is clean. Hallucinations are a defect of the manufacturing process and don’t appear in the product.

We were rejecting candidates for a fixable flaw. Worse, the flaw doesn’t even exist in the product.

What the Metrics Were Actually Measuring

The scoring formula looked like this:

Score = 1.0 ร— match_rate โˆ’ 0.25 ร— vowel_elongation โˆ’ 1.0 ร— speech_rate_cv โˆ’ 2.0 ร— jitter ...

Enter fullscreen mode Exit fullscreen mode

I thought match rate measured “whether the voice reads the script accurately.” In reality, it measured “whether this caption+seed combination is less likely to hallucinate with short sentences.” This was a measure of process state, not voice quality.

Worse, hallucinations contaminated speech rate measurements. Added speech extended the duration, making mora/second appear slower than actual. After fixing the probe, speech rate changed from 4.0โ€“6.3 โ†’ 5.2โ€“7.2 mora/second, converging to the target range. A single defect was distorting multiple metrics.

What was the result? The top female presenter candidate was seed 864200, but after fixing the probe, seed 123456 rose to first place. Seed 123456 had an intonation range of 26.0st, significantly larger than other candidates (11.7โ€“18.3st). The candidate with the strongest “emphasis variation” needed for presentations was ranked third due to hallucinations.

Searching for Reasons to Keep the Metric

At this point, I considered:

In presenter roles, short emphasis phrases are spoken during actual operation. So isn’t it valuable to select voices that are less likely to hallucinate with short phrases?

This seemed plausible but didn’t hold up. In actual operation, the trained model is used, where hallucinations don’t occur. The property “weak with short phrases” doesn’t exist in the product.

Realizing this made me understand that I didn’t want to abandon the metric. I was trying to justify the system I’d built.

However, “Not Inherited” Was an Overstatement

The story continues. If trimming is used to remove hallucinations before training, hallucinations shouldn’t appear in the corpus at all. So were we training with hallucination-containing samples?

I re-examined the script matching code:

def judge_transcript(script_text, transcript,
                     min_ratio=0.40, max_inserted=3):
    """Match script with transcription. Reject if insertions (text not in script) > max_inserted."""

Enter fullscreen mode Exit fullscreen mode

Insertions of up to 3 characters are accepted. Insertions of 4+ characters (e.g., “ใŸใตใซใƒœใƒณใ‚ธใƒฅใใฎใ”ใ†ใ€‚” = 11 insertions) are rejected, but insertions of 3 or fewer characters are included in the training material.

When these were read by the trained model, the following occurred:

ใคใพใ‚Šใ€ใ“ใ†ใ„ใ†ใ“ใจใงใ™ใ€‚       โ†’ "ใคใพใ‚Šใ€ใ“ใ†ใ„ใ†ใ“ใจใงใ™ใ€‚ใƒดใ‚งใƒผ"   (2 insertions)
ๅคงไธˆๅคซใงใ™ใ‚ˆใ€ๆฐ—ใซใ—ใชใ„ใงใใ ใ•ใ„ โ†’ "ๅคงไธˆๅคซใงใ™ใ‚ˆใ€‚ๆฐ—ใซใ—ใชใ„ใงใใ ใ•ใ„ใ€‚ใ„ใ‚„ใ€‚" (1 insertion)

Enter fullscreen mode Exit fullscreen mode

The size of what passed the gate matched the size of added speech reproduced by the model.

Further inspection using waveform envelopes revealed defects not visible in STT-dependent inspection (there are defects transcription doesn’t catch):

ใ”่ฆงใใ ใ•ใ„ใ€‚   Original 0.88s โ†’ Silence 0.48s โ†’ [0.16s speech]   STT: "ใ”่ฆงใใ ใ•ใ„ใ‚ใ‚"
ใ“ใกใ‚‰ใงใ™ใ€‚     Original 0.72s โ†’ Silence 0.56s โ†’ [0.28s speech]   STT: Not captured

Enter fullscreen mode Exit fullscreen mode

Speech separated by 0.5 seconds of complete silence cannot be a trailing reverberation. “0/6 didn’t carry over” only meant it didn’t happen in those six sentencesโ€”we hadn’t confirmed it was sentence-dependent. Even though I’d observed “ใƒดใ‚งใƒผ” myself, I’d written it off as “minor noise” and generalized from there.

The correct understanding is:

Therefore, removing match rate from selection metrics is correct, and simultaneously “tightening manufacturing gate tolerance” is also correct. Two separate layer issues were overlapping.

Fix 1: Trim Before Measurement

On the selection side, hallucinations are removed before metrics are measured. Just changing the order:

# Before: Generate โ†’ Measure
wav = gen(text, caption, seed)
ratio = judge_transcript(text, whisper(wav)).ratio   # Value including hallucinations

# After: Generate โ†’ Trim hallucinations โ†’ Measure
wav  = gen(text, caption, seed)
segs = transcribe(wav)["segments"]
end  = script_end_sec(text, segs)          # Position where script ends
wav  = trim_to_segment_end(wav, end) if end else wav
ratio = judge_transcript(text, whisper(wav)).ratio   # Only voice properties remain
speech_sec, level, f0, range_st = acoustics(wav)     # Speech rate & F0 not contaminated

Enter fullscreen mode Exit fullscreen mode

Now match rate reflects only “sentences that truly can’t be read, even after trimming.” As a side effect, speech rate contamination also disappears because added speech no longer inflates duration.

At the same time, we no longer need to worry about probe sentence length. Before the fix, we had a warning in the screener: “Don’t use probes shorter than 15 mora as they invite hallucinations,” but if trimming works, that constraint isn’t needed. Getting rid of a workaround was surprisingly significant.

Fix 2: Tighten Manufacturing Gate Tolerance

Reduce insertion tolerance from 3 to 1. However, doing this directly would reject normal clips, so we first need to absorb notation variations.

Long sounds are just notation differences

This insight was key:

Script: ใตใตใฃใ€ไปŠๆ—ฅใฏๆœ€้ซ˜ใซๆฅฝใ—ใ„ไธ€ๆ—ฅใงใ—ใŸ๏ผ
Transcription: ใตใฃใตใฃใ€ไปŠๆ—ฅใฏๆœ€้ซ˜ใซๆฅฝใ—ใ„ไธ€ๆ—ฅใงใ—ใŸ!     โ†’ 2 insertions

Script: ใฉใ€ใฉใ†ใ—ใ‚ˆใ†ใ€‚่ƒธใŒใ–ใ‚ใคใ„ใฆโ€ฆ
Transcription: ใฉใฃใฉใ†ใ—ใ‚ˆใ† ่ƒธใŒใ–ใ‚ใคใ„ใฆโ€ฆ            โ†’ 2 insertions

Enter fullscreen mode Exit fullscreen mode

Both are just differences in whether the same sound is written as ใฃ or ใ€. Existing normalization dropped punctuation but kept long sounds, leaving characters on one side that were counted as insertions. Now we also drop long sounds during comparison.

_PUNCT_RE  = re.compile(r"[ใ€ใ€‚๏ผ๏ผŸ!?โ€ฆใƒป\sใ€Œใ€ใƒผใ€œ,\.]")
_REPEAT_RE = re.compile(r"(.)\1+")
_SOKUON_RE = re.compile(r"[ใฃใƒƒ]")        # Added

def _collapse(s):
    return _REPEAT_RE.sub(r"\1", _SOKUON_RE.sub("", _PUNCT_RE.sub("", s or "")))

Enter fullscreen mode Exit fullscreen mode

“ใตใตใฃ” โ†’ “ใตใต” โ†’ (repetition compression) โ†’ “ใต”, “ใตใฃใตใฃ” โ†’ “ใตใต” โ†’ “ใต”. They match.

We also verify that unwanted items remain. “ใ€œใงใ™ใ€‚ใƒดใ‚งใƒผ” stays at 2 insertions, “ใ€œใŸใตใซใƒœใƒณใ‚ธใƒฅใใฎใ”ใ†ใ€‚” stays at 11 insertions. We list both items we want to save and those we want to reject to ensure normalization doesn’t let the latter through.

Kanji/number notation variations absorbed via reading

Whisper doesn’t necessarily transcribe using the same notation as the script:

Script: ใ“ใ“ใŒใ€ไปŠๆ—ฅใ„ใกใฐใ‚“ๅคงไบ‹ใชใƒใ‚คใƒณใƒˆใงใ™ใ€‚
Transcription: ใ“ใ“ใŒไปŠๆ—ฅไธ€็•ชๅคงไบ‹ใชใƒใ‚คใƒณใƒˆใงใ™

Script: ๆœˆใ€…ใฎใ‚ณใ‚นใƒˆใ‚’ไธ‰ๅ‰ฒใปใฉๆŠ‘ใˆใ‚‰ใ‚Œใพใ™ใ€‚
Transcription: ๆœˆๆœˆใฎใ‚ณใ‚นใƒˆใ‚’3ๅ‰ฒใปใฉๆŠ‘ใˆใ‚‰ใ‚Œใพใ™

Enter fullscreen mode Exit fullscreen mode

Existing “kana normalization” only converted katakana to hiragana, leaving kanji and numbers unchanged. Now we convert both sides to readings using pyopenjtalk’s g2p:

_G2P = None

def _reading(s: str) -> str:
    # Absorb kanji/number notation variations by converting to readings (kana).
    # Fall back to surface comparison if dictionary isn't available to avoid regression.
    global _G2P
    if _G2P is None:
        try:
            import pyopenjtalk
            pyopenjtalk.g2p("ใ‚", kana=True)      # Check dictionary availability
            _G2P = pyopenjtalk
        except Exception:
            _G2P = False
            logger.warning("pyopenjtalk dictionary unavailable, disabling reading normalization (surface comparison)")
    if _G2P:
        try:
            return _G2P.g2p(s, kana=True)
        except Exception:
            return s
    return s

def judge_transcript(script_text, transcript, min_ratio=0.40, max_inserted=1):
    a = _kana(_collapse(_reading(script_text)))
    b = _kana(_collapse(_reading(transcript)))
    ...

Enter fullscreen mode Exit fullscreen mode

g2p('ใ“ใ“ใŒไปŠๆ—ฅไธ€็•ชๅคงไบ‹ใชใƒใ‚คใƒณใƒˆใงใ™')      โ†’ ใ‚ณใ‚ณใ‚ฌใ‚ญใƒงใƒผใ‚คใƒใƒใƒณใƒ€ใ‚คใ‚ธใƒŠใƒใ‚คใƒณใƒˆใƒ‡ใ‚น
g2p('ใ“ใ“ใŒใ€ไปŠๆ—ฅใ„ใกใฐใ‚“ๅคงไบ‹ใชใƒใ‚คใƒณใƒˆใงใ™ใ€‚') โ†’ ใ‚ณใ‚ณใ‚ฌใ€ใ‚ญใƒงใƒผใ‚คใƒใƒใƒณใƒ€ใ‚คใ‚ธใƒŠใƒใ‚คใƒณใƒˆใƒ‡ใ‚นใ€‚

Enter fullscreen mode Exit fullscreen mode

Even with different notation, readings match.

The trap of working without a dictionary

This is where I got stuck. pyopenjtalk may have the library installed but doesn’t include the dictionary, attempting to download it on first use. In production containers, site-packages is read-only, causing this to fail:

PermissionError: [Errno 13] Permission denied:
  '/opt/venv/lib/python3.11/site-packages/pyopenjtalk/dic.tar.gz'

Enter fullscreen mode Exit fullscreen mode

This silently falls back to the fallback path, leaving reading normalization ineffective while operating normally. In the image build, expand the dictionary while root has write permissions:

# Expand pyopenjtalk dictionary during build. At runtime, site-packages is
# read-only, causing initial download to fail with PermissionError, disabling reading normalization.
RUN python -c "import pyopenjtalk; print(pyopenjtalk.g2p('ใƒ†ใ‚นใƒˆ', kana=True))"

USER fastapi

Enter fullscreen mode Exit fullscreen mode

After deployment, verify in the Pod that the dictionary is working. If _reading() returns the input unchanged, the dictionary is missing:

>>> verify._reading('ใ“ใ“ใŒไปŠๆ—ฅไธ€็•ชๅคงไบ‹ใชใƒใ‚คใƒณใƒˆใงใ™')
'ใ‚ณใ‚ณใ‚ฌใ‚ญใƒงใƒผใ‚คใƒใƒใƒณใƒ€ใ‚คใ‚ธใƒŠใƒใ‚คใƒณใƒˆใƒ‡ใ‚น'      # If kana is returned, dictionary is working

Enter fullscreen mode Exit fullscreen mode

Set thresholds using real data

Don’t decide to “reduce from 3 to 1” based on intuition. Re-transcribe existing training material to count how many would be lost. Audio and scripts are preserved, so this can be measured without regeneration:

# Re-transcribe 75 existing clips to compare old vs new judgments
for clip in sample_clips(75):
    wav   = download(clip.r2_key)
    trans = transcribe(wav)["text"]
    ins_old, ratio_old = measure(clip.text, trans, normalize="old")
    ins_new, ratio_new = measure(clip.text, trans, normalize="no_sokuon+reading")

Enter fullscreen mode Exit fullscreen mode

Threshold New method remaining Old method remaining โ‰ค0 72/75 (96%) 70/75 (93%) โ‰ค1 75/75 (100%) 73/75 (97%) โ‰ค2 75/75 (100%) 75/75 (100%) โ‰ค3 (conventional) 75/75 (100%) 75/75 (100%)

Average ratio improved from 0.963 โ†’ 0.995. With โ‰ค1, no false rejects while catching insertions of 2+ characters.

Reducing to โ‰ค0 would reject 3 clips, but all were Whisper-side issues:

Script: ๆœฌๆ—ฅใฎไบˆๅฎšใฏใ™ในใฆๆปžใ‚Šใชใ้€ฒใ‚“ใงใŠใ‚Šใพใ™ใ€‚
Transcription: ๆœฌๆ—ฅใฎไบˆๅฎšใฏใ™ในใฆ[ใจใฉใ“ใŠใ‚Š/ๅฑŠใ“ใŠใ‚Š]ใชใ้€ฒใ‚“ใงใŠใ‚Šใพใ™

Enter fullscreen mode Exit fullscreen mode

The synthesized voice pronounces correctly, but transcription notation causes rejection. Decided โ‰ค1 was appropriate since capturing ASR variations would be excessive.

โš ๏ธ This measurement has bias. The sample only includes “clips that passed and were saved,” so it answers “how many would be lost if tightened” but not “how many could be saved if loosened.” Only useful for making thresholds stricter.

Still, this “re-judge existing assets to count losses” approach can be used every time thresholds are adjusted. Making arbitrary thresholds stricter causes yield to drop, and it can take weeks to notice. This method prevents that.

Questions to Ask When Creating Metrics

Is the metric measuring product properties or process state? In this case, match rate was entirely the latter. Fixing the process would eliminate this value, but it was being used to judge candidate superiority.

Only reject properties that can’t be fixed. Process defects should be fixed in the process. Selection should only reject inherent undesirable properties of the candidate.

Metrics aren’t independent. As hallucinations contaminated speech rate, a single defect can distort multiple metrics. When seeing correlations like “candidates with low match rates also have abnormal speech rates,” we should suspect a common cause.

Are you writing conclusions that contradict your own data? When inconvenient observations are dismissed as “minor” and set aside, the conclusion is already broken. This is exactly what happened here.

Series: Mass-producing Practical Voices from Diffusion TTS

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

โ† Previous: Quality Gate’s Allowed “3 Characters” Became the Model’s Verbal Tic
โ†’ Next: There Are Defects Transcription Can’t Catch

Full series (18 parts)

  1. TTS Chosen for Audio Quality Was Too Slow for Conversation
  2. Rolling Voices Like a Gacha
  3. Having the Machine Select “Narrator-like Voices” from 24 Candidates
  4. The Harder the Quality Gate, the More Monotone Voices Survive
  5. Speech Rate Can’t Be Changed After Training
  6. TTS That Changes “Recording Location” Every Time It’s Generated
  7. One Rough Clip Ruins the Entire Style
  8. Where Did the AI’s Habit of Elongating “konnichiwa~” Come From?
  9. ใ€Œๅฐ‘ใ€…ใ€Becomesใ€Œใ—ใ‚‡ใ‚‚ใ€โ€” The Allowed Character List Was Truncating Japanese
  10. Hallucination Prevention Code Only Worked When Hallucinations Occurred
  11. Quality Gate’s Allowed “3 Characters” Became the Model’s Verbal Tic 12. We Were Rejecting Candidates for Fixable Flaws โ† Now reading
  12. There Are Defects Transcription Can’t Catch
  13. 70 Minutes of Training Material Was Lost to a Network Blink
  14. From “ja” to “JP”: How a Babbling Model Was Born
  15. Four Registration Paths, Zero Management Screens
  16. Deploying Erased Each Other’s Work Every Time
  17. Pushing Unmeasurable Things with Thresholds Always Fails

The notebook containing the insights that led to this article is available in Diffusion TTS to Mass-produce Practical Voices Manufacturing Pipeline.

์›๋ฌธ์—์„œ ๊ณ„์† โ†—