#!/usr/bin/env python3 """ Run evaluation locally against locally-staged packed conditions, and write the two CSVs the Space normally publishes to the bucket as plain files on disk: - /leaderboard.csv (same schema as results/leaderboard.csv) - /jobs_state.csv (same schema as results/jobs_state.csv) Nothing is pushed to the Hub. The packed test tensors are read from `--packed-dir` instead of being downloaded from the bucket — point it at `data/new_local_generated/packed_small/`, which mirrors `data/packed/` in the bucket. Files expected: clean.pt, noisy.pt, reverberant.pt (others are ignored). Apple Silicon / GPU acceleration: * `--device auto` (default) picks MPS (Apple GPU) on M-series Macs, then CUDA, then CPU. * `--batch-size N` enables HF-pipeline batched inference (used by the `auto` cascade for Whisper / Wav2Vec2-pipeline models). Non-pipeline backends (Granite chat, SpeechBrain, custom CTC) ignore batching and run sample-by-sample. Usage: python scripts/run_eval_and_publish.py --model openai/whisper-tiny python scripts/run_eval_and_publish.py --model openai/whisper-small \\ --device mps --batch-size 16 python scripts/run_eval_and_publish.py --model org/model --family transformers_ctc \\ --packed-dir /path/to/packed_small --out-dir ./local_results """ from __future__ import annotations import argparse import csv import io import json import os import sys import time import traceback import uuid import warnings warnings.filterwarnings("ignore", category=UserWarning) # HF tokenizer parallelism from datetime import datetime, timezone ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if ROOT not in sys.path: sys.path.insert(0, ROOT) DEFAULT_PACKED_DIR = "/Users/shivamsaini/speech-rir-asr-dataset/data/new_local_generated/packed_small" DEFAULT_OUT_DIR = os.path.join(ROOT, "local_results") # --------------------------------------------------------------------------- # Device selection (CUDA / MPS / CPU) # --------------------------------------------------------------------------- def _resolve_device(choice: str) -> tuple[str, int]: """Return (device_str, device_int) for orchestrator / pipeline APIs. `device_int` is the value HF pipeline expects: -1 for CPU, 0+ for CUDA. For MPS we still pass `device_str="mps"` to the pipeline (it accepts strings), and report device_int=-1 so any code that branches on it treats it as non-CUDA. """ import torch choice = (choice or "auto").lower() has_cuda = torch.cuda.is_available() has_mps = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()) if choice == "auto": if has_mps: choice = "mps" elif has_cuda: choice = "cuda" else: choice = "cpu" if choice == "cuda": if not has_cuda: raise RuntimeError("CUDA requested but torch.cuda.is_available() is False.") return "cuda", 0 if choice == "mps": if not has_mps: raise RuntimeError( "MPS requested but torch.backends.mps.is_available() is False. " "Need PyTorch with MPS support on Apple Silicon." ) # Improve coverage: fall back to CPU for ops MPS doesn't implement. os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") return "mps", -1 if choice == "cpu": return "cpu", -1 raise ValueError(f"--device must be one of auto/cpu/cuda/mps; got {choice!r}") # --------------------------------------------------------------------------- # Local packed-file loader (replaces `download_bucket_file` for `.pt` reads) # --------------------------------------------------------------------------- def _patch_packed_loader(packed_dir: str) -> None: """Redirect benchmark.dataset.load_packed_condition to read local .pt files. Must run BEFORE importing orchestrator/run_evaluation so the orchestrator sees the patched function. """ import torch from benchmark import dataset as bench_ds cache: dict[str, dict] = {} def _local_loader(condition_key: str) -> dict: if condition_key in cache: return cache[condition_key] rel = bench_ds.PACKED_FILES[condition_key] local_path = os.path.join(packed_dir, os.path.basename(rel)) if not os.path.isfile(local_path): raise FileNotFoundError( f"Packed file for condition {condition_key!r} not found: {local_path}" ) data = torch.load(local_path, weights_only=False) cache[condition_key] = data return data bench_ds.load_packed_condition = _local_loader # --------------------------------------------------------------------------- # Inline orchestrator: explicit device + optional HF-pipeline batching # --------------------------------------------------------------------------- def _resolve_dtype(choice: str, device_str: str): """Pick torch dtype. `auto` policy: * CUDA -> fp16 (fast + generally stable) * MPS -> fp32 (several speech models overflow / misbehave in fp16 on Apple GPU) * CPU -> fp32 """ import torch choice = (choice or "auto").lower() if choice == "auto": return torch.float16 if device_str == "cuda" else torch.float32 return { "float32": torch.float32, "fp32": torch.float32, "float16": torch.float16, "fp16": torch.float16, "half": torch.float16, "bfloat16": torch.bfloat16, "bf16": torch.bfloat16, }.get(choice, torch.float32) _SEQ2SEQ_INCOMPATIBLE_MODEL_TYPES = { # Chat-prompted speech models — their processors require a `text` arg with the # chat template, so the audio-only processor call we use below is wrong. # Their dedicated backends know the right prompt; let those handle it. "granite_speech", "qwen2_audio", "qwen_audio", } # When `--family auto`, route specific model_type values to their dedicated # backend instead of letting the auto cascade fall through pipeline → universal, # which loads but crashes at inference for chat-prompted speech models, or # crashes at batched-collation time for models with variable-length features # the HF pipeline padding can't reconcile (Cohere ASR). _MODEL_TYPE_TO_FAMILY = { "granite_speech": "granite_speech", "cohere_asr": "universal", } def _resolve_family_for_model(family_id: str, model_id: str) -> tuple[str, str]: """Return (resolved_family, model_type). If `family_id` is `auto` and the config exposes a model_type that has a dedicated backend, switch to it.""" try: from transformers import AutoConfig cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True) model_type = getattr(cfg, "model_type", "") or "" except Exception: return family_id, "" if family_id == "auto" and model_type in _MODEL_TYPE_TO_FAMILY: return _MODEL_TYPE_TO_FAMILY[model_type], model_type return family_id, model_type def _try_build_batched_seq2seq(model_id: str, device_str: str, dtype): """Hand-rolled batched seq2seq transcriber (Whisper-style). Bypasses the HF pipeline so encoder/decoder forward passes are actually batched, preprocessing pads once per batch, and we can pick fp16. Works for any model loadable via `AutoModelForSpeechSeq2Seq`. Returns (transcribe_batch, num_params, cleanup, info) or None when this path doesn't apply (model needs chat prompts, can't load on this device, or the audio-only processor signature isn't supported). """ import torch try: from transformers import AutoConfig, AutoModelForSpeechSeq2Seq, AutoProcessor except Exception: return None # Cheap pre-flight: read the config first and bail for known chat-prompted # speech models so we don't pay the model-load cost just to crash. try: cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True) cfg_model_type = getattr(cfg, "model_type", "") or "" except Exception: cfg_model_type = "" if cfg_model_type in _SEQ2SEQ_INCOMPATIBLE_MODEL_TYPES: return None try: processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) except Exception: return None # Some chat-style processors are missing `feature_extractor` entirely; their # `__call__` requires `text=`. Detecting this cleanly is hard, so we treat # the warm-up below as a probe: if it fails, we fall back. fe = getattr(processor, "feature_extractor", None) target_sr = int(getattr(fe, "sampling_rate", 0)) or 16000 try: model = AutoModelForSpeechSeq2Seq.from_pretrained( model_id, torch_dtype=dtype, low_cpu_mem_usage=True, trust_remote_code=True, ) except Exception: return None if not hasattr(model, "generate"): del model return None try: model.to(device_str) except Exception: # Some ops not implemented on MPS; bail and let the pipeline path take over. del model return None model.eval() # Confirm placement (and fall back if torch silently kept it on CPU). try: param_device = str(next(model.parameters()).device) except StopIteration: param_device = "?" # Whisper-only kwargs are tried first, then fall back to plain generate(). is_whisper = getattr(getattr(model, "config", None), "model_type", "") == "whisper" def _generate(input_features, attention_mask=None): gen_kwargs = {"max_new_tokens": 440} if attention_mask is not None: gen_kwargs["attention_mask"] = attention_mask if cfg_model_type == "cohere_asr": # Cohere's remote generation code expects this key to be a tensor, # not None. Without it, HF generation crashes when extending masks. gen_kwargs["decoder_attention_mask"] = torch.ones( (int(input_features.shape[0]), 1), device=input_features.device, dtype=torch.long, ) if is_whisper: try: with torch.inference_mode(): return model.generate( input_features, language="english", task="transcribe", **gen_kwargs, ) except (TypeError, ValueError): pass with torch.inference_mode(): return model.generate(input_features, **gen_kwargs) def transcribe_batch(items: list[dict], batch_size: int) -> list[str]: audios = [it["raw"] for it in items] # Packed test data is already 16k mono; processor handles padding. inputs = processor(audios, sampling_rate=target_sr, return_tensors="pt", padding=True) if "input_features" in inputs: feats = inputs["input_features"].to(device_str, dtype=dtype) elif "input_values" in inputs: feats = inputs["input_values"].to(device_str, dtype=dtype) else: raise RuntimeError(f"Processor returned unexpected keys: {list(inputs.keys())}") attn = inputs.get("attention_mask") if attn is not None: attn = attn.to(device_str) ids = _generate(feats, attention_mask=attn) return processor.batch_decode(ids, skip_special_tokens=True) # Warm-up doubles as a probe: if the processor refuses an audio-only call # (e.g. chat-prompted speech models we didn't catch above), fall back so # the dedicated backend can take over. import numpy as np try: warm = [{"raw": np.zeros(target_sr, dtype=np.float32), "sampling_rate": target_sr}] * 2 transcribe_batch(warm, batch_size=2) except Exception as e: print( f"[eval] seq2seq path rejected for {model_id} ({type(e).__name__}: {e}); " "falling back to pipeline / per-sample.", file=sys.stderr, ) del model, processor if torch.cuda.is_available(): torch.cuda.empty_cache() return None from backends._model_utils import count_params num_params = count_params(model) def cleanup() -> None: nonlocal model, processor del model, processor info = { "kind": "seq2seq", "param_device": param_device, "dtype": str(dtype).replace("torch.", ""), "model_type": getattr(getattr(model, "config", None), "model_type", "?"), "target_sr": target_sr, } return transcribe_batch, num_params, cleanup, info def _try_build_batched_pipeline(model_id: str, device_str: str): """Fallback batched path via the HF pipeline. Less aggressive batching than the hand-rolled seq2seq path but works for CTC models too.""" # Cohere ASR pipeline batching currently breaks in HF pipeline collation for # variable-length feature tensors. Keep pipeline available for per-sample use, # but avoid this batched path so seq2seq/per-sample code handles it. try: from transformers import AutoConfig cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True) if getattr(cfg, "model_type", "") == "cohere_asr": return None except Exception: pass try: from transformers import pipeline except Exception: return None try: pipe = pipeline( "automatic-speech-recognition", model=model_id, device=device_str, trust_remote_code=True, ) except Exception: return None import numpy as np try: pipe({"raw": np.zeros(16000, dtype=np.float32), "sampling_rate": 16000}) except Exception: pass from backends._model_utils import count_params model_obj = getattr(pipe, "model", None) num_params = count_params(model_obj) try: param_device = str(next(model_obj.parameters()).device) if model_obj else "?" except Exception: param_device = "?" def transcribe_batch(audios_with_sr: list[dict], batch_size: int) -> list[str]: outs = pipe(audios_with_sr, batch_size=batch_size) return [str(o["text"]) for o in outs] def cleanup() -> None: nonlocal pipe del pipe info = { "kind": "pipeline", "param_device": param_device, "dtype": "float32", "model_type": getattr(getattr(model_obj, "config", None), "model_type", "?"), } return transcribe_batch, num_params, cleanup, info def _eval_condition_batched( transcribe_batch, condition_key: str, batch_size: int, progress_cb, ): """Batched WER eval — calls `transcribe_batch(list[dict], batch_size)` once per condition.""" from jiwer import wer as compute_wer from backends._audio_utils import safe_pad_audio from benchmark.dataset import _normalize_for_wer, load_packed_condition data = load_packed_condition(condition_key) if not data: if progress_cb is not None: progress_cb(condition_key, 0, 0) return 0.0, 0, 0.0, 0.0 items = list(data.items()) inputs: list[dict] = [] references: list[str] = [] audio_seconds = 0.0 for _sid, sample in items: audio_np = safe_pad_audio(sample["waveform"].numpy()) sr = int(sample.get("sample_rate", 16000)) audio_seconds += float(len(audio_np)) / float(sr) inputs.append({"raw": audio_np, "sampling_rate": sr}) references.append(_normalize_for_wer(str(sample["transcript"]))) total = len(items) predictions: list[str] = [] t0 = time.perf_counter() # Process in chunks so we can report progress between batches. for start in range(0, total, batch_size): chunk = inputs[start : start + batch_size] texts = transcribe_batch(chunk, batch_size=batch_size) predictions.extend(_normalize_for_wer(str(t)) for t in texts) if progress_cb is not None: try: progress_cb(condition_key, min(start + len(chunk), total), total) except Exception: pass inference_seconds = time.perf_counter() - t0 return round(compute_wer(references, predictions), 4), len(references), audio_seconds, inference_seconds def _run_evaluation_local( model_id: str, family_id: str, device_str: str, device_int: int, batch_size: int, dtype_choice: str, progress_cb, ) -> dict: """Inline orchestrator: chosen device + batched fast-path when possible.""" import torch from evaluation.runtime import disable_broken_torchcodec disable_broken_torchcodec() from backends.registry import build_transcriber, resolve_label from benchmark.dataset import ( PACKED_FILES, evaluate_condition_wer_timed, wer_result_key, load_packed_condition, ) dtype = _resolve_dtype(dtype_choice, device_str) resolved_family, model_type = _resolve_family_for_model(family_id, model_id) if resolved_family != family_id: print( f"[eval] family override: {family_id} -> {resolved_family} " f"(detected model_type={model_type!r})", file=sys.stderr, ) family_id = resolved_family # Pre-compute totals so progress is meaningful from the start. condition_totals: dict[str, int] = {} grand_total = 0 for ck in PACKED_FILES: try: condition_totals[ck] = len(load_packed_condition(ck)) except Exception: condition_totals[ck] = 0 grand_total += condition_totals[ck] if progress_cb is not None: try: progress_cb(0, grand_total, "") except Exception: pass # Try the batched fast paths in order: hand-rolled seq2seq → HF pipeline. batched = None info = None if batch_size > 1 and family_id in ("auto", "transformers_pipeline", "universal"): built = _try_build_batched_seq2seq(model_id, device_str, dtype) if built is None: built = _try_build_batched_pipeline(model_id, device_str) if built is not None: transcribe_batch, num_params, cleanup, info = built batched = (transcribe_batch, num_params, cleanup) print( f"[eval] batched path: {info['kind']} | model_type={info.get('model_type','?')} | " f"param_device={info['param_device']} | dtype={info['dtype']} | batch_size={batch_size}", file=sys.stderr, ) if batched is not None: transcribe_batch, num_params, cleanup = batched eval_family_label = resolve_label("transformers_pipeline") try: results: dict = { "model_id": model_id, "eval_family": eval_family_label, "num_params": int(num_params or 0), } total_samples = 0 total_audio_s = 0.0 total_infer_s = 0.0 done_so_far = 0 for condition_key in PACKED_FILES: base = done_so_far def _cb(ck: str, i: int, _n: int, _b: int = base) -> None: if progress_cb is not None: try: progress_cb(_b + i, grand_total, ck) except Exception: pass wer, count, audio_s, infer_s = _eval_condition_batched( transcribe_batch, condition_key, batch_size, progress_cb=_cb ) results[wer_result_key(condition_key)] = wer total_samples = max(total_samples, count) total_audio_s += audio_s total_infer_s += infer_s done_so_far += count results["num_samples"] = total_samples results["eval_audio_seconds"] = round(total_audio_s, 3) results["eval_wall_time_s"] = round(total_infer_s, 3) results["eval_rtf"] = ( round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else "" ) return results finally: cleanup() if torch.cuda.is_available(): torch.cuda.empty_cache() transcribe, cleanup = build_transcriber(family_id, model_id, device_str, device_int) try: num_params = int(getattr(transcribe, "_num_params", 0) or 0) backend_batch = getattr(transcribe, "_batch_transcribe", None) if batch_size > 1 and callable(backend_batch): print( f"[eval] backend-native batched path: family={family_id} device={device_str} " f"batch_size={batch_size}", file=sys.stderr, ) results = { "model_id": model_id, "eval_family": resolve_label(family_id), "num_params": num_params, } total_samples = 0 total_audio_s = 0.0 total_infer_s = 0.0 done_so_far = 0 for condition_key in PACKED_FILES: base = done_so_far def _cb(ck: str, i: int, _n: int, _b: int = base) -> None: if progress_cb is not None: try: progress_cb(_b + i, grand_total, ck) except Exception: pass wer, count, audio_s, infer_s = _eval_condition_batched( backend_batch, condition_key, batch_size, progress_cb=_cb ) results[wer_result_key(condition_key)] = wer total_samples = max(total_samples, count) total_audio_s += audio_s total_infer_s += infer_s done_so_far += count results["num_samples"] = total_samples results["eval_audio_seconds"] = round(total_audio_s, 3) results["eval_wall_time_s"] = round(total_infer_s, 3) results["eval_rtf"] = ( round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else "" ) return results # Per-sample fallback (any backend; honors device_str). print( f"[eval] per-sample path (no batching): family={family_id} device={device_str}", file=sys.stderr, ) results = { "model_id": model_id, "eval_family": resolve_label(family_id), "num_params": num_params, } total_samples = 0 total_audio_s = 0.0 total_infer_s = 0.0 done_so_far = 0 for condition_key in PACKED_FILES: base = done_so_far def _cb(ck: str, i: int, _n: int, _b: int = base) -> None: if progress_cb is not None: try: progress_cb(_b + i, grand_total, ck) except Exception: pass wer, count, audio_s, infer_s = evaluate_condition_wer_timed( transcribe, condition_key, progress_cb=_cb ) results[wer_result_key(condition_key)] = wer total_samples = max(total_samples, count) total_audio_s += audio_s total_infer_s += infer_s done_so_far += count results["num_samples"] = total_samples results["eval_audio_seconds"] = round(total_audio_s, 3) results["eval_wall_time_s"] = round(total_infer_s, 3) results["eval_rtf"] = ( round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else "" ) return results finally: cleanup() if torch.cuda.is_available(): torch.cuda.empty_cache() # --------------------------------------------------------------------------- # CSV helpers (local files; same schema as the Space's bucket CSVs) # --------------------------------------------------------------------------- def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _progress_print(done: int, total: int, condition: str) -> None: if total <= 0: sys.stderr.write(f"\r[{condition or 'preparing'}] preparing…") else: pct = 100.0 * done / total sys.stderr.write( f"\r[{condition or '—'}] {done}/{total} ({pct:5.1f}%) " ) sys.stderr.flush() def _read_csv(path: str) -> list[dict]: if not os.path.isfile(path): return [] with open(path, "r", encoding="utf-8", newline="") as f: return list(csv.DictReader(f)) def _write_csv(path: str, fields: list[str], rows: list[dict]) -> None: os.makedirs(os.path.dirname(path) or ".", exist_ok=True) buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore") w.writeheader() for row in rows: w.writerow({k: row.get(k, "") for k in fields}) with open(path, "w", encoding="utf-8", newline="") as f: f.write(buf.getvalue()) def _append_leaderboard_local(csv_path: str, result: dict, notes: str) -> dict: from analytics import sort_leaderboard_rows_inplace from init import CSV_FIELDS, leaderboard_row_from_eval_result, normalize_legacy_csv_row rows = _read_csv(csv_path) for row in rows: for k in CSV_FIELDS: row.setdefault(k, "") normalize_legacy_csv_row(row) if any(r.get("model_id") == result["model_id"] for r in rows): raise RuntimeError( f"Model {result['model_id']!r} already in {csv_path}; refusing to duplicate." ) new_row = leaderboard_row_from_eval_result(result, _now_iso(), submission_notes=notes) normalize_legacy_csv_row(new_row) rows.append(new_row) sort_leaderboard_rows_inplace(rows) _write_csv(csv_path, CSV_FIELDS, rows) return new_row _JOBS_CSV_FIELDS = [ "job_id", "model_id", "family_id", "status", "created_at", "updated_at", "error", "submission_notes", ] def _upsert_job_local( csv_path: str, *, job_id: str, model_id: str, family_id: str, status: str, created_at: str, error: str | None = None, submission_notes: str = "", ) -> None: rows = _read_csv(csv_path) by_id = {(r.get("job_id") or "").strip(): r for r in rows if r.get("job_id")} by_id[job_id] = { "job_id": job_id, "model_id": model_id, "family_id": family_id, "status": status, "created_at": created_at, "updated_at": _now_iso(), "error": (error or "").replace("\n", " ")[:2000], "submission_notes": (submission_notes or "").replace("\n", " ")[:4000], } merged = list(by_id.values()) merged.sort(key=lambda r: r.get("created_at") or "") _write_csv(csv_path, _JOBS_CSV_FIELDS, merged) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser( description="Run FFASR evaluation locally against staged packed files; write CSVs to disk." ) parser.add_argument("--model", required=True, help="Hugging Face model id (author/name).") parser.add_argument( "--family", default=None, help="Inference backend family (default: auto). Validated against backends.registry.", ) parser.add_argument( "--device", default="auto", choices=("auto", "cpu", "cuda", "mps"), help="Compute device. `auto` picks MPS on Apple Silicon, then CUDA, then CPU.", ) parser.add_argument( "--batch-size", type=int, default=8, help="Batch size for HF-pipeline backends (whisper, wav2vec2 pipeline, …). " "Non-pipeline backends ignore this and run sample-by-sample. Use 1 to disable batching.", ) parser.add_argument( "--dtype", default="auto", choices=("auto", "float32", "float16", "bfloat16", "fp32", "fp16", "bf16", "half"), help="Model dtype. `auto` uses fp16 on CUDA/MPS and fp32 on CPU. " "fp16 typically halves Whisper inference time on M-series Macs.", ) parser.add_argument( "--packed-dir", default=DEFAULT_PACKED_DIR, help=f"Directory containing clean.pt / noisy.pt / reverberant.pt (default: {DEFAULT_PACKED_DIR}).", ) parser.add_argument( "--out-dir", default=DEFAULT_OUT_DIR, help=f"Directory to write leaderboard.csv and jobs_state.csv (default: {DEFAULT_OUT_DIR}).", ) parser.add_argument( "--leaderboard-csv", default=None, help="Override path for leaderboard.csv (default: /leaderboard.csv).", ) parser.add_argument( "--jobs-csv", default=None, help="Override path for jobs_state.csv (default: /jobs_state.csv).", ) parser.add_argument( "--notes", default="", help="Optional submission notes recorded with the leaderboard row.", ) parser.add_argument( "--job-id", default=None, help="Reuse an existing job_id. Defaults to a fresh uuid.", ) parser.add_argument("--json", action="store_true", help="Print result as JSON at the end.") args = parser.parse_args() packed_dir = os.path.abspath(args.packed_dir) if not os.path.isdir(packed_dir): print(f"ERROR: --packed-dir does not exist: {packed_dir}", file=sys.stderr) return 2 for cond in ("clean.pt", "noisy.pt", "reverberant.pt"): if not os.path.isfile(os.path.join(packed_dir, cond)): print(f"ERROR: missing {cond} in {packed_dir}", file=sys.stderr) return 2 _patch_packed_loader(packed_dir) try: device_str, device_int = _resolve_device(args.device) except Exception as e: print(f"ERROR: {e}", file=sys.stderr) return 2 from backends.registry import FAMILY_IDS, default_family_id # noqa: E402 family_id = args.family or default_family_id() if family_id not in FAMILY_IDS: print( f"ERROR: invalid --family {family_id!r}; choose from {list(FAMILY_IDS)}", file=sys.stderr, ) return 2 if args.batch_size < 1: print("ERROR: --batch-size must be >= 1", file=sys.stderr) return 2 model_id = args.model.strip() notes = (args.notes or "").strip()[:4000] out_dir = os.path.abspath(args.out_dir) leaderboard_csv = args.leaderboard_csv or os.path.join(out_dir, "leaderboard.csv") jobs_csv = args.jobs_csv or os.path.join(out_dir, "jobs_state.csv") existing = _read_csv(leaderboard_csv) if any(r.get("model_id") == model_id for r in existing): print( f"ERROR: {model_id!r} already in {leaderboard_csv}; nothing to do.", file=sys.stderr, ) return 1 job_id = (args.job_id or str(uuid.uuid4())[:8]).strip() created = _now_iso() print( f"[job {job_id}] running {model_id} (family={family_id}, device={device_str}, " f"batch_size={args.batch_size}, dtype={args.dtype}) using packed dir {packed_dir}", file=sys.stderr, ) _upsert_job_local( jobs_csv, job_id=job_id, model_id=model_id, family_id=family_id, status="running", created_at=created, submission_notes=notes, ) try: result = _run_evaluation_local( model_id, family_id, device_str=device_str, device_int=device_int, batch_size=args.batch_size, dtype_choice=args.dtype, progress_cb=_progress_print, ) sys.stderr.write("\n") except Exception as e: sys.stderr.write("\n") traceback.print_exc() _upsert_job_local( jobs_csv, job_id=job_id, model_id=model_id, family_id=family_id, status="failed", created_at=created, error=str(e), submission_notes=notes, ) print(f"[job {job_id}] FAILED: {e}", file=sys.stderr) return 1 try: new_row = _append_leaderboard_local(leaderboard_csv, result, notes) except Exception as e: traceback.print_exc() _upsert_job_local( jobs_csv, job_id=job_id, model_id=model_id, family_id=family_id, status="failed", created_at=created, error=f"publish failed: {e}", submission_notes=notes, ) return 1 _upsert_job_local( jobs_csv, job_id=job_id, model_id=model_id, family_id=family_id, status="done", created_at=created, submission_notes=notes, ) print( f"[job {job_id}] done — wrote row to {leaderboard_csv}, job to {jobs_csv}", file=sys.stderr, ) if args.json: print(json.dumps({"job_id": job_id, "result": result, "row": new_row}, indent=2)) else: print(f"job_id: {job_id}") print(f"model_id: {result['model_id']}") print(f"eval_family: {result['eval_family']}") print(f"device: {device_str}") print(f"dtype: {args.dtype}") print(f"batch_size: {args.batch_size}") print(f"wer_clean (→ anechoic CSV): {result.get('wer_clean')}") print(f"wer_noisy (→ lab measured CSV): {result.get('wer_noisy')}") print(f"wer_reverberant (→ lab sim CSV): {result.get('wer_reverberant')}") print(f"wer_high (→ High SNR CSV): {result.get('wer_high')}") print(f"wer_moving (→ Moving Sources CSV): {result.get('wer_moving')}") print(f"wer_mid (→ Mid SNR CSV): {result.get('wer_mid')}") print(f"wer_low (→ Low SNR CSV): {result.get('wer_low')}") print(f"num_samples: {result.get('num_samples')}") print(f"eval_wall_time_s: {result.get('eval_wall_time_s')}") print(f"eval_rtf: {result.get('eval_rtf')}") print(f"num_params: {result.get('num_params')}") return 0 if __name__ == "__main__": sys.exit(main())