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