File size: 9,487 Bytes
a4975fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Zero-shot eval on held-out speakers: intelligibility (MMS CER) + voice similarity.

For each held-out speaker: one real clip is the voice prompt, two other clips of
the same speaker give the target sentences. The real target recordings are
scored the same way, as the ceiling (system "real").

Usage (GPU box):
    python tools/eval_heldout.py build                      # writes /home/data/eval/items.json once
    python tools/eval_heldout.py run NAME CKPT [VOCAB]      # synthesize + score one checkpoint
    python tools/eval_heldout.py real                       # score the real recordings
"""
import hashlib
import json
import os
import re
import sys
import unicodedata

EVAL = "/home/data/eval"
MANIFEST = "/home/data/nepali_tts/manifest.jsonl"
HOLDOUT = "/home/data/ref/holdout_speakers.json"
IVF5_VOCAB = "/home/models/IndicF5/checkpoints/vocab.txt"
DEV = os.environ.get("EVAL_DEVICE", "cuda")


def build():
    held = set(json.load(open(HOLDOUT)))
    by = {}
    for line in open(MANIFEST, encoding="utf-8"):
        m = json.loads(line)
        if m["speaker"] in held:
            by.setdefault(m["speaker"], []).append(m)
    items = []
    for spk in sorted(by):
        clips = sorted(by[spk], key=lambda m: hashlib.md5(m["audio"].encode()).hexdigest())
        refs = [c for c in clips if 4 <= c["dur"] <= 10 and c["snr"] >= 25] or [c for c in clips if 3 <= c["dur"] <= 12]
        if not refs:
            continue
        ref = refs[0]
        targets = [c for c in clips if c is not ref and 3 <= c["dur"] <= 15][:2]
        for i, t in enumerate(targets):
            items.append({"id": f"{len(items):03d}", "speaker": spk, "ref_wav": ref["audio"], "ref_text": ref["text"],
                          "text": t["text"], "real_wav": t["audio"]})
    os.makedirs(EVAL, exist_ok=True)
    json.dump(items, open(f"{EVAL}/items.json", "w"), ensure_ascii=False, indent=1)
    print(f"{len(items)} items from {len({i['speaker'] for i in items})} held-out speakers")


def norm(s):
    if os.environ.get("DIGITS_AS_WORDS", "1") == "1" and re.search(r"[0-9०-९]", s):
        # ASR writes numbers as digits ("24 घण्टामा") while targets are spelled out;
        # expand them with our own normalizer so correct speech is not counted as wrong
        sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        from frontend import normalize
        s = normalize(s)
    s = unicodedata.normalize("NFC", s)
    s = re.sub(r"[।॥.,?!;:\"'()\-–—]", " ", s)
    return " ".join(s.split())


def ed(a, b):
    d = list(range(len(b) + 1))
    for i, x in enumerate(a, 1):
        p, d[0] = d[0], i
        for j, y in enumerate(b, 1):
            p, d[j] = d[j], min(d[j] + 1, d[j - 1] + 1, p + (x != y))
    return d[-1]


def score(name, wavs):
    """wavs: {item id: wav path}. Writes EVAL/NAME/scores.json and prints a summary."""
    import librosa
    import torch
    from transformers import AutoProcessor, Wav2Vec2ForCTC
    from speechbrain.inference.speaker import EncoderClassifier

    items = json.load(open(f"{EVAL}/items.json"))
    proc = AutoProcessor.from_pretrained("facebook/mms-1b-all", target_lang="npi")
    asr = Wav2Vec2ForCTC.from_pretrained("facebook/mms-1b-all", target_lang="npi",
                                         ignore_mismatched_sizes=True).to(DEV).eval()
    spk = EncoderClassifier.from_hparams("speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": DEV},
                                         savedir="/home/hf_cache/ecapa")

    def emb(path):
        w, _ = librosa.load(path, sr=16000)
        with torch.no_grad():
            return spk.encode_batch(torch.tensor(w).unsqueeze(0).to(DEV)).squeeze()

    from mos import utmos
    E = N = 0
    sims, moss, rows = [], [], []
    for it in items:
        p = wavs.get(it["id"])
        if not p or not os.path.exists(p):
            continue
        w, _ = librosa.load(p, sr=16000)
        inp = {k: v.to(DEV) for k, v in proc(w, sampling_rate=16000, return_tensors="pt").items()}
        with torch.no_grad():
            hyp = norm(proc.decode(asr(**inp).logits.argmax(-1)[0]))
        ref = norm(it["text"])
        e = ed(ref, hyp)
        E, N = E + e, N + len(ref)
        # similarity to the speaker's *other* real clip (not the prompt itself)
        sim = float(torch.nn.functional.cosine_similarity(emb(p), emb(it["real_wav"] if name != "real" else it["ref_wav"]), dim=0))
        sims.append(sim)
        moss.append(utmos(w))
        rows.append({"id": it["id"], "cer": e / max(1, len(ref)), "sim": sim, "mos": moss[-1], "hyp": hyp, "ref": ref})
    res = {"name": name, "n": len(rows), "cer": 100 * E / max(1, N), "sim": sum(sims) / max(1, len(sims)),
           "mos": sum(moss) / max(1, len(moss)), "rows": rows}
    os.makedirs(f"{EVAL}/{name}", exist_ok=True)
    json.dump(res, open(f"{EVAL}/{name}/scores.json", "w"), ensure_ascii=False, indent=1)
    print(f"{name}: CER {res['cer']:.2f}%  voice-sim {res['sim']:.3f}  UTMOS {res['mos']:.2f}  (n={res['n']})")
    return res


def text_converter():
    """TEXT_MODE=respell feeds the model our G2P written back as Devanagari
    (frontend.g2p.respell), matching a model trained on train_respell.csv."""
    mode = os.environ.get("TEXT_MODE", "chars")
    if mode == "nepal":  # Nepal pronunciation respelling (frontend.pron.nepal_accent) + pron fixes
        sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        from frontend.pron import apply_fixes, nepal_accent
        return lambda s: nepal_accent(apply_fixes(s))
    if mode == "fix":  # data/pron_fixes.json respellings (tools/mine_hard_words.py)
        sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        from frontend.pron import apply_fixes
        return apply_fixes
    if mode != "respell":
        return lambda s: s
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    from frontend import g2p, normalize
    from frontend.g2p import respell
    return lambda s: respell(g2p(normalize(s)))


def auto_ref_text():
    """REF_TEXT=asr: transcribe the prompt clip with MMS (npi) instead of using the gold
    transcript, as a real user would only have the audio. F5's own fallback is Whisper,
    which is weak on Nepali."""
    import librosa
    import torch
    from transformers import AutoProcessor, Wav2Vec2ForCTC
    proc = AutoProcessor.from_pretrained("facebook/mms-1b-all", target_lang="npi")
    asr = Wav2Vec2ForCTC.from_pretrained("facebook/mms-1b-all", target_lang="npi",
                                         ignore_mismatched_sizes=True).to(DEV).eval()
    cache = {}
    lm = None
    if os.environ.get("REF_TEXT") == "asr_lm":  # Devanagari-only + KenLM (tools/build_lm.py)
        from build_lm import decoder
        from rescore_deva import DevaMMS
        lm, mask = decoder(os.environ.get("LM", "/home/lm/ne4.bin"), 0.3, 2.0), DevaMMS.latin_mask(proc)

    def f(it):
        if it["ref_wav"] not in cache:
            w, _ = librosa.load(it["ref_wav"], sr=16000)
            inp = {k: v.to(DEV) for k, v in proc(w, sampling_rate=16000, return_tensors="pt").items()}
            with torch.no_grad():
                lg = asr(**inp).logits[0]
            cache[it["ref_wav"]] = proc.decode(lg.argmax(-1)) if lm is None else \
                lm.decode(torch.log_softmax(lg.float() + mask, -1).cpu().numpy(), beam_width=64)
        return cache[it["ref_wav"]]
    return f


def run(name, ckpt, vocab=IVF5_VOCAB, arch=os.environ.get("F5_MODEL", "F5TTS_Base")):
    from f5_tts.api import F5TTS
    items = json.load(open(f"{EVAL}/items.json"))
    out = f"{EVAL}/{name}"
    os.makedirs(out, exist_ok=True)
    tts = F5TTS(model=arch, ckpt_file=ckpt, vocab_file=vocab)
    tts.ema_model.float()  # IndicF5-derived weights overflow in fp16 inference
    # inference knobs for sweeps (F5 defaults: nfe 32, cfg 2.0, sway -1.0, speed 1.0)
    knobs = {"nfe_step": int(os.environ.get("NFE", 32)), "cfg_strength": float(os.environ.get("CFG", 2.0)),
             "sway_sampling_coef": float(os.environ.get("SWAY", -1.0)), "speed": float(os.environ.get("SPEED", 1.0))}
    conv = text_converter()
    ref_text = auto_ref_text() if os.environ.get("REF_TEXT", "").startswith("asr") else (lambda it: it["ref_text"])
    from enhance import enhance  # REF_ENHANCE=dfn cleans the prompt clip first
    wavs = {}
    for it in items:
        p = f"{out}/{it['id']}.wav"
        if not os.path.exists(p):
            tts.infer(ref_file=enhance(it["ref_wav"], os.environ.get("REF_ENHANCE")), ref_text=conv(ref_text(it)), gen_text=conv(it["text"]), file_wave=p,
                      remove_silence=True, seed=0, **knobs)
        wavs[it["id"]] = p
    del tts
    import torch
    torch.cuda.empty_cache()
    return score(name, wavs)


if __name__ == "__main__":
    cmd = sys.argv[1]
    if cmd == "build":
        build()
    elif cmd == "rescore":  # re-score an existing run dir (e.g. after adding a metric)
        items = json.load(open(f"{EVAL}/items.json"))
        n = sys.argv[2]
        src = (lambda it: it["real_wav"]) if n == "real" else (lambda it: f"{EVAL}/{n}/{it['id']}.wav")
        score(n, {it["id"]: src(it) for it in items})
    elif cmd == "real":
        items = json.load(open(f"{EVAL}/items.json"))
        score("real", {it["id"]: it["real_wav"] for it in items})
    elif cmd == "run":
        run(sys.argv[2], sys.argv[3], *(sys.argv[4:5] or []))