Upload comfy/custom_nodes/ComfyUI-IndexTTS2/klaus_lexicon.py with huggingface_hub
Browse files
comfy/custom_nodes/ComfyUI-IndexTTS2/klaus_lexicon.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""klaus_lexicon.py — transform Chinese text into IndexTTS-2 mangled-pinyin format
|
| 3 |
+
simulating a 3-month-Mandarin Australian learner (Klaus).
|
| 4 |
+
|
| 5 |
+
IndexTTS-2 accepts inline UPPERCASE pinyin tokens directly in the input string,
|
| 6 |
+
e.g. '今天HEN3累' forces 很 to be read as "HEN3" instead of the standard pinyin.
|
| 7 |
+
Constraint (checkpoints/pinyin.vocab, 1728 entries): tokens MUST be valid
|
| 8 |
+
Mandarin syllables — `WOR`/`DUH`/English-vowel reshapes do NOT work; we can
|
| 9 |
+
only swap one legal syllable for another (wrong tone, wrong reading, plain
|
| 10 |
+
for retroflex, u for ü).
|
| 11 |
+
|
| 12 |
+
This module applies four classes of beginner-level error, weighted by frequency
|
| 13 |
+
in real Anglo L2 Mandarin learners (Cantonese/non-tonal-L1 priors are similar):
|
| 14 |
+
|
| 15 |
+
- Tone collapse: 4→1 (loss of falling), 3→2 (loss of dipping)
|
| 16 |
+
- Retroflex loss: ZH/CH/SH → Z/C/S (no curl-tongue training)
|
| 17 |
+
- ü→u (V→U): English has no /y/, defaults to /u/
|
| 18 |
+
|
| 19 |
+
Each candidate token is validated against the IndexTTS-2 pinyin.vocab set;
|
| 20 |
+
invalid candidates fall back to emitting the Chinese character verbatim (so the
|
| 21 |
+
model uses its built-in standard pronunciation).
|
| 22 |
+
|
| 23 |
+
Output: mixed Chinese + uppercase pinyin tokens, ready to feed
|
| 24 |
+
`indextts.infer_v2.IndexTTS2.infer(text=...)`.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import random
|
| 31 |
+
import re
|
| 32 |
+
import sys
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
import pypinyin
|
| 37 |
+
except ImportError:
|
| 38 |
+
sys.exit("klaus_lexicon.py: pypinyin required. `pip install pypinyin`")
|
| 39 |
+
|
| 40 |
+
VOCAB_PATH = Path(__file__).parent / "pinyin.vocab"
|
| 41 |
+
VOCAB: set[str] = set(VOCAB_PATH.read_text().split()) # 1728 legal syllables
|
| 42 |
+
|
| 43 |
+
# Default probability tuning — calibrated for "3-month learner" perceptual signal.
|
| 44 |
+
# Override per-call via transform(... tone_4to1_prob=, tone_3to2_prob=, retroflex_loss_prob=, u_prob=).
|
| 45 |
+
# Hitting 1.0 across the board produces robotic-not-human output; keep at least one rule under 0.8.
|
| 46 |
+
TONE_4TO1_PROB = 0.4 # loss of falling tone
|
| 47 |
+
TONE_3TO2_PROB = 0.5 # loss of dipping tone (the hardest tone for English speakers)
|
| 48 |
+
RETROFLEX_LOSS_PROB = 0.6 # zh/ch/sh → z/c/s
|
| 49 |
+
U_PROB = 0.7 # ü → u (V → U)
|
| 50 |
+
|
| 51 |
+
_HAN = re.compile(r"[一-鿿]")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _is_han(ch: str) -> bool:
|
| 55 |
+
return bool(_HAN.match(ch))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _standard_pinyin(ch: str) -> str | None:
|
| 59 |
+
"""Return e.g. 'ZHONG1' for 中, or None if not a known Chinese char."""
|
| 60 |
+
py = pypinyin.pinyin(ch, style=pypinyin.Style.TONE3, errors="ignore", heteronym=False)
|
| 61 |
+
if not py or not py[0]:
|
| 62 |
+
return None
|
| 63 |
+
raw = py[0][0] # 'zhong1' or 'ma' (neutral tone, no digit)
|
| 64 |
+
if not raw or not raw[-1].isdigit():
|
| 65 |
+
raw = (raw or "") + "5" # neutral tone
|
| 66 |
+
return raw.upper()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _retroflex_loss(token: str) -> str:
|
| 70 |
+
if token.startswith("ZH"):
|
| 71 |
+
return "Z" + token[2:]
|
| 72 |
+
if token.startswith("CH"):
|
| 73 |
+
return "C" + token[2:]
|
| 74 |
+
if token.startswith("SH"):
|
| 75 |
+
return "S" + token[2:]
|
| 76 |
+
return token
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def transform(
|
| 80 |
+
text: str,
|
| 81 |
+
seed: int = 42,
|
| 82 |
+
tone_4to1_prob: float | None = None,
|
| 83 |
+
tone_3to2_prob: float | None = None,
|
| 84 |
+
retroflex_loss_prob: float | None = None,
|
| 85 |
+
u_prob: float | None = None,
|
| 86 |
+
) -> tuple[str, dict]:
|
| 87 |
+
"""Apply Klaus lexicon to `text`. Returns (transformed_string, stats).
|
| 88 |
+
|
| 89 |
+
Per-call probability overrides default to the module constants when None,
|
| 90 |
+
so existing callers (smoke_test, segment_klaus_tts) get the calibrated
|
| 91 |
+
behavior; sweeps pass explicit values.
|
| 92 |
+
|
| 93 |
+
stats keys: total_han, overrides_emitted, fallbacks_invalid, fallbacks_unchanged.
|
| 94 |
+
Use stats to sanity-check that the lexicon is hitting reasonable error rates
|
| 95 |
+
(e.g. overrides_emitted / total_han should land around 0.4–0.7 for a heavy accent).
|
| 96 |
+
"""
|
| 97 |
+
p_4to1 = TONE_4TO1_PROB if tone_4to1_prob is None else tone_4to1_prob
|
| 98 |
+
p_3to2 = TONE_3TO2_PROB if tone_3to2_prob is None else tone_3to2_prob
|
| 99 |
+
p_retro = RETROFLEX_LOSS_PROB if retroflex_loss_prob is None else retroflex_loss_prob
|
| 100 |
+
p_u = U_PROB if u_prob is None else u_prob
|
| 101 |
+
|
| 102 |
+
rng = random.Random(seed)
|
| 103 |
+
out: list[str] = []
|
| 104 |
+
stats = dict(total_han=0, overrides_emitted=0, fallbacks_invalid=0, fallbacks_unchanged=0)
|
| 105 |
+
|
| 106 |
+
for ch in text:
|
| 107 |
+
if not _is_han(ch):
|
| 108 |
+
out.append(ch)
|
| 109 |
+
continue
|
| 110 |
+
stats["total_han"] += 1
|
| 111 |
+
|
| 112 |
+
std = _standard_pinyin(ch)
|
| 113 |
+
if std is None or std not in VOCAB:
|
| 114 |
+
# Don't know how to mangle it; let the model handle the char.
|
| 115 |
+
out.append(ch)
|
| 116 |
+
stats["fallbacks_invalid"] += 1
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
candidate = std
|
| 120 |
+
|
| 121 |
+
# Retroflex loss
|
| 122 |
+
if rng.random() < p_retro:
|
| 123 |
+
candidate = _retroflex_loss(candidate)
|
| 124 |
+
|
| 125 |
+
# Tone collapse
|
| 126 |
+
body, digit = candidate[:-1], candidate[-1]
|
| 127 |
+
if digit == "4" and rng.random() < p_4to1:
|
| 128 |
+
candidate = body + "1"
|
| 129 |
+
elif digit == "3" and rng.random() < p_3to2:
|
| 130 |
+
candidate = body + "2"
|
| 131 |
+
|
| 132 |
+
# ü → u
|
| 133 |
+
if "V" in candidate and rng.random() < p_u:
|
| 134 |
+
candidate = candidate.replace("V", "U")
|
| 135 |
+
|
| 136 |
+
if candidate == std:
|
| 137 |
+
# No mutation rolled true — keep Chinese, model uses standard pronunciation.
|
| 138 |
+
out.append(ch)
|
| 139 |
+
stats["fallbacks_unchanged"] += 1
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
if candidate not in VOCAB:
|
| 143 |
+
# Mutation produced an illegal syllable (rare, e.g. some V→U combos).
|
| 144 |
+
# Fall back to the standard pronunciation rather than risk an invalid token.
|
| 145 |
+
out.append(ch)
|
| 146 |
+
stats["fallbacks_invalid"] += 1
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
out.append(candidate)
|
| 150 |
+
stats["overrides_emitted"] += 1
|
| 151 |
+
|
| 152 |
+
return "".join(out), stats
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def main() -> int:
|
| 156 |
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
| 157 |
+
ap.add_argument("text", nargs="?", default="大家好我叫Klaus,我学中文学了三个月,普通话真的很难,特别是声调")
|
| 158 |
+
ap.add_argument("--seed", type=int, default=42)
|
| 159 |
+
ap.add_argument("--quiet", action="store_true", help="suppress stats line on stderr")
|
| 160 |
+
args = ap.parse_args()
|
| 161 |
+
|
| 162 |
+
transformed, stats = transform(args.text, seed=args.seed)
|
| 163 |
+
print(transformed)
|
| 164 |
+
if not args.quiet:
|
| 165 |
+
rate = stats["overrides_emitted"] / max(stats["total_han"], 1)
|
| 166 |
+
print(
|
| 167 |
+
f"[stats] han={stats['total_han']} overrides={stats['overrides_emitted']} "
|
| 168 |
+
f"({rate:.0%}) unchanged={stats['fallbacks_unchanged']} "
|
| 169 |
+
f"invalid={stats['fallbacks_invalid']}",
|
| 170 |
+
file=sys.stderr,
|
| 171 |
+
)
|
| 172 |
+
return 0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
sys.exit(main())
|