Download scripts/gate_eval.py from ehabnegm/s2pro-egy-v6: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/ehabnegm/s2pro-egy-v6/resolve/main/scripts/gate_eval.py
- Command line
-
hf download hf://ehabnegm/s2pro-egy-v6/scripts/gate_eval.py
-
curl -L -o gate_eval.py https://huggingface.co/ehabnegm/s2pro-egy-v6/resolve/main/scripts/gate_eval.py
14.8 kB
| #!/usr/bin/env python3 | |
| """Free-running synthesis gate + ASR scoring for the base model or a LoRA checkpoint. | |
| For every sentence x reference (x seed): synthesize with voice cloning (reference in the system | |
| prompt, exactly like inference), save the wav, and measure | |
| * duration ratio = generated seconds / (words / 2.5) (babble or early stop) | |
| * active fraction = share of 100 ms frames with RMS > 0.02 (silence collapse) | |
| * tail ratio = RMS(last 25%) / RMS(first 75%) (fade-out collapse, July failure) | |
| then transcribe with the Egyptian-dialect Whisper judge and compute normalised WER / CER. | |
| Usage (fish-speech root, uv env, HF_HOME=/root/work/hf): | |
| python scripts/gate_eval.py --tag base --sentences data/eval/quick.jsonl --out results/eval/base | |
| python scripts/gate_eval.py --tag s250 --lora results/<run>/checkpoints/step_000000250.ckpt \ | |
| --lora-config r_32_alpha_16_slow --sentences data/eval/quick.jsonl --out results/eval/s250 | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from pathlib import Path | |
| os.environ.setdefault("HF_HOME", "/root/work/hf") | |
| os.environ.setdefault("OMP_NUM_THREADS", "8") | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import torchaudio | |
| from hydra import compose, initialize | |
| from hydra.utils import instantiate | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from fish_speech.models.text2semantic.inference import ( # noqa: E402 | |
| decode_one_token_ar, decode_to_audio, encode_audio, generate_long, load_codec_model, | |
| ) | |
| from fish_speech.models.text2semantic.llama import BaseTransformer, DualARTransformer # noqa: E402 | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--base", default="checkpoints/s2-pro") | |
| ap.add_argument("--lora", default=None, help="Lightning .ckpt with LoRA weights (unmerged eval)") | |
| ap.add_argument("--lora-config", default="r_32_alpha_16_slow") | |
| ap.add_argument("--merged", default=None, help="merged model dir (alternative to --lora)") | |
| ap.add_argument("--tag", required=True) | |
| ap.add_argument("--sentences", default="data/eval/quick.jsonl") | |
| ap.add_argument("--refs", default="data/eval/refs.jsonl") | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--seeds", type=int, default=1) | |
| ap.add_argument("--temperature", type=float, default=0.8) | |
| ap.add_argument("--top-p", type=float, default=0.8) | |
| ap.add_argument("--max-refs", type=int, default=99) | |
| ap.add_argument("--merge-mode", choices=["unmerged", "bf16"], default="unmerged", | |
| help="unmerged: keep the LoRA as a separate fp-accurate path (default); bf16: merge into bf16 weights (old behaviour)") | |
| ap.add_argument("--no-asr", action="store_true") | |
| ap.add_argument("--prefix", default="", help="text prepended to every synthesized sentence and to the reference text (e.g. \"[egyptian accent] \")") | |
| ap.add_argument("--judge", choices=["soniox", "whisper"], default=None, | |
| help="default: soniox when SONIOX_API_KEY is set, else whisper") | |
| ap.add_argument("--asr-model", default="oddadmix/whisper-large-v3-turbo-arabic-dialectal-v2") | |
| args = ap.parse_args() | |
| judge = args.judge or ("soniox" if os.environ.get("SONIOX_API_KEY") else "whisper") | |
| SONIOX_API = os.environ.get("SONIOX_API_BASE", "https://api.soniox.com").rstrip("/") # EU keys: https://api.eu.soniox.com | |
| if judge == "soniox": | |
| import requests | |
| try: | |
| code = requests.get(f"{SONIOX_API}/v1/models", timeout=20, | |
| headers={"Authorization": f"Bearer {os.environ.get('SONIOX_API_KEY', '')}"}).status_code | |
| except Exception as e: | |
| code = repr(e) | |
| if code != 200: | |
| print(f"WARNING: Soniox key check failed ({code}); falling back to the Whisper judge") | |
| judge = "whisper" | |
| device = "cuda" | |
| out = Path(args.out); (out / "wav").mkdir(parents=True, exist_ok=True) | |
| sentences = [json.loads(l) for l in Path(args.sentences).read_text(encoding="utf-8").splitlines() if l.strip()] | |
| refs = [json.loads(l) for l in Path(args.refs).read_text(encoding="utf-8").splitlines() if l.strip()][: args.max_refs] | |
| # ---------------------------------------------------------------- model | |
| t0 = time.time() | |
| if args.lora: | |
| with initialize(version_base="1.3", config_path="../fish_speech/configs/lora"): | |
| lc = instantiate(compose(config_name=args.lora_config)) | |
| model = BaseTransformer.from_pretrained(args.base, load_weights=True, max_length=8192, lora_config=lc) | |
| model = model.to(device=device, dtype=torch.bfloat16).train() # July trap 11: train() -> load -> eval() | |
| sd = torch.load(args.lora, map_location="cpu", weights_only=False) | |
| sd = sd.get("state_dict", sd) | |
| sd = {k[len("model."):] if k.startswith("model.") else k: v for k, v in sd.items() if "lora" in k} | |
| missing, unexpected = model.load_state_dict(sd, strict=False) | |
| n_loaded = len(sd) | |
| assert not unexpected, unexpected[:5] | |
| if args.merge_mode == "unmerged": | |
| # keep W and the LoRA term separate: merging a ~0.1% update into bf16 weights rounds most of it away | |
| for m in model.modules(): | |
| if hasattr(m, "merge_weights"): | |
| m.merge_weights = False | |
| model.eval() # bf16 mode: loralib merges here | |
| print(f"loaded {n_loaded} LoRA tensors from {args.lora} (merge mode: {args.merge_mode})") | |
| else: | |
| src = args.merged or args.base | |
| model = DualARTransformer.from_pretrained(src, load_weights=True, max_length=8192) | |
| model = model.to(device=device, dtype=torch.bfloat16).eval() | |
| model.fixed_temperature = torch.tensor(0.7, device=device) | |
| model.fixed_top_p = torch.tensor(0.7, device=device) | |
| model.fixed_repetition_penalty = torch.tensor(1.5, device=device) | |
| model._cache_setup_done = False | |
| with torch.device(device): | |
| model.setup_caches(max_batch_size=1, max_seq_len=model.config.max_seq_len, dtype=torch.bfloat16) | |
| codec = load_codec_model(Path(args.base) / "codec.pth", device) | |
| sr = codec.sample_rate | |
| print(f"model ready in {time.time()-t0:.0f}s | codec sr {sr}") | |
| ref_codes = {r["name"]: encode_audio(r["wav"], codec, device).cpu() for r in refs} | |
| for r in refs: | |
| print(f"ref {r['name']}: {tuple(ref_codes[r['name']].shape)} codes") | |
| def synth(text, ref): | |
| chunks = [] | |
| gen = generate_long( | |
| model=model, device=device, decode_one_token=decode_one_token_ar, text=text, num_samples=1, | |
| max_new_tokens=0, top_p=args.top_p, top_k=30, temperature=args.temperature, | |
| repetition_penalty=1.1, compile=False, iterative_prompt=True, chunk_length=300, | |
| prompt_text=[args.prefix + ref["text"]], prompt_tokens=[ref_codes[ref["name"]]], | |
| ) | |
| for resp in gen: | |
| if resp.action == "sample" and resp.codes is not None: | |
| chunks.append(resp.codes) | |
| codes = torch.cat(chunks, dim=1) if len(chunks) > 1 else chunks[0] | |
| wav = decode_to_audio(codes.to(device), codec).float().cpu().numpy() | |
| return codes.shape[1], wav | |
| def audio_stats(wav, words): | |
| dur = len(wav) / sr | |
| frame = int(sr * 0.1) | |
| frames = [wav[i:i + frame] for i in range(0, max(frame, len(wav) - frame + 1), frame)] | |
| rms = np.array([np.sqrt(np.mean(f ** 2)) for f in frames if len(f)]) | |
| active = float((rms > 0.02).mean()) if len(rms) else 0.0 | |
| k = max(1, int(0.75 * len(rms))) | |
| head = float(np.sqrt(np.mean(rms[:k] ** 2))) if k else 0.0 | |
| tail = float(np.sqrt(np.mean(rms[k:] ** 2))) if len(rms) > k else 0.0 | |
| return {"dur_s": round(dur, 2), "dur_ratio": round(dur / max(0.5, words / 2.5), 2), | |
| "active_frac": round(active, 3), "tail_ratio": round(tail / head, 3) if head > 1e-6 else 0.0} | |
| rows = [] | |
| t1 = time.time() | |
| for s in sentences: | |
| for ref in refs: | |
| for seed in range(args.seeds): | |
| torch.manual_seed(seed); torch.cuda.manual_seed(seed) | |
| name = f"{s['id']}__{ref['name']}__s{seed}" | |
| try: | |
| n_codes, wav = synth(args.prefix + s["text"], ref) | |
| except Exception as e: # keep going; record the failure | |
| print("GEN ERROR", name, repr(e)[:200]); rows.append({"name": name, "error": repr(e)[:200]}); continue | |
| path = out / "wav" / f"{name}.wav" | |
| sf.write(str(path), np.clip(wav, -1.0, 1.0), sr, subtype="PCM_16") | |
| st = audio_stats(wav, s.get("words") or len(s["text"].split())) | |
| rows.append({"name": name, "id": s["id"], "src": s.get("src"), "ref": ref["name"], "seed": seed, | |
| "text": s["text"], "asr_text": s.get("asr_text"), "n_codes": int(n_codes), "wav": str(path), **st}) | |
| print(f"{len(rows):4d}/{len(sentences)*len(refs)*args.seeds} {name} {st['dur_s']}s ratio={st['dur_ratio']} act={st['active_frac']} tail={st['tail_ratio']}", flush=True) | |
| print(f"synthesis done in {(time.time()-t1)/60:.1f} min") | |
| # ---------------------------------------------------------------- ASR judge | |
| DIAC = re.compile(r"[ؐ-ًؚ-ٰٟۖ-ۭـ]") | |
| PUNCT = re.compile(r"[^\w\s]", re.UNICODE) | |
| AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789") | |
| def norm(t): | |
| t = DIAC.sub("", t).translate(AR_DIGITS) | |
| t = t.replace("أ", "ا").replace("إ", "ا").replace("آ", "ا").replace("ى", "ي").replace("ة", "ه").replace("ؤ", "و").replace("ئ", "ي") | |
| t = PUNCT.sub(" ", t) | |
| return " ".join(t.split()) | |
| def edit_distance(a, b): | |
| prev = list(range(len(b) + 1)) | |
| for i, x in enumerate(a, 1): | |
| cur = [i] | |
| for j, y in enumerate(b, 1): | |
| cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (x != y))) | |
| prev = cur | |
| return prev[-1] | |
| def soniox_transcribe(path, retries=3): | |
| """Soniox stt-async-v5 (same engine as the July judge and the podcast transcripts).""" | |
| import requests | |
| api = SONIOX_API | |
| hdr = {"Authorization": f"Bearer {os.environ['SONIOX_API_KEY']}"} | |
| last = None | |
| for attempt in range(retries): | |
| try: | |
| with open(path, "rb") as f: | |
| r = requests.post(f"{api}/v1/files", headers=hdr, files={"file": (Path(path).name, f, "audio/wav")}, timeout=120) | |
| r.raise_for_status() | |
| file_id = r.json()["id"] | |
| try: | |
| r = requests.post(f"{api}/v1/transcriptions", headers=hdr, | |
| json={"file_id": file_id, "model": "stt-async-v5", "language_hints": ["ar"]}, timeout=30) | |
| r.raise_for_status() | |
| tid = r.json()["id"] | |
| try: | |
| for _ in range(180): | |
| s = requests.get(f"{api}/v1/transcriptions/{tid}", headers=hdr, timeout=30).json() | |
| if s.get("status") == "completed": | |
| break | |
| if s.get("status") == "error": | |
| raise RuntimeError(f"soniox error: {s.get('error_message')}") | |
| time.sleep(1) | |
| else: | |
| raise TimeoutError("soniox poll timeout") | |
| return requests.get(f"{api}/v1/transcriptions/{tid}/transcript", headers=hdr, timeout=30).json().get("text", "") | |
| finally: | |
| requests.delete(f"{api}/v1/transcriptions/{tid}", headers=hdr, timeout=30) | |
| finally: | |
| requests.delete(f"{api}/v1/files/{file_id}", headers=hdr, timeout=30) | |
| except Exception as e: # transient API errors: retry | |
| last = e | |
| time.sleep(3 * (attempt + 1)) | |
| raise RuntimeError(f"soniox failed after {retries} attempts: {last!r}") | |
| def score(r, hyp): | |
| if r.get("asr_text"): # phoneme-tag sets: plain reference, digits verbalised on both sides (Soniox writes numerals) | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| from egy_numbers import verbalize | |
| ref_n, hyp_n = norm(verbalize(r["asr_text"])), norm(verbalize(hyp)) | |
| else: | |
| ref_n, hyp_n = norm(r["text"]), norm(hyp) | |
| rw, hw = ref_n.split(), hyp_n.split() | |
| r["hyp"] = hyp | |
| r["wer"] = round(edit_distance(rw, hw) / max(1, len(rw)), 4) | |
| r["cer"] = round(edit_distance(ref_n.replace(" ", ""), hyp_n.replace(" ", "")) / max(1, len(ref_n.replace(" ", ""))), 4) | |
| if not args.no_asr and rows: | |
| todo = [r for r in rows if "wav" in r] | |
| print(f"ASR judge: {judge} on {len(todo)} files") | |
| if judge == "soniox": | |
| from concurrent.futures import ThreadPoolExecutor | |
| def one(r): | |
| try: | |
| return soniox_transcribe(r["wav"]) | |
| except Exception as e: | |
| return e | |
| with ThreadPoolExecutor(max_workers=8) as ex: | |
| for r, hyp in zip(todo, ex.map(one, todo)): | |
| if isinstance(hyp, Exception): | |
| r["asr_error"] = repr(hyp)[:200]; print("ASR ERROR", r["name"], r["asr_error"]) | |
| else: | |
| score(r, hyp) | |
| else: | |
| del model; torch.cuda.empty_cache() | |
| from transformers import pipeline | |
| asr = pipeline("automatic-speech-recognition", model=args.asr_model, torch_dtype=torch.float16, device=0) | |
| for r in todo: | |
| wav, s_r = sf.read(r["wav"], dtype="float32") | |
| wav16 = torchaudio.functional.resample(torch.from_numpy(wav), s_r, 16000).numpy() | |
| hyp = asr({"raw": wav16, "sampling_rate": 16000}, chunk_length_s=30, | |
| generate_kwargs={"language": "ar", "task": "transcribe"})["text"] | |
| score(r, hyp) | |
| # ---------------------------------------------------------------- report | |
| ok = [r for r in rows if "wav" in r] | |
| scored = [r for r in ok if "wer" in r] | |
| def mean(k, rs=None): rs = ok if rs is None else rs; return round(float(np.mean([r[k] for r in rs])), 4) if rs else None | |
| summary = { | |
| "tag": args.tag, "judge": judge if not args.no_asr else None, "n": len(ok), "errors": len(rows) - len(ok), | |
| "n_scored": len(scored), "wer": mean("wer", scored), "cer": mean("cer", scored), | |
| "dur_ratio_median": round(float(np.median([r["dur_ratio"] for r in ok])), 3) if ok else None, | |
| "active_frac_mean": mean("active_frac"), "tail_ratio_mean": mean("tail_ratio"), | |
| "collapsed": sum(1 for r in ok if r["tail_ratio"] < 0.3 or r["active_frac"] < 0.5), | |
| "too_long": sum(1 for r in ok if r["dur_ratio"] > 1.6), "too_short": sum(1 for r in ok if r["dur_ratio"] < 0.5), | |
| } | |
| for src in sorted({r["src"] for r in scored}): | |
| sub = [r for r in scored if r["src"] == src] | |
| summary[f"wer_{src}"] = round(float(np.mean([r["wer"] for r in sub])), 4) | |
| for ref in sorted({r["ref"] for r in scored}): | |
| sub = [r for r in scored if r["ref"] == ref] | |
| summary[f"wer_ref_{ref}"] = round(float(np.mean([r["wer"] for r in sub])), 4) | |
| (out / "rows.jsonl").write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8") | |
| (out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") | |
| print("SUMMARY", json.dumps(summary, ensure_ascii=False)) | |