igerry commited on
Commit
8fc3d92
·
verified ·
1 Parent(s): d662049

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())