"""Oswald compare: test and compare Oswald V1, V2 and V3 on Datamio recordings or your own audio. Datamio clips come from jsbeaudry/datamio-kreyol-voices (approved phone takes). Pick one clip or many, run any of the models, and read word and character error rates, numbers found whole, and each model's output with its errors marked. The scoring is the V3 evaluation's (scoring.py): numbers in words and in digits count the same, accents never decide. V1 and V2 are Qwen3-ASR 0.6B fine-tunes (transformers, beam 4, as in the Trio Space); V3 is Parakeet-TDT 0.6B v3 fine-tuned (transformers ParakeetForTDT, converted from the NeMo checkpoint, greedy as NeMo scores it). All run on ZeroGPU. The dataset and V3 are private, so the Space reads them with its HF_TOKEN secret. """ import csv import glob import io import json import os import random import re import tempfile import time import spaces # ZeroGPU: before torch import gradio as gr import librosa import numpy as np import pandas as pd import pyarrow.parquet as pq import soundfile as sf import torch from huggingface_hub import snapshot_download from transformers import AutoModelForTDT, AutoProcessor from oswald import Oswald from scoring import clip_wer, diff_html, number_accuracy, rates, target TOKEN = os.environ.get("HF_TOKEN") def token_report(): """Whose token the Space has and what it may read; never the token itself.""" if not TOKEN: return "HF_TOKEN is not set" try: from huggingface_hub import whoami w = whoami(token=TOKEN) auth = w.get("auth", {}).get("accessToken", {}) scopes = sorted({p for s in auth.get("fineGrained", {}).get("scoped", []) for p in s.get("permissions", [])}) return (f"HF_TOKEN belongs to {w.get('name')}, role {auth.get('role')}" + (f", fine-grained: {scopes or 'no repo permissions'}" if auth.get("role") == "fineGrained" else "")) except Exception as e: return f"HF_TOKEN is set but not accepted: {type(e).__name__}" print(token_report(), flush=True) DEVICE = os.environ.get("DEVICE", "cuda") # "cpu" only to run the app off the Space QWEN_DTYPE = torch.bfloat16 if DEVICE == "cuda" else torch.float32 DATASET = "jsbeaudry/datamio-kreyol-voices" SR = 16000 MAX_CLIPS = 500 # per run CHUNK = 40 # clips per ZeroGPU call, so each call stays well under its time limit MAX_ROWS_SHOWN = 300 MODELS = { "Oswald V1": ("qwen", "jsbeaudry/Oswald-ASR-0.6-V1"), "Oswald V2": ("qwen", "jsbeaudry/Oswald-ASR-0.6-V2"), "Oswald V3": ("tdt", "jsbeaudry/Oswald-ASR-0.6-V3"), "Oswald V3 no-JW": ("tdt", "jsbeaudry/Oswald-ASR-0.6-V3-nojw"), } NOTES = { "Oswald V1": "Qwen3-ASR · numbers in words · never saw Datamio", "Oswald V2": "Qwen3-ASR · digits · trained on Datamio training speakers (takes to Oct 15)", "Oswald V3": "Parakeet-TDT · digits · trained with JW narration (private: jw.org terms)", "Oswald V3 no-JW": "Parakeet-TDT · digits · same mix without JW", } # MODELS_LOAD="Oswald V2,Oswald V3 no-JW" loads a subset, for running off the Space with little memory. WANTED = [m for m in os.environ.get("MODELS_LOAD", ",".join(MODELS)).split(",") if m in MODELS] # ---------------------------------------------------------------- models class Parakeet: def __init__(self, repo): self.processor = AutoProcessor.from_pretrained(repo, token=TOKEN) self.model = AutoModelForTDT.from_pretrained(repo, token=TOKEN, dtype=torch.float32).to(DEVICE).eval() @torch.inference_mode() def transcribe_many(self, audios, batch=8): out = [] for i in range(0, len(audios), batch): inputs = self.processor(audios[i:i + batch], sampling_rate=SR) inputs = inputs.to(self.model.device, dtype=self.model.dtype) ids = self.model.generate(**inputs, return_dict_in_generate=True) out += [t.strip() for t in self.processor.batch_decode(ids.sequences, skip_special_tokens=True)] return out def load_model(name): kind, repo = MODELS[name] if kind == "qwen": return Oswald(repo, DEVICE, QWEN_DTYPE) return Parakeet(repo) models, load_errors = {}, {} for name in WANTED: try: models[name] = load_model(name) print(f"loaded {name}", flush=True) except Exception as e: # a private repo without HF_TOKEN must not take the whole Space down load_errors[name] = f"{type(e).__name__}: {str(e)[:200]}" print(f"could not load {name}: {load_errors[name]}", flush=True) AVAILABLE = [m for m in MODELS if m in models] def gpu_seconds(name, audios): total = sum(len(a) for a in audios) / SR if MODELS[name][0] == "qwen": need = 15 + 0.2 * total + 0.5 * len(audios) else: need = 10 + 0.03 * total + 0.1 * len(audios) return int(min(120, need)) @spaces.GPU(duration=gpu_seconds) def transcribe(name, audios): m = models[name] if isinstance(m, Parakeet): return m.transcribe_many(audios) return [m.transcribe(a) for a in audios] # ---------------------------------------------------------------- dataset splits = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "datamio_v2_splits.json"))) SPLIT_OF = {s: "test" for s in splits["test"]} | {s: "validation" for s in splits["val"]} SPLIT_LABEL = {"test": "held-out test speaker", "validation": "held-out validation speaker", "train": "training speaker"} META_COLS = ["id", "speaker", "gender", "age", "department", "duration_s", "snr_db", "text", "oswald_score", "recorded_at"] meta, dataset_error, files = pd.DataFrame(), "", [] try: # DATA_PATTERN loads fewer shards, only to run the app off the Space local = snapshot_download(DATASET, repo_type="dataset", token=TOKEN, allow_patterns=[os.environ.get("DATA_PATTERN", "data/*.parquet")]) pattern = os.environ.get("DATA_PATTERN", "data/*.parquet").split("/")[-1] files = sorted(f for f in glob.glob(os.path.join(local, "data", pattern))) parts = [] for fi, path in enumerate(files): pf = pq.ParquetFile(path) cols = [c for c in META_COLS if c in pf.schema_arrow.names] for g in range(pf.num_row_groups): t = pf.read_row_group(g, columns=cols).to_pandas() t["_file"], t["_group"], t["_row"] = fi, g, range(len(t)) parts.append(t) meta = pd.concat(parts, ignore_index=True) meta["split"] = meta["speaker"].map(SPLIT_OF).fillna("train") meta["duration_s"] = meta["duration_s"].astype(float).round(1) meta = meta.sort_values(["split", "speaker", "recorded_at"], key=lambda s: s.map( {"test": 0, "validation": 1, "train": 2}) if s.name == "split" else s).reset_index(drop=True) print(f"dataset: {len(meta):,} clips, {meta.speaker.nunique()} speakers", flush=True) except Exception as e: dataset_error = f"{type(e).__name__}: {str(e)[:300]}" print("dataset not loaded:", dataset_error, flush=True) _pq = {} def clip_audio(i): """16 kHz mono float audio of meta row i, read from its parquet row group.""" r = meta.iloc[i] pf = _pq.setdefault(int(r["_file"]), pq.ParquetFile(files[int(r["_file"])])) b = pf.read_row_group(int(r["_group"]), columns=["audio"]).column("audio")[int(r["_row"])].as_py()["bytes"] a, sr = sf.read(io.BytesIO(b), dtype="float32") a = a.mean(1) if a.ndim > 1 else a return librosa.resample(a, orig_sr=sr, target_sr=SR) if sr != SR else a def clip_audios(rows): """clip_audio for many rows, reading each parquet row group once.""" out, by_group = {}, {} for i in rows: r = meta.iloc[i] by_group.setdefault((int(r["_file"]), int(r["_group"])), []).append(i) for (fi, g), members in by_group.items(): col = _pq.setdefault(fi, pq.ParquetFile(files[fi])).read_row_group(g, columns=["audio"]).column("audio") for i in members: a, sr = sf.read(io.BytesIO(col[int(meta.iloc[i]["_row"])].as_py()["bytes"]), dtype="float32") a = a.mean(1) if a.ndim > 1 else a out[i] = librosa.resample(a, orig_sr=sr, target_sr=SR) if sr != SR else a return [out[i] for i in rows] def choices(col): if meta.empty or col not in meta: return ["All"] counts = meta[col].fillna("").replace("", "(not given)").value_counts() return ["All"] + [f"{k} ({v})" for k, v in counts.sort_index().items()] def strip_count(v): return re.sub(r" \(\d+\)$", "", v or "All") def filter_clips(split, speaker, gender, department, search): if meta.empty: return pd.DataFrame(), [], "No dataset loaded." m = meta want = {"Held-out test speakers": ["test"], "Held-out validation speakers": ["validation"], "Training speakers": ["train"], "All speakers": ["test", "validation", "train"]}[split] m = m[m.split.isin(want)] for col, v in (("speaker", speaker), ("gender", gender), ("department", department)): v = strip_count(v) if v != "All": m = m[m[col].fillna("").replace("", "(not given)") == v] if search and search.strip(): m = m[m.text.str.contains(re.escape(search.strip()), case=False, na=False)] idx = list(m.index) shown = m.head(MAX_ROWS_SHOWN) table = pd.DataFrame({"#": range(1, len(shown) + 1), "speaker": shown.speaker, "split": shown.split, "s": shown.duration_s, "text": shown.text}) note = f"{len(idx):,} clips match" + (f" (first {MAX_ROWS_SHOWN} shown; random picks use all)" if len(idx) > MAX_ROWS_SHOWN else "") return table, idx, note def parse_selection(text, idx): """'1, 4, 7-9' -> meta rows, by position (#) in the current filtered list.""" picked = [] for part in re.split(r"[,\s]+", text or ""): if not part: continue a, _, b = part.partition("-") try: lo, hi = int(a), int(b or a) except ValueError: continue picked += [idx[k - 1] for k in range(lo, hi + 1) if 1 <= k <= len(idx)] return list(dict.fromkeys(picked)) def add_clicked(evt: gr.SelectData, selection, idx): k = evt.index[0] + 1 nums = [s for s in re.split(r"[,\s]+", selection or "") if s] if str(k) not in nums: nums.append(str(k)) i = idx[k - 1] r = meta.loc[i] info = f"**#{k}** · {r.speaker} · {SPLIT_LABEL[r.split]} · {r.duration_s} s\n\n{r.text}" return ", ".join(nums), (SR, clip_audio(i)), info def random_pick(n, idx): if not idx: return "" ks = sorted(random.sample(range(1, len(idx) + 1), min(int(n), len(idx), MAX_CLIPS))) return ", ".join(map(str, ks)) # ---------------------------------------------------------------- running and reporting CSS = """ del.miss {color:#c0392b; text-decoration:line-through;} ins.extra {color:#d35400; text-decoration:none; font-weight:600;} .clip {border:1px solid var(--border-color-primary); border-radius:8px; padding:10px 12px; margin:8px 0;} .clip .ref {font-weight:600; margin-bottom:6px;} .clip table {width:100%; border-collapse:collapse;} .clip td {padding:3px 6px; vertical-align:top; border-top:1px solid var(--border-color-primary);} .clip td.m {white-space:nowrap; width:1%; font-weight:600;} .clip td.w {white-space:nowrap; width:1%; text-align:right;} .clip .scored {color:var(--body-text-color-subdued); font-size:0.9em;} """ def summary_table(names, refs, outs, secs, audio_s): rows = [] for n in names: wer, cer = rates(refs, outs[n]) if refs else (None, None) acc, spans = number_accuracy(refs, outs[n]) if refs else (None, 0) rows.append({"model": n, "clips": len(outs[n]), "WER %": None if wer is None else round(100 * wer, 2), "CER %": None if cer is None else round(100 * cer, 2), "numbers whole": "-" if acc is None else f"{100 * acc:.0f}% of {spans}", "GPU s": round(secs[n], 1), "RTF": round(secs[n] / max(audio_s, 1e-6), 4)}) return pd.DataFrame(rows) def cards(names, labels, refs, outs): html = [] for k, (label, ref) in enumerate(zip(labels, refs)): lines = [] for n in names: hyp = outs[n][k] w = clip_wer(ref, hyp) if ref else None scored = f'