fd-speech-demo / scripts /score_seed_tts_eval.py
voidful's picture
Add SR-FD four-step comparison demo
10bebcd verified
Raw
History Blame Contribute Delete
11.9 kB
#!/usr/bin/env python3
"""Score a seed-tts-eval submission directory.
Given a directory containing `wav_res_ref_text` (the listing produced by
`scripts/infer_seed_tts_eval.py`), compute:
• WER/CER (Whisper-large-v3 for English/Chinese)
• SIM (WavLM-SV-large) — same recipe as cal_sim.sh
The numbers go into `<out_dir>/seed_tts_eval_metrics.json` for paper rendering.
This combines what the upstream seed-tts-eval scoring scripts do, but in
one Python invocation that doesn't depend on multi-GPU sharding tricks
(the upstream cal_wer.sh assumes ARNOLD_WORKER_GPU which we don't have).
"""
from __future__ import annotations
import argparse
import json
import re
import string
import sys
import unicodedata
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
import numpy as np
import torch
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser()
p.add_argument("--listing", required=True,
help="path to wav_res_ref_text (gen_wav|ref_wav|target_text)")
p.add_argument("--out_json", required=True)
p.add_argument("--lang", default="en", choices=["en", "zh"])
p.add_argument("--whisper_model", default=str(PROJECT_ROOT / "models/whisper-large-v3"))
p.add_argument("--wavlm_sv_ckpt", default=None,
help="path to wavlm_large_finetune.pth; if None, fall back to "
"microsoft/wavlm-base-plus-sv (cosine sim of pooled features)")
p.add_argument("--wavlm_model", default=str(PROJECT_ROOT / "models/wavlm-base-plus-sv"),
help="local WavLM-SV model path or HF model id for fallback SIM")
p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
p.add_argument("--max_items", type=int, default=0)
p.add_argument(
"--item_start",
type=int,
default=0,
help="0-based item offset before applying --max_items; useful for non-prefix eval slices.",
)
p.add_argument(
"--item_stride",
type=int,
default=1,
help="Keep every Nth item after --item_start; useful for deterministic spread-out eval slices.",
)
p.add_argument("--skip_sim", action="store_true",
help="Only compute ASR error. Useful for large search sweeps; final tables should score SIM.")
return p.parse_args()
def normalise_en(s: str) -> str:
s = s.lower()
for ch in string.punctuation:
if ch == "'":
continue
s = s.replace(ch, "")
s = re.sub(r"\s+", " ", s).strip()
return s
def normalise_zh(s: str) -> str:
"""Normalize Chinese ASR text for character-error scoring."""
s = unicodedata.normalize("NFKC", s).lower()
chars = []
for ch in s:
cat = unicodedata.category(ch)
if cat.startswith("P") or cat.startswith("Z"):
continue
chars.append(ch)
return "".join(chars)
def edit_distance(a, b) -> int:
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(
min(
prev[j] + 1,
cur[j - 1] + 1,
prev[j - 1] + (ca != cb),
)
)
prev = cur
return prev[-1]
def compute_cer(refs, hyps) -> float:
n_err = 0
n_chars = 0
for ref, hyp in zip(refs, hyps):
n_err += edit_distance(list(ref), list(hyp))
n_chars += len(ref)
return n_err / max(1, n_chars)
def per_utt_cer(refs, hyps):
out = []
for ref, hyp in zip(refs, hyps):
out.append(edit_distance(list(ref), list(hyp)) / max(1, len(ref)))
return out
def compute_wer(refs, hyps):
try:
from jiwer import compute_measures
except Exception:
try:
from jiwer import wer
return float(wer(refs, hyps))
except Exception:
# Fallback: simple per-word edit distance.
from difflib import SequenceMatcher
n_err = 0
n_words = 0
for r, h in zip(refs, hyps):
rw, hw = r.split(), h.split()
n_words += len(rw)
sm = SequenceMatcher(None, rw, hw)
n_err += sum(
max(b - a, d - c)
for tag, a, b, c, d in sm.get_opcodes()
if tag != "equal"
)
return n_err / max(1, n_words)
return compute_measures("\n".join(refs), "\n".join(hyps))["wer"]
def per_utt_wer(refs, hyps):
try:
from jiwer import compute_measures
except Exception:
try:
from jiwer import wer
return [float(wer(r, h)) for r, h in zip(refs, hyps)]
except Exception:
return [None] * len(refs)
out = []
for r, h in zip(refs, hyps):
try:
out.append(compute_measures(r, h)["wer"])
except Exception:
out.append(None)
return out
def main() -> int:
args = parse_args()
listing_path = Path(args.listing)
items = []
for line in listing_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
parts = line.split("|")
if len(parts) >= 3:
items.append((parts[0], parts[1], parts[2]))
if args.item_start < 0:
raise ValueError(f"--item_start must be >= 0, got {args.item_start}")
if args.item_stride < 1:
raise ValueError(f"--item_stride must be >= 1, got {args.item_stride}")
if args.item_start or args.item_stride != 1:
items = items[args.item_start :: args.item_stride]
if args.max_items > 0:
items = items[: args.max_items]
print(f"[score] {len(items)} items", file=sys.stderr)
# ----- WER pass -----
print("[wer] loading Whisper", file=sys.stderr)
import soundfile as sf
import scipy.signal
from transformers import WhisperForConditionalGeneration, WhisperProcessor
processor = WhisperProcessor.from_pretrained(args.whisper_model)
asr = WhisperForConditionalGeneration.from_pretrained(args.whisper_model).to(args.device)
asr.eval()
whisper_lang = "english" if args.lang == "en" else "chinese"
forced_ids = processor.get_decoder_prompt_ids(language=whisper_lang, task="transcribe")
refs, hyps = [], []
per_utt = []
n_skip = 0
for i, (gen_wav, ref_wav, text_ref) in enumerate(items):
try:
wav, sr = sf.read(gen_wav)
if wav.ndim > 1:
wav = wav.mean(axis=1)
if sr != 16000:
wav = scipy.signal.resample(wav, int(len(wav) * 16000 / sr))
feat = processor(wav, sampling_rate=16000, return_tensors="pt").input_features.to(args.device)
feat = feat.to(dtype=next(asr.parameters()).dtype)
with torch.no_grad():
pred = asr.generate(feat, forced_decoder_ids=forced_ids)
hyp = processor.batch_decode(pred, skip_special_tokens=True)[0]
except Exception as e:
n_skip += 1
print(f"[wer-skip] {gen_wav}: {e}", file=sys.stderr)
continue
if args.lang == "zh":
ref_n = normalise_zh(text_ref)
hyp_n = normalise_zh(hyp)
else:
ref_n = normalise_en(text_ref)
hyp_n = normalise_en(hyp)
refs.append(ref_n)
hyps.append(hyp_n)
per_utt.append({"gen_wav": gen_wav, "ref": ref_n, "hyp": hyp_n})
if (i + 1) % 50 == 0:
print(f"[wer] {i+1}/{len(items)}", file=sys.stderr)
if args.lang == "zh":
wer_corpus = float(compute_cer(refs, hyps))
wer_per = per_utt_cer(refs, hyps)
error_metric = "cer"
else:
wer_corpus = float(compute_wer(refs, hyps))
wer_per = per_utt_wer(refs, hyps)
error_metric = "wer"
for r, w in zip(per_utt, wer_per):
r[error_metric] = w
print(f"[wer] corpus {error_metric.upper()} = {wer_corpus*100:.3f}%", file=sys.stderr)
# ----- SIM pass -----
print("[sim] loading speaker model", file=sys.stderr)
sims = []
sim_model_used = None
if args.skip_sim:
print("[sim] skipped", file=sys.stderr)
sim_model_used = "skipped"
elif args.wavlm_sv_ckpt and Path(args.wavlm_sv_ckpt).exists():
# Use the upstream UniSpeech wavlm_large finetune. We assume
# `thirdparty/UniSpeech/...` exists in PYTHONPATH so we can call it.
sim_model_used = "wavlm_large_finetune"
# TODO: load the upstream verification model. For now, fall back below.
if sim_model_used is None:
from transformers import AutoFeatureExtractor, AutoModelForAudioXVector
default_local_wavlm = PROJECT_ROOT / "models" / "wavlm-base-plus-sv"
sim_model_id = args.wavlm_model
sim_model_path = Path(sim_model_id)
if sim_model_path.exists():
sim_model_id = str(sim_model_path)
sim_model_used = "wavlm-base-plus-sv (local)"
elif sim_model_id == str(default_local_wavlm):
sim_model_id = "microsoft/wavlm-base-plus-sv"
sim_model_used = sim_model_id
else:
sim_model_used = sim_model_id
feat_ext = AutoFeatureExtractor.from_pretrained(sim_model_id)
sim_model = AutoModelForAudioXVector.from_pretrained(sim_model_id).to(args.device).eval()
for i, (gen_wav, ref_wav, _) in enumerate(items):
try:
w_g, sr_g = sf.read(gen_wav)
w_r, sr_r = sf.read(ref_wav)
if w_g.ndim > 1:
w_g = w_g.mean(axis=1)
if w_r.ndim > 1:
w_r = w_r.mean(axis=1)
if sr_g != 16000:
w_g = scipy.signal.resample(w_g, int(len(w_g) * 16000 / sr_g))
if sr_r != 16000:
w_r = scipy.signal.resample(w_r, int(len(w_r) * 16000 / sr_r))
feats = feat_ext([w_g, w_r], sampling_rate=16000, return_tensors="pt", padding=True).to(args.device)
with torch.no_grad():
out = sim_model(**feats)
emb = torch.nn.functional.normalize(out.embeddings, dim=-1)
sim = float((emb[0] * emb[1]).sum().item())
sims.append(sim)
except Exception as e:
print(f"[sim-skip] {gen_wav}: {e}", file=sys.stderr)
continue
if (i + 1) % 50 == 0:
print(f"[sim] {i+1}/{len(items)}", file=sys.stderr)
sim_mean = float(np.mean(sims)) if sims else None
per_utt_mean = (
float(np.mean([w for w in wer_per if w is not None]) * 100)
if wer_per
else None
)
out = {
"n_items": len(items),
"n_skipped_wer": n_skip,
"n_skipped_asr": n_skip,
"error_metric": error_metric,
"wer_corpus": wer_corpus,
"wer_corpus_pct": wer_corpus * 100,
"cer_corpus": wer_corpus if args.lang == "zh" else None,
"cer_corpus_pct": wer_corpus * 100 if args.lang == "zh" else None,
"wer_per_utt_mean": per_utt_mean,
"error_per_utt_mean": per_utt_mean,
"sim_mean": sim_mean,
"sim_n": len(sims),
"sim_model": sim_model_used,
"lang": args.lang,
}
out_path = Path(args.out_json)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
# Also write per-utt for paired tests.
(out_path.parent / "per_utt_wer.jsonl").write_text(
"\n".join(json.dumps(r) for r in per_utt), encoding="utf-8"
)
print(f"[done] wrote {out_path}", file=sys.stderr)
print(json.dumps(out, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())