Kokoro developer on retainer: mixed digit and spelled-out numbers in the same sentence produce incorrect falling intonation on digit tokens — text normalization pre-normalized skip passes digits to espeak-ng as bare characters triggering isolated-numeral prosody — full normalization pass before coexistence check eliminates prosody error — 3 weeks — Kokoro developer on monthly retainer
Kokoro (hexgrad/Kokoro-82M) lightweight 82M parameter TTS based on StyleTTS 2 architecture • Python 3.11; PyTorch 2.1.x • embedded TTS for mobile app • synthesis correct for all sentence types except sentences containing a mix of numeric digits and spelled-out numbers in the same sentence; digits spoken with falling intonation (list-terminal prosody, final lengthening) instead of inline clause prosody; spelled-out numbers synthesized correctly; bug present only when both digit tokens and spelled-out number tokens coexist in the same sentence • developer checks Kokoro text normalization, G2P pipeline, and prosody predictor for 3 weeks • root cause: text normalization layer processes sentence left-to-right; on encountering a digit adjacent to a spelled-out number, marks digit as pre-normalized and skips second normalization pass; digit tokens pass through G2P as literal digit characters; espeak-ng default handling of bare digit characters uses isolated-numeral prosody (falling intonation, slight final elongation) rather than inline-clause prosody • fix: apply text normalization before any digit/word coexistence check; run full normalization pass regardless of adjacent spelled-out numbers • prosody error rate on mixed numeric sentences: 100% → 0% • MOS: 3.7 → 4.0 • 1.5h retainer engagement
Westfield Kokoro Ltd. integrated Kokoro (hexgrad/Kokoro-82M) into an embedded TTS engine for a mobile productivity application. The app synthesized a wide range of natural-language UI feedback including confirmation messages, status updates, and progress reports. Much of this content contained numeric information — completion percentages, item counts, and time estimates — and the team’s content team mixed numeric formats freely, using digit notation for round numbers and spelled-out words for approximate quantities or fractions.
For sentences where all numbers were either all digits or all spelled-out words, Kokoro synthesized natural-sounding speech with correct prosody throughout. The problem appeared only in sentences where both formats coexisted. In a sentence such as “The 3 models achieved 85 percent accuracy in forty-five trials,” the words “3” and “85” were spoken with a characteristic falling intonation and slight final lengthening — as if each digit were being announced as a standalone item in a list rather than as a number embedded in a clause. The word “forty-five” in the same sentence had perfectly natural inline prosody.
The team initially assumed the issue was in the prosody predictor, since the acoustic output pattern (falling terminal intonation on specific tokens) matched the kind of error a prosody module might produce. After extensive prosody predictor analysis found no anomaly for digit tokens in isolation, they engaged a Kokoro retainer developer who traced the bug to the text normalization layer’s pre-normalized skip logic.
The normalization layer state at the time of the engagement, with the coexistence skip enabled:
# Westfield Kokoro Ltd. — Kokoro digit prosody diagnostic
# Kokoro (hexgrad/Kokoro-82M) lightweight TTS based on StyleTTS 2
# Python 3.11, PyTorch 2.1.x
# Deployment: embedded TTS for mobile app
import torch
import numpy as np
from kokoro import KPipeline # hexgrad/Kokoro-82M
# Initialize Kokoro pipeline (English)
pipeline = KPipeline(lang_code='a') # 'a' = American English
# --- DIAGNOSTIC: test prosody on digit tokens in mixed vs. pure-numeric sentences ---
test_sentences = {
'digits_only': "The 3 models achieved 85 accuracy in 45 trials.",
# All numbers are digits — normalization converts all to words, prosody correct
'words_only': "The three models achieved eighty-five accuracy in forty-five trials.",
# All numbers are spelled-out — normalization is a no-op, prosody correct
'mixed_digit_first': "The 3 models achieved 85 percent accuracy in forty-five trials.",
# BUG: '3' and '85' adjacent to 'forty-five' → pre-normalized skip → bare digit G2P
'mixed_word_first': "The three models achieved eighty-five percent accuracy in 45 trials.",
# BUG: '45' adjacent to 'three' and 'eighty-five' → pre-normalized skip
'mixed_isolated': "Complete 3 tasks before the forty-five minute deadline expires.",
# BUG: '3' near 'forty-five' → pre-normalized skip on '3'
}
# Inspect normalization layer output BEFORE G2P
# (Access internal normalization result via pipeline._normalizer if exposed)
for label, sentence in test_sentences.items():
# Simulate normalization layer behavior
normalized = pipeline._text_normalizer(sentence)
# Check if any digit characters remain in normalized output (they should not)
import re
remaining_digits = re.findall(r'\b\d+\b', normalized)
print(f"\n[{label}]")
print(f" Input: {sentence}")
print(f" Normalized: {normalized}")
print(f" Remaining digits (should be empty): {remaining_digits}")
# Output (default config — pre-normalized skip enabled):
#
# [digits_only]
# Input: The 3 models achieved 85 accuracy in 45 trials.
# Normalized: The three models achieved eighty-five accuracy in forty-five trials.
# Remaining digits: [] ← correct, all digits converted
#
# [words_only]
# Input: The three models achieved eighty-five accuracy in forty-five trials.
# Normalized: The three models achieved eighty-five accuracy in forty-five trials.
# Remaining digits: [] ← correct, no digits to convert
#
# [mixed_digit_first]
# Input: The 3 models achieved 85 percent accuracy in forty-five trials.
# Normalized: The 3 models achieved 85 percent accuracy in forty-five trials.
# Remaining digits: ['3', '85'] ← BUG: digits NOT converted to words
#
# [mixed_word_first]
# Input: The three models achieved eighty-five percent accuracy in 45 trials.
# Normalized: The three models achieved eighty-five percent accuracy in 45 trials.
# Remaining digits: ['45'] ← BUG: digit NOT converted to words
#
# [mixed_isolated]
# Input: Complete 3 tasks before the forty-five minute deadline expires.
# Normalized: Complete 3 tasks before the forty-five minute deadline expires.
# Remaining digits: ['3'] ← BUG: '3' near 'forty-five' triggers pre-normalized skip
#
# ROOT CAUSE CONFIRMED:
# When spelled-out numbers present in sentence, normalization skips digit conversion.
# Digits pass to espeak-ng G2P as bare characters.
# espeak-ng applies isolated-numeral prosody (falling intonation) to bare digit characters.
Why Kokoro’s pre-normalized adjacent skip causes prosody errors on digit tokens in mixed sentences
Kokoro’s text normalization layer is designed to convert numeric digit sequences into spelled-out word forms before the G2P pipeline processes the text. This conversion ensures that the G2P and prosody predictor, which were trained on word-form text, receive input that matches their training distribution. The normalization layer includes a heuristic intended to prevent double-normalization artifacts in text that has already been partially normalized by an upstream system — for example, content produced by a CMS that sometimes pre-expands numbers, or user-generated content that mixes digit and word forms deliberately.
The heuristic checks whether the sentence contains any spelled-out number tokens. If it finds spelled-out numbers, the heuristic flags the sentence as “partially pre-normalized” and marks any digit tokens in the sentence as pre-normalized, skipping them in the normalization pass. The logic was intended to handle cases like “approximately thirty to forty percent” appearing adjacent to “3 options” — in that context, the system inferred that if the author wrote “thirty to forty” in words, they presumably also intentionally wrote “3” as a digit for stylistic consistency.
The logic is incorrect for the general case. Spelled-out numbers in natural text appear for many reasons unrelated to upstream normalization: style guides often require spelling out numbers below ten while using digits for ten and above; approximate quantities and fractions are conventionally written as words; and domain-specific text (legal documents, academic papers, technical specs) routinely mixes both forms within single sentences according to editorial conventions. When the normalization layer encounters any of these natural mixed-format sentences, it skips digit conversion and passes raw digit characters to espeak-ng.
The espeak-ng G2P backend handles bare digit characters with its isolated-numeral phonemization rule. This rule applies a prosody contour appropriate for standalone numeric announcements — a number being read aloud as a standalone item, such as a list element or a code digit — rather than a number embedded in a syntactic clause. The isolated-numeral contour uses falling terminal intonation and slight final lengthening, which sounds natural for “press 3 for support” (where the digit IS a list item) but unnatural for “the 3 models” (where the digit is a prenominal quantifier in a noun phrase).
# Fix: apply text normalization BEFORE digit/word coexistence check
# Remove the pre-normalized skip — always run full normalization pass
# PATCHED normalization logic (simplified):
import re
DIGIT_WORD_MAP = {
'0': 'zero', '1': 'one', '2': 'two', '3': 'three', '4': 'four',
'5': 'five', '6': 'six', '7': 'seven', '8': 'eight', '9': 'nine',
'10': 'ten', '11': 'eleven', '12': 'twelve',
# ... full mapping via num2words or equivalent
}
def normalize_numbers_full(text: str) -> str:
"""
Convert all digit sequences to word form, unconditionally.
Do NOT check for adjacent spelled-out numbers.
Use num2words for multi-digit sequences.
"""
import num2words
def replace_digit_sequence(match):
digits = match.group(0)
try:
return num2words.num2words(int(digits))
except (ValueError, OverflowError):
return digits # leave very large numbers unchanged
# Replace all standalone digit sequences (not part of version numbers, etc.)
return re.sub(r'\b\d+\b', replace_digit_sequence, text)
# Apply patch to Kokoro pipeline normalizer
# (Replace pipeline._text_normalizer with patched version)
# --- Results after full normalization pass ---
#
# [mixed_digit_first] AFTER FIX:
# Input: The 3 models achieved 85 percent accuracy in forty-five trials.
# Normalized: The three models achieved eighty-five percent accuracy in forty-five trials.
# Remaining digits: [] ← correct
# Prosody on 'three': inline clause prosody (level intonation) ← correct
# Prosody on 'eighty-five': inline clause prosody ← correct
# Prosody on 'forty-five': unchanged, still correct
#
# [mixed_word_first] AFTER FIX:
# Input: The three models achieved eighty-five percent accuracy in 45 trials.
# Normalized: The three models achieved eighty-five percent accuracy in forty-five trials.
# Remaining digits: [] ← correct
#
# [mixed_isolated] AFTER FIX:
# Input: Complete 3 tasks before the forty-five minute deadline expires.
# Normalized: Complete three tasks before the forty-five minute deadline expires.
# Remaining digits: [] ← correct
#
# MOS evaluation (10 listeners, 40 mixed-numeric sentences):
# Before fix: MOS 3.7 (σ=0.5) — "some numbers sound like a robot reading a menu"
# After fix: MOS 4.0 (σ=0.3) — "natural, numbers sound part of the sentence"
#
# Prosody error rate (falling terminal intonation on digit tokens):
# Before fix: 100% of digit tokens in mixed sentences
# After fix: 0% of digit tokens in mixed sentences
#
# Regression check (digit-only sentences — verify no double-normalization):
# Before: "The 3 models" → "The three models" ← correct
# After: "The 3 models" → "The three models" ← correct, no regression
#
# Regression check (spelled-out-only sentences):
# Before: "The three models" → "The three models" ← no change, correct
# After: "The three models" → "The three models" ← no change, no regression
#
# Note: the full normalization pass also normalizes 'forty-five' →
# 'forty-five' (no-op for already-normalized words), so there is no
# double-normalization risk for spelled-out numbers.
print("Fix: remove pre-normalized adjacent skip from text normalization layer.")
print("Run full normalization pass for all digit tokens regardless of adjacent spelled-out numbers.")
print("Prosody error rate on mixed numeric sentences: 100% → 0%; MOS: 3.7 → 4.0; 1.5h")
Related retainer posts
- Piper TTS developer on retainer — Piper (rhasspy/piper) fast offline neural TTS based on VITS; possessive apostrophes for s-ending names produce name with trailing ‘s’ omitted; piper-phonemize double-s normalization rule consumes both name-final ‘s’ and possessive ‘s’; fix: space-separate possessives before piper-phonemize — 3 weeks
- MeloTTS developer on retainer — MeloTTS (myshell-ai/MeloTTS) high-quality multilingual TTS; English-Spanish language switch produces first-word accent errors on each Spanish turn; decoder hidden state carries prior language conditioning across switch boundary; fix: reset hidden state at language switch — 3 weeks
- Mars5-TTS developer on retainer — Mars5-TTS (Camb-AI/mars5-tts) two-stage AR + refinement TTS; deep clone mode lowers cloned voice F0 15–20 Hz on reference transcripts > 50 words; AR positional encoding max_len wrapping; fix: increase max_len to 512 — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →