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