s2pro-egy-v6 / scripts /gate_eval.py
ehabnegm's picture
Upload scripts/gate_eval.py with huggingface_hub
43c7da3 verified
Raw History Blame Contribute Delete
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))