File size: 10,752 Bytes
6e802a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | # eval_suite.py: Scores generated cells: WER (field protocol), prefix WER, completion, UTMOS, per row and per cell.
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / "scripts"))
GATES = {}
def suite_for_arm(codes_jsonl, wavdir, scorer, utmos, device="cuda", mos_panel=False):
from fan_metric import flip2
from score_naturalness_panel import (am_probe, f0_track, flutter_percent,
hf_percent)
from artifact_metrics import (autotune_metrics, prosody_metrics,
truncation_metrics)
from contour_metrics import contour_metrics
rows = [json.loads(l) for l in open(codes_jsonl, encoding="utf-8") if l.strip()]
coarse, wers, utm, amp, flut, hf = [], [], [], [], [], []
comp, pwer, stair, f0sd, pros, fastp = [], [], [], [], [], []
hyps, flut_row = [], []
for i, r in enumerate(rows):
for lv in (r.get("residual_codes") or [])[:3]:
f = flip2(lv)
if f is not None:
coarse.append(100.0 * f)
w = Path(wavdir) / f"sample_{i:02d}.wav"
if not w.exists():
continue
audio, sr = sf.read(str(w))
if audio.ndim > 1:
audio = audio.mean(axis=1)
hf.append(hf_percent(audio, sr))
_am_pct, am_peak = am_probe(audio, sr)
if am_peak == am_peak:
amp.append(am_peak)
f0, fs = f0_track(audio, sr)
fl = flutter_percent(f0, fs)
if fl is not None and not np.isnan(fl):
flut.append(fl)
flut_row.append(float(fl) if fl is not None and not np.isnan(fl) else float("nan"))
if utmos is not None:
import librosa
import torch
y = librosa.resample(audio.astype(np.float32), orig_sr=sr, target_sr=16000)
with torch.no_grad():
utm.append(float(utmos(torch.from_numpy(y).unsqueeze(0).to(device),
16000).item()))
stair.append(autotune_metrics(audio, sr)["stair"])
f0sd.append(prosody_metrics(audio, sr)["f0_sd_st"])
_c = contour_metrics(audio, sr)
pros.append(_c["prosodic"]); fastp.append(_c["fast"])
if scorer is not None and r.get("prompt"):
hyp = scorer.transcribe(str(w))
hyps.append(hyp)
t = truncation_metrics(r["prompt"], hyp)
wers.append(t["wer"])
comp.append(t["completion"])
pwer.append(t["prefix_wer"])
def mean(xs):
xs = [v for v in xs if v == v]
return float(np.mean(xs)) if xs else float("nan")
extra = {}
if mos_panel:
import mos_metrics
acc, n_attempted = {}, 0
errs: dict = {}
def _try(label, fn):
try:
return fn()
except Exception as e:
errs.setdefault(label, f"{type(e).__name__}: {e}")
return {}
for i, r in enumerate(rows):
w = Path(wavdir) / f"sample_{i:02d}.wav"
if not w.exists():
errs.setdefault("missing_wav", f"first missing: {w}")
continue
n_attempted += 1
a, sr = sf.read(str(w))
a = a.mean(axis=1) if a.ndim > 1 else a
m = {}
m.update(_try("dnsmos", lambda: mos_metrics.dnsmos_scores(a, sr)))
m.update(_try("plcmos",
lambda: {"plcmos": mos_metrics.plcmos_score(a, sr)}))
s = _try("squim", lambda: mos_metrics.squim_scores(a, sr))
m.update({k: s[k] for k in ("squim_stoi", "squim_pesq",
"squim_sisdr") if k in s})
for k, v in m.items():
acc.setdefault(k, []).append(v)
extra = {k: mean(v) for k, v in acc.items()}
extra["mos_panel_n"] = {k: len(v) for k, v in acc.items()}
extra["mos_panel_attempted"] = n_attempted
if errs or any(len(v) != n_attempted for v in acc.values()):
print(f" [mos_panel] WARNING for {codes_jsonl}: attempted "
f"{n_attempted}/{len(rows)} clip(s); per-metric n="
f"{extra['mos_panel_n']}. A metric with a smaller n was "
f"averaged over a DIFFERENT subset than the others and than "
f"the other arms. First error per scorer: {errs}", flush=True)
extra["per_row"] = {"wer": [float(x) for x in wers],
"prefix_wer": [float(x) for x in pwer],
"utmos": [float(x) for x in utm],
"completion": [float(x) for x in comp],
"hyp": list(hyps), "flutter": flut_row}
return {**extra,
"n": len(rows), "wer": mean(wers), "utmos": mean(utm),
"flip2_coarse": mean(coarse), "ampk": mean(amp),
"flutter": mean(flut), "hf": mean(hf),
"completion": mean(comp), "prefix_wer": mean(pwer),
"stair": mean(stair), "f0_sd_st": mean(f0sd),
"prosodic": mean(pros), "fast": mean(fastp)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--arm", nargs=3, action="append", metavar=("NAME", "CODES", "WAVDIR"),
required=True, help="repeatable: NAME codes.jsonl wavdir")
ap.add_argument("--device", default="cuda")
ap.add_argument("--whisper", default="openai/whisper-base.en")
ap.add_argument("--whisper_norm", default="simple", choices=["simple", "whisper"],
help="WER text normalisation: simple (legacy, all pre-S209 rows) or whisper "
"(Whisper EnglishTextNormalizer - the Seed-TTS/F5/CosyVoice convention; "
"use with --whisper openai/whisper-large-v3 for field-comparable WER)")
ap.add_argument("--no_wer", action="store_true")
ap.add_argument("--custom_axes", action="store_true",
help="also print the home-grown texture axes (flip2, amPk, flutter, "
"stair, f0sd, prosodic%%/fast%%). DIAGNOSTIC ONLY - scrapped as "
"verdicts on 2026-09-15; they are always dumped to --out.")
ap.add_argument("--no_utmos", action="store_true")
ap.add_argument("--out", default="out_eval_suite.json")
ap.add_argument("--mos_panel", action="store_true",
help="add the VALIDATED extra predictors (DNSMOS P.835, "
"PLCMOS, SQUIM objective) as a second table. Each "
"passed the known-answer ladder in "
"scripts/validate_mos_metrics.py; squim_mos did not "
"and is excluded. Reported, not gated.")
args = ap.parse_args()
scorer = None
if not args.no_wer:
from eval.intelligibility import IntelligibilityScorer
scorer = IntelligibilityScorer(model_id=args.whisper, device=args.device, normalizer=args.whisper_norm)
if args.mos_panel:
import mos_metrics
mos_metrics._init_scorers()
utmos = None
if not args.no_utmos:
import torch
utmos = torch.hub.load("tarepan/SpeechMOS:v1.2.0", "utmos22_strong",
trust_repo=True).to(args.device).eval()
res = {}
for name, codes, wavdir in args.arm:
if not Path(codes).exists() or not Path(wavdir).exists():
print(f"[skip] {name}: missing {codes} or {wavdir}")
continue
res[name] = suite_for_arm(codes, wavdir, scorer, utmos, args.device,
mos_panel=args.mos_panel)
hdr = f"\n{'arm':<24} {'n':>4} {'WER':>6} {'pWER':>6} {'comp':>6} {'UTMOS':>6}"
if args.custom_axes:
hdr += (f" {'flip2c':>7} {'amPk':>6} {'flutr':>6} {'stair':>6} {'f0sd':>5}"
f" {'pros%':>6} {'fast%':>6}")
print(hdr)
print("-" * (len(hdr) - 1))
for name, m in res.items():
line = (f"{name:<24} {m['n']:>4} {m['wer']:>6.3f} {m['prefix_wer']:>6.3f} "
f"{m['completion']:>6.2f} {m['utmos']:>6.2f}")
if args.custom_axes:
line += (f" {m['flip2_coarse']:>7.2f} {m['ampk']:>6.1f} {m['flutter']:>6.1f} "
f"{m['stair']:>6.1f} {m['f0_sd_st']:>5.2f} {m['prosodic']:>6.1f} "
f"{m['fast']:>6.1f}")
print(line)
proto = ("field protocol" if args.whisper_norm == "whisper"
else "LEGACY normaliser - not field-comparable")
print(f"\nWER: whisper={args.whisper} normaliser={args.whisper_norm} ({proto}); "
"pWER = WER over the covered prefix; comp = fraction of the target spoken; "
"UTMOS = UTMOS22, reported without a threshold.")
print("No gates, no PASS/FAIL (verdict rule 2026-09-15): WER, industry-standard "
"metrics, benchmarks and listening tests are the only verdicts."
+ (" The custom axes above are DIAGNOSTIC ONLY." if args.custom_axes else ""))
if args.mos_panel:
cols = [("dnsmos_ovrl", "dnsOVRL"), ("dnsmos_sig", "dnsSIG"),
("dnsmos_bak", "dnsBAK"), ("dnsmos_p808", "dnsP808"),
("plcmos", "PLCMOS"), ("squim_stoi", "sqSTOI"),
("squim_pesq", "sqPESQ"), ("squim_sisdr", "sqSISDR")]
print(f"\n{'arm':<24}" + "".join(f"{lab:>9}" for _, lab in cols)
+ f"{'n(min/att)':>12}")
for name, m in res.items():
ns = m.get("mos_panel_n") or {}
lo = min(ns.values()) if ns else 0
print(f"{name:<24}"
+ "".join(f"{m.get(k, float('nan')):>9.2f}" for k, _ in cols)
+ f"{lo:>6}/{m.get('mos_panel_attempted', 0):<5}")
print("\nEXTRA PREDICTORS — REPORTED, NOT GATED. Each passed the 20/10/5 dB")
print("noise ladder in scripts/validate_mos_metrics.py; PLCMOS is the")
print("strongest DISCONTINUITY detector tested (-1.47 on 5% frame dropout")
print("vs UTMOS -0.85). squim_mos is EXCLUDED: it scored NOISIER speech")
print("higher (3.48 -> 3.97 across the ladder) and failed outright.")
print("NONE of them detects autotune: against a WORLD analysis-resynthesis")
print("control, semitone-quantised F0 moves UTMOS -0.02, DNSMOS -0.04 and")
print("SQUIM-STOI 0.00, on audio that is autotuned by construction. Do not")
print("read a good score here as evidence that autotune is absent.")
Path(args.out).write_text(json.dumps(res, indent=2), encoding="utf-8")
print(f"-> {args.out}\nEVAL_SUITE_DONE")
if __name__ == "__main__":
main()
|