igerry commited on
Commit
d662049
·
verified ·
1 Parent(s): 71c7104

Upload comfy/custom_nodes/ComfyUI-IndexTTS2/verify_klaus_dropout.py with huggingface_hub

Browse files
comfy/custom_nodes/ComfyUI-IndexTTS2/verify_klaus_dropout.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """verify_klaus_dropout.py — ASR-confirm a TTS dropout suspicion.
3
+
4
+ Sibling tool to render_klaus_episode.py. Lives on the server alongside it
5
+ (/root/IndexTTS2/) but runs in the *whisperx* conda env (NOT the IndexTTS-2
6
+ venv), since whisperx pulls a different torch + faster-whisper stack.
7
+
8
+ Layer 2 v2 — pinyin-sequence verdict (2026-05-11 rewrite):
9
+ Klaus uses klaus_lexicon to deliberately mangle tones; Whisper without
10
+ tone context picks homophone characters (`任重道远 → 任中刀援`,
11
+ `是→诗`, `像→相`), so grapheme-level Levenshtein had ~90% false
12
+ positives in audit_klaus_dropouts.py against ep01. The fix: compute
13
+ pinyin sequences (tone-stripped, Arabic numerals normalized to Chinese
14
+ via cn2an so `30 → 三十 → san shi`), and use the count of `delete`
15
+ opcodes in SequenceMatcher as the dropout verdict. Same-syllable
16
+ substitutions match (no false positive); missing syllables = real
17
+ dropout.
18
+
19
+ Output: single JSON verdict line on stdout. `ok=true` ⇒ no missing
20
+ syllables; `ok=false` ⇒ at least one expected pinyin syllable is absent
21
+ from the ASR output. Grapheme char_ratio/seq_ratio stay in the output for
22
+ diagnostics, but they no longer drive the verdict.
23
+
24
+ Cost: ~10-15s model load + ~1-2s inference per call.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import contextlib
31
+ import difflib
32
+ import json
33
+ import re
34
+ import sys
35
+ import unicodedata
36
+ from pathlib import Path
37
+
38
+
39
+ @contextlib.contextmanager
40
+ def _stdout_to_stderr():
41
+ """Redirect Python-level sys.stdout → stderr inside the block. Required
42
+ around whisperx + pyannote calls: they leak INFO log lines straight to
43
+ stdout (pyannote in particular, e.g. "Performing voice activity
44
+ detection..."), which corrupts our line-delimited JSON daemon protocol.
45
+ `logging.basicConfig(stream=stderr, force=True)` alone doesn't catch
46
+ these because pyannote's logger gets configured during import, after we
47
+ set basicConfig."""
48
+ real = sys.stdout
49
+ sys.stdout = sys.stderr
50
+ try:
51
+ yield
52
+ finally:
53
+ sys.stdout = real
54
+
55
+
56
+ _HAN = re.compile(r"[一-鿿]")
57
+
58
+
59
+ def _to_simplified(text: str) -> str:
60
+ """Trad → simp via zhconv. WhisperX often returns traditional on Klaus
61
+ audio (tone errors push the decoder toward Cantonese-prior tokens);
62
+ without this, simp `远` vs trad `遠` would be treated as a substitution."""
63
+ try:
64
+ from zhconv import convert # type: ignore
65
+ return convert(text, "zh-cn")
66
+ except ImportError:
67
+ return text
68
+
69
+
70
+ def _normalize_digits(text: str) -> str:
71
+ """Replace Arabic digit clusters with Chinese readings (`30 → 三十`,
72
+ `8000 → 八千`). Klaus scripts mix Arabic and Chinese; ASR may transcribe
73
+ spoken Chinese numbers as Arabic digits. Without this, every digit
74
+ cluster collapses to zero pinyin tokens on one side and a syllable run
75
+ on the other, producing fake `delete` opcodes."""
76
+ try:
77
+ import cn2an # type: ignore
78
+ except ImportError:
79
+ return text
80
+
81
+ def repl(m: re.Match) -> str:
82
+ try:
83
+ return cn2an.an2cn(m.group())
84
+ except Exception:
85
+ return m.group()
86
+ return re.sub(r"\d+", repl, text)
87
+
88
+
89
+ def text_to_pinyin_sequence(text: str) -> list[str]:
90
+ """Convert text → list of tone-stripped lowercase pinyin syllables (Han only).
91
+
92
+ Pipeline: NFKC normalize → trad→simp → digits→Chinese → keep only Han
93
+ chars → pypinyin Style.NORMAL (no tone marks).
94
+ """
95
+ from pypinyin import pinyin, Style # type: ignore
96
+
97
+ s = unicodedata.normalize("NFKC", text)
98
+ s = _to_simplified(s)
99
+ s = _normalize_digits(s)
100
+ han_only = "".join(c for c in s if _HAN.match(c))
101
+ if not han_only:
102
+ return []
103
+ # pypinyin returns nested list: [['ren'], ['zhong'], ...]; flatten and lowercase
104
+ return [p[0].lower() for p in pinyin(han_only, style=Style.NORMAL)]
105
+
106
+
107
+ def _grapheme_normalize(text: str) -> str:
108
+ """Legacy grapheme normalize (alphanumerics only, trad→simp, NFKC) for
109
+ diagnostic char_ratio / seq_ratio. NOT used in the verdict anymore."""
110
+ s = unicodedata.normalize("NFKC", _to_simplified(text))
111
+ return "".join(c for c in s if unicodedata.category(c)[0] in ("L", "N"))
112
+
113
+
114
+ def pinyin_diff(exp_seq: list[str], got_seq: list[str]) -> dict:
115
+ """SequenceMatcher diff on pinyin syllable lists. Returns counts +
116
+ list of dropped syllables (the things that show up in `delete` opcodes
117
+ of the expected sequence)."""
118
+ matcher = difflib.SequenceMatcher(None, exp_seq, got_seq, autojunk=False)
119
+ counts = {"equal": 0, "delete": 0, "replace": 0, "insert": 0}
120
+ dropped: list[str] = []
121
+ replaces: list[tuple[list[str], list[str]]] = []
122
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
123
+ if tag == "equal":
124
+ counts["equal"] += i2 - i1
125
+ elif tag == "delete":
126
+ counts["delete"] += i2 - i1
127
+ dropped.extend(exp_seq[i1:i2])
128
+ elif tag == "replace":
129
+ counts["replace"] += max(i2 - i1, j2 - j1)
130
+ replaces.append((exp_seq[i1:i2], got_seq[j1:j2]))
131
+ elif tag == "insert":
132
+ counts["insert"] += j2 - j1
133
+ return {
134
+ "counts": counts,
135
+ "dropped": dropped,
136
+ "replaces": replaces,
137
+ "ratio": round(matcher.ratio(), 3),
138
+ }
139
+
140
+
141
+ def _compute_verdict(expected: str, got_text: str) -> dict:
142
+ """Run the pinyin-sequence diff and pack the verdict + diagnostics into the
143
+ shared response shape (used by both one-shot and daemon modes)."""
144
+ exp_pinyin = text_to_pinyin_sequence(expected)
145
+ got_pinyin = text_to_pinyin_sequence(got_text)
146
+ diff = pinyin_diff(exp_pinyin, got_pinyin)
147
+ ok = diff["counts"]["delete"] == 0
148
+
149
+ exp_g = _grapheme_normalize(expected)
150
+ got_g = _grapheme_normalize(got_text)
151
+ char_ratio = len(got_g) / max(len(exp_g), 1)
152
+ seq_ratio = difflib.SequenceMatcher(None, exp_g, got_g, autojunk=False).ratio()
153
+
154
+ return {
155
+ "ok": ok,
156
+ "expected": expected,
157
+ "got": got_text,
158
+ # Pinyin verdict (primary, drives `ok`)
159
+ "pinyin_expected_count": len(exp_pinyin),
160
+ "pinyin_got_count": len(got_pinyin),
161
+ "pinyin_diff_counts": diff["counts"],
162
+ "pinyin_dropped": diff["dropped"],
163
+ "pinyin_ratio": diff["ratio"],
164
+ # Grapheme diagnostics (informational only)
165
+ "char_ratio": round(char_ratio, 3),
166
+ "seq_ratio": round(seq_ratio, 3),
167
+ # Backward-compat aliases for older render_klaus_episode.py print lines
168
+ "expected_chars": len(exp_g),
169
+ "got_chars": len(got_g),
170
+ "ratio": round(seq_ratio, 3),
171
+ }
172
+
173
+
174
+ def _daemon_loop(args) -> int:
175
+ """Long-running mode: load model once, then read line-delimited JSON
176
+ requests `{"audio": "...", "expected": "..."}` from stdin and write a JSON
177
+ verdict response per line to stdout.
178
+
179
+ Special control lines on stdin:
180
+ QUIT — graceful shutdown, returns 0.
181
+ (EOF) — same as QUIT.
182
+
183
+ Designed to be spawned by render_klaus_episode.py for per-fragment audit
184
+ after all fragments have been rendered. Loading whisperx once means each
185
+ fragment check is ~1-2s instead of ~15s cold-spawn."""
186
+ # Force ML libs' logging to stderr BEFORE importing whisperx.
187
+ import logging
188
+ logging.basicConfig(stream=sys.stderr, force=True, level=logging.WARNING)
189
+
190
+ # whisperx + pyannote leak INFO via direct print/logger-with-stdout-handler
191
+ # despite our basicConfig — wrap all ML calls in stdout→stderr redirect.
192
+ with _stdout_to_stderr():
193
+ import whisperx # type: ignore
194
+ asr = whisperx.load_model(
195
+ args.asr_model, args.device, language=args.language,
196
+ compute_type=args.compute_type,
197
+ asr_options={"beam_size": 1, "condition_on_previous_text": False},
198
+ )
199
+
200
+ # Handshake: client blocks on this line until it appears.
201
+ print(json.dumps({"status": "ready"}), flush=True)
202
+
203
+ for raw in sys.stdin:
204
+ line = raw.strip()
205
+ if not line or line == "QUIT":
206
+ break
207
+ try:
208
+ req = json.loads(line)
209
+ audio_path = req["audio"]
210
+ expected = req["expected"]
211
+ except (json.JSONDecodeError, KeyError) as e:
212
+ print(json.dumps({"error": f"bad request: {e}"}, ensure_ascii=False), flush=True)
213
+ continue
214
+
215
+ try:
216
+ with _stdout_to_stderr():
217
+ audio = whisperx.load_audio(audio_path)
218
+ result = asr.transcribe(audio, batch_size=8)
219
+ got_text = "".join(seg.get("text", "") for seg in result["segments"]).strip()
220
+ verdict = _compute_verdict(expected, got_text)
221
+ verdict["audio"] = audio_path
222
+ print(json.dumps(verdict, ensure_ascii=False), flush=True)
223
+ except Exception as e:
224
+ print(json.dumps({"error": str(e), "audio": audio_path}, ensure_ascii=False), flush=True)
225
+ return 0
226
+
227
+
228
+ def main() -> int:
229
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
230
+ ap.add_argument("--daemon", action="store_true",
231
+ help="Long-running mode: read JSON requests from stdin, "
232
+ "write JSON verdicts to stdout. Whisperx loads once. "
233
+ "Used by render_klaus_episode.py's post-render audit.")
234
+ ap.add_argument("--audio", help="(one-shot mode) path to .wav/.flac to verify")
235
+ ap.add_argument("--expected", help="(one-shot mode) the text that *should* "
236
+ "have been spoken")
237
+ ap.add_argument("--language", default="zh")
238
+ ap.add_argument("--asr-model", default="large-v3")
239
+ ap.add_argument("--device", default="cuda")
240
+ ap.add_argument("--compute-type", default="float16")
241
+ args = ap.parse_args()
242
+
243
+ if args.daemon:
244
+ return _daemon_loop(args)
245
+
246
+ # One-shot mode (kept for ad-hoc debugging + smoke-tests)
247
+ if not args.audio or not args.expected:
248
+ sys.exit("one-shot mode requires --audio and --expected (or use --daemon)")
249
+ if not Path(args.audio).exists():
250
+ sys.exit(f"audio missing: {args.audio}")
251
+
252
+ import whisperx # type: ignore
253
+ audio = whisperx.load_audio(args.audio)
254
+ asr = whisperx.load_model(
255
+ args.asr_model, args.device, language=args.language,
256
+ compute_type=args.compute_type,
257
+ asr_options={"beam_size": 1, "condition_on_previous_text": False},
258
+ )
259
+ result = asr.transcribe(audio, batch_size=8)
260
+ got_text = "".join(seg.get("text", "") for seg in result["segments"]).strip()
261
+
262
+ verdict = _compute_verdict(args.expected, got_text)
263
+ print(json.dumps(verdict, ensure_ascii=False))
264
+ return 0
265
+
266
+
267
+ if __name__ == "__main__":
268
+ sys.exit(main())