igerry commited on
Commit
a1ba166
·
verified ·
1 Parent(s): 5f569e2

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

Browse files
comfy/custom_nodes/ComfyUI-IndexTTS2/audit_klaus_dropouts.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """audit_klaus_dropouts.py — re-ASR all per-fragment wav files from a Klaus
3
+ render and compute the ground-truth dropout report.
4
+
5
+ Runs in the whisperx conda env (NOT the IndexTTS-2 venv). Reuses md_to_fragments
6
+ from render_klaus_episode.py to derive the expected text per fragment from the
7
+ .md (same processing the render loop uses), then ASRs each frag wav file and
8
+ diffs got vs expected at the **pinyin sequence** level (tone-stripped, digit-
9
+ normalized) — same primitive used by verify_klaus_dropout.py.
10
+
11
+ Why pinyin diff (v2, 2026-05-11): grapheme-level Levenshtein had ~90% false
12
+ positives on Klaus audio because klaus_lexicon deliberately mangles tones
13
+ and Whisper substitutes homophones. Pinyin sequence diff treats homophone
14
+ substitutions as match (no false positive) and only flags actual missing
15
+ syllables.
16
+
17
+ Outputs a per-fragment table + summary of which fragments dropped which
18
+ specific syllables. Loads whisperx ONCE — total wall ~30s + 1-2s per fragment.
19
+
20
+ Usage (on server, after a Klaus render):
21
+ /root/miniconda3/envs/whisperx/bin/python \
22
+ /root/IndexTTS2/audit_klaus_dropouts.py \
23
+ --md /tmp/klaus_ep01_listen.md \
24
+ --frag-dir /tmp/klaus_render \
25
+ [--json /tmp/klaus_audit.json]
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import difflib
32
+ import json
33
+ import re
34
+ import sys
35
+ import time
36
+ import unicodedata
37
+ from pathlib import Path
38
+
39
+ # Reuse the verifier's pinyin pipeline so audit + production stay in sync.
40
+ sys.path.insert(0, str(Path(__file__).parent))
41
+ from verify_klaus_dropout import ( # noqa: E402
42
+ text_to_pinyin_sequence,
43
+ pinyin_diff,
44
+ _grapheme_normalize,
45
+ )
46
+
47
+
48
+ def md_to_fragments(md: str) -> list[str]:
49
+ """Mirror of render_klaus_episode.py's md_to_fragments — kept in sync."""
50
+ body = "\n".join(ln for ln in md.splitlines() if not ln.lstrip().startswith("#"))
51
+ raw = re.split(r"\n*\s*(停顿)\s*\n*", body)
52
+ out: list[str] = []
53
+ for s in raw:
54
+ s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
55
+ s = re.sub(r"([^)]*)", "", s)
56
+ s = re.sub(r"\n{3,}", "\n\n", s).strip()
57
+ if s:
58
+ out.append(s)
59
+ return out
60
+
61
+
62
+ def main() -> int:
63
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
64
+ ap.add_argument("--md", required=True, type=Path, help=".md script used for the render")
65
+ ap.add_argument("--frag-dir", required=True, type=Path,
66
+ help="dir containing frag001.wav ... fragNNN.wav")
67
+ ap.add_argument("--json", type=Path, default=None,
68
+ help="optional path to write the full report as JSON")
69
+ ap.add_argument("--language", default="zh")
70
+ ap.add_argument("--asr-model", default="large-v3")
71
+ ap.add_argument("--device", default="cuda")
72
+ ap.add_argument("--compute-type", default="float16")
73
+ args = ap.parse_args()
74
+
75
+ fragments = md_to_fragments(args.md.read_text())
76
+ print(f"## script: {args.md.name}", flush=True)
77
+ print(f"## fragments parsed: {len(fragments)}", flush=True)
78
+
79
+ frag_wavs = sorted(args.frag_dir.glob("frag*.wav"))
80
+ print(f"## frag wavs found: {len(frag_wavs)}", flush=True)
81
+ if len(fragments) != len(frag_wavs):
82
+ print(f"⚠ mismatch: {len(fragments)} fragments vs {len(frag_wavs)} wavs — proceeding "
83
+ f"with min(len)", file=sys.stderr, flush=True)
84
+ n = min(len(fragments), len(frag_wavs))
85
+
86
+ print(f"## loading whisperx {args.asr_model} …", flush=True)
87
+ t0 = time.perf_counter()
88
+ import whisperx
89
+ asr = whisperx.load_model(
90
+ args.asr_model, args.device, language=args.language,
91
+ compute_type=args.compute_type,
92
+ asr_options={"beam_size": 1, "condition_on_previous_text": False},
93
+ )
94
+ print(f"## loaded in {time.perf_counter()-t0:.1f}s", flush=True)
95
+
96
+ rows = []
97
+ print()
98
+ print(f"{'idx':>3} {'py_exp':>6} {'py_got':>6} {'del':>3} {'rpl':>3} {'ins':>3} {'verdict':>7} dropped_syllables")
99
+ print("-" * 80)
100
+
101
+ for i in range(n):
102
+ frag_text = fragments[i]
103
+ wav_path = frag_wavs[i]
104
+ t0 = time.perf_counter()
105
+ audio = whisperx.load_audio(str(wav_path))
106
+ result = asr.transcribe(audio, batch_size=8)
107
+ got_text = "".join(seg.get("text", "") for seg in result["segments"]).strip()
108
+ elapsed = time.perf_counter() - t0
109
+
110
+ exp_pinyin = text_to_pinyin_sequence(frag_text)
111
+ got_pinyin = text_to_pinyin_sequence(got_text)
112
+ diff = pinyin_diff(exp_pinyin, got_pinyin)
113
+ counts = diff["counts"]
114
+ verdict = "DROP" if counts["delete"] >= 1 else "OK"
115
+
116
+ # Grapheme diagnostics (informational only)
117
+ exp_g = _grapheme_normalize(frag_text)
118
+ got_g = _grapheme_normalize(got_text)
119
+ char_ratio = len(got_g) / max(len(exp_g), 1)
120
+ seq_ratio = difflib.SequenceMatcher(None, exp_g, got_g, autojunk=False).ratio()
121
+
122
+ dropped_str = " ".join(diff["dropped"]) if diff["dropped"] else ""
123
+ print(f"{i+1:>3} {len(exp_pinyin):>6} {len(got_pinyin):>6} "
124
+ f"{counts['delete']:>3} {counts['replace']:>3} {counts['insert']:>3} "
125
+ f"{verdict:>7} {dropped_str}", flush=True)
126
+
127
+ rows.append({
128
+ "idx": i + 1,
129
+ "wav": wav_path.name,
130
+ "expected_text": frag_text,
131
+ "got_text": got_text,
132
+ "pinyin_expected_count": len(exp_pinyin),
133
+ "pinyin_got_count": len(got_pinyin),
134
+ "pinyin_diff_counts": counts,
135
+ "pinyin_dropped": diff["dropped"],
136
+ "pinyin_ratio": diff["ratio"],
137
+ "char_ratio": round(char_ratio, 3),
138
+ "seq_ratio": round(seq_ratio, 3),
139
+ "verdict": verdict,
140
+ "asr_sec": round(elapsed, 2),
141
+ })
142
+
143
+ drops = [r for r in rows if r["verdict"] == "DROP"]
144
+ print()
145
+ print(f"## summary: {len(drops)}/{n} fragments flagged DROP (pinyin-level, ground truth)")
146
+ for r in drops:
147
+ print(f" frag {r['idx']:>2}: dropped pinyin = {r['pinyin_dropped']}")
148
+ print(f" expected: {r['expected_text'][:120]}")
149
+ print(f" got: {r['got_text'][:120]}")
150
+
151
+ if args.json:
152
+ args.json.write_text(json.dumps(rows, ensure_ascii=False, indent=2))
153
+ print(f"## json report: {args.json}")
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())