--- name: community-model-conversion description: Convert a HuggingFace ASR fine-tune into a sherpa-onnx external model, publish it, and add it to the Anti-Vocale community catalog. Use when a user requests an importable model for a language we don't cover, or when catalog-listed repos fail to import. --- # Community model conversion (HF fine-tune -> sherpa-onnx -> catalog entry) This pipeline turns "user wants language X" into a one-URL import in the app. Every step below was learned from a real conversion (Arabic 2026-08, Swiss German 2026-08-24, GH #63). The gotchas are the point of this file: each one cost hours because the failure mode is silent (models load but transcribe garbage, or decode to empty strings). ## 0. Triage the user's repo FIRST (before any conversion work) If the user points at `onnx-community/*` or any transformers.js-layout export (`onnx/encoder_model*.onnx` + `tokenizer.json`, inputs named `input_features` / `last_hidden_state`): STOP. It is structurally incompatible with sherpa-onnx (no `n_mels`/`model_type` metadata, wrong tensor names, merged decoder KV cache). Our app's rejection message is correct; relaxing validation would only turn it into a native exit 255. Verify cheaply with the eval harness: `OfflineRecognizer.from_whisper(...)` fails at `InitEncoder: 'n_mels' does not exist in the metadata`. The fix is a re-export from the fine-tuned PyTorch checkpoint, not the ONNX files. Then find the PyTorch source of the fine-tune (check the HF model card's base_model, search `?search=` on the HF API). Swiss German had `Flurin17/whisper-large-v3-turbo-swiss-german` behind the onnx-community mirror. ## 1. Environment (CPU only is fine, ~40 min end to end) - Workspace on REAL disk (NOT /tmp: it is tmpfs, the fp32 graphs are ~3 GB and eat RAM twice), e.g. /var/tmp/chwork. - venv: torch+cpu, transformers, onnx, onnxruntime, onnxscript, openai-whisper, safetensors, numpy<2. Python 3.14 + onnx may need `pip install -U ml_dtypes` or onnx<1.20 (float4 attribute error). - sherpa-onnx sources at the pinned tag (`git clone --branch v1.13.5 ...`; the tag equals the `.sherpa-version` srclib commit) for `scripts/whisper/export-onnx.py`. - Validate with the eval harness venv (eval/.venv, sherpa-onnx Python). ## 2. HF transformers checkpoint -> openai-whisper .pt The export script consumes openai-whisper models. Write a key remapper (kept at the workspace; see memory checkpoint for the working one): encoder/decoder layer subkeys map (`self_attn.q_proj`->`attn.query`, `layer_norm1`->`attn_ln`, `final_layer_norm`->`mlp_ln`, bare `fc1`/`fc2`-> `mlp.0`/`mlp.2`, `encoder_attn.*`->`cross_attn.*`), drop `lm_head` (tied), drop `k_proj.bias` (softmax-invariant: adds q.b, constant across keys). **GOTCHA 1 (the big one): turbo's decoder positional embedding is LEARNED, not sinusoid.** Download the official turbo.pt from the URL in `whisper/__init__.py` and check: decoder.positional_embedding has std ~0.0066 (near-zero learned table), while a sinusoid has std ~0.7. You MUST copy `model.decoder.embed_positions.weight` (and the encoder one) from the HF checkpoint; injecting a hand-computed sinusoid produces models that load, run, and emit total garbage. Symptom if you get this wrong: weights verified identical, architecture verified identical, output still gibberish. Wrap the result as `{'dims': {...}, 'model_state_dict': sd}` with dims read off the shapes (turbo: n_mels 128, n_audio_* 1500/1280/20/32, n_text_* 448/1280/20/4, n_vocab from token_embedding). whisper.load_model infers from dims. Validate BEFORE exporting: transcribe a short wav with openai-whisper from the converted .pt AND with the original HF pipeline; they must agree. If you see NaN logits, you probably read a 22 kHz int16 wav as float32 (use whisper.load_audio / ffmpeg), not a model bug. ## 3. sherpa export (export-onnx.py, patched for a local checkpoint) Local patches needed (keep them in the working copy): - add the model name to `--model` choices and to a `load_model` branch returning `whisper.load_model("./.pt")`; - the n_mels branch (`large`/`turbo` -> 128) must include your name; - the external-data save branches (`"large" in filename`) must include your name: the fp32 graphs exceed the 2 GB protobuf limit (FileExistsError on rerun: delete `*.weights` too, not just `*.onnx*`). NEVER delete these files while the export is still running: the `.weights` beside the `.onnx` is the canonical external data the proto points at, and deleting it mid-run (2026-08-28, "looked like a duplicate") crashes quantization and forces a full re-export. Clean up only after the process exits. **GOTCHA 2a (int8 truncation): per-tensor int8 quantization of a FINE-TUNED whisper decoder causes premature EOT after ~1 phrase per 30s chunk.** The fine-tune shifts weight distributions; per-tensor crushes outlier channels and the greedy decoder stops early. Short audio looks correct, 30s chunks yield 1 phrase instead of 15. FIX: pass per_channel=True to quantize_dynamic() for the decoder (ORT docs recommend it when accuracy loss is large). Verified: per-channel int8 = identical output to fp32 at the same size. The encoder is unaffected. MODEL-DEPENDENT (2026-08-27, primeline German): a light fine-tune (3 epochs, lr 1e-6) moved distributions so little that per-tensor survived a 62s tiled test with no truncation (only a token-casing wobble); the Swiss fine-tune truncated at 30s. You cannot predict which kind you have: always quantize the decoder per-channel, and always test with a tiled ~30s audio file (60s is better) before publishing. **GOTCHA 2b: torch >= 2.9 defaults torch.onnx.export to the dynamo exporter, which bakes a wrong cross-KV reshape in the whisper decoder.** Symptom: sherpa decodes to EMPTY text and logs `Caught exception ... Reshape ... input_shape_size == requested_shape_size was false, Input shape {1,4,1280} requested {1280}` (hidden behind the misleading "tail_paddings" message). Fix: pass `dynamo=False` for the decoder export. The encoder is unaffected. Quantization (int8, MatMul-only) is produced by the script itself. Validate: decode the same wav via `OfflineRecognizer.from_whisper` in eval/.venv; must match the HF ground truth. Use audio >= 10 s or sherpa's "Return an empty result ... input frames" fires (frames must exceed tail_paddings, 1000 default). Load the wav with ffmpeg resampling, not np.frombuffer. ## 4. Publish + catalog - HF repo under `pantinor/` (hf CLI, HUGGINGFACE token at ~/.cache/huggingface/token). README yaml: `language:` needs ISO codes (de, gsw), NOT `de-CH` (use `language_bcp47:` for that) or the upload is rejected. - Entry JSON in `app/src/main/assets/external-catalog/.json` (mirror arabic.json exactly: family, per-file url/sha256/size; whisper options use the keys `"whisper.language"`/`"whisper.task"` from ModelFamilySupport). - the CURRENT versioned index (index-.json, the highest-versioned index-*.json present; TASK-643): add the entry. NEVER the unsuffixed index.json (frozen legacy channel for <=1.13.x apps). **GOTCHA 3: the catalog matcher prefix-matches name WORDS and language codes, not raw substrings** ("ry" inside "Canary" stays silent; a name WORD starting with a code matches it). The by-code test pins filter("ar") == the arabic entries (TWO since TASK-770: the turbo quality pick and the small light option, both carrying the word "Arabic"). A name word starting with another entry's code ("Armenian" for "ar", "Farsi" for "fa") would surface that entry under the code and break the pin. - The entryUrl points at raw.githubusercontent.com main: push BEFORE any released client can use it, and curl the URL to 200 after push. - Full suite (test count asserts in ExternalCatalogTest), /review-local, commit, push. ## 5. Device validation (REQUIRED before advertising: we promised on GH) Import path: Model tab > Advanced > ONNX Sherpa > family=Whisper > "Import from URL" (NOT "Import from Hugging Face URL", that is the LiteRT-LM importer). Type a unique language code (e.g. "gsw") to filter the catalog suggestion; tap the suggestion NAME line; verify the field shows the entry URL; Import; confirm the file list; wait for the ~1 GB download; run a transcription in that language. Driving this via adb is fragile: urlText persists across dialog opens (force-stop resets it), BACK dismisses the dialog (never use it to hide the keyboard), and `input text` renders fine but verify with a screenshot read via the zai MCP image tools, not by eyeballing. If the form fights you more than twice, ask the user to do the manual step and verify the result from `run-as ... ls files/models/external` + the datastore record instead. ## 6. Close out - Reply on the originating GH issue with the analysis + catalog link. - Backlog task final summary; delete the multi-GB workspace only after the device pass.