Automatic Speech Recognition
Transformers
Safetensors
English
asr_model
feature-extraction
asr
speech-recognition
speech-to-text
audio
speech-llm
word-timestamps
speaker-diarization
qwen
granite-speech
custom_code
Instructions to use mazesmazes/tiny-audio with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use mazesmazes/tiny-audio with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("mazesmazes/tiny-audio", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Promote tiny-audio-granite-qwen-500-3 (Granite NC encoder + fine-tuned Qwen3-0.6B)
Browse filesCopy of mazesmazes/tiny-audio-granite-qwen-500-3@989df838 (step 16159). config.json: pretrained_model_path -> mazesmazes/tiny-audio, model_dtype -> bfloat16. Previous model tagged granite-qwen-frozen-4.
- .gitattributes +35 -2
- README.md +82 -215
- adapter_config.json +0 -55
- adapter_model.safetensors +0 -3
- alignment.py +2 -2
- asr_attention.py +37 -4
- asr_config.py +80 -117
- asr_layers.py +4 -118
- asr_modeling.py +259 -521
- asr_packing.py +58 -0
- asr_pipeline.py +66 -106
- asr_processing.py +66 -18
- asr_text.py +123 -0
- chat_template.jinja +36 -101
- config.json +73 -159
- diarization.py +2 -2
- generation_config.json +9 -6
- handler.py +0 -112
- model.safetensors +2 -2
- preprocessor_config.json +0 -1
- projectors.py +5 -14
- qwen3_asr_encoder.py +152 -0
- requirements.txt +2 -0
- tokenizer.json +2 -2
- tokenizer_config.json +2 -19
- training_args.bin +0 -3
.gitattributes
CHANGED
|
@@ -1,3 +1,36 @@
|
|
| 1 |
-
*.
|
|
|
|
| 2 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
license:
|
| 3 |
language:
|
| 4 |
- en
|
| 5 |
datasets:
|
|
@@ -8,12 +8,13 @@ datasets:
|
|
| 8 |
- fixie-ai/common_voice_17_0
|
| 9 |
- speechcolab/gigaspeech
|
| 10 |
- kensho/spgispeech
|
| 11 |
-
- facebook/voxpopuli
|
| 12 |
- edinburghcstr/ami
|
| 13 |
- sanchit-gandhi/tedlium-data
|
|
|
|
|
|
|
| 14 |
base_model:
|
| 15 |
-
- ibm-granite/granite-speech-5.0-470m-turboctc
|
| 16 |
-
- Qwen/Qwen3
|
| 17 |
pipeline_tag: automatic-speech-recognition
|
| 18 |
tags:
|
| 19 |
- asr
|
|
@@ -25,171 +26,48 @@ tags:
|
|
| 25 |
- speaker-diarization
|
| 26 |
- qwen
|
| 27 |
- granite-speech
|
| 28 |
-
- lora
|
| 29 |
library_name: transformers
|
| 30 |
metrics:
|
| 31 |
- wer
|
| 32 |
-
model-index:
|
| 33 |
-
- name: tiny-audio
|
| 34 |
-
results:
|
| 35 |
-
- task:
|
| 36 |
-
type: automatic-speech-recognition
|
| 37 |
-
name: Automatic Speech Recognition
|
| 38 |
-
dataset:
|
| 39 |
-
name: "LibriSpeech test-clean, subset of up to 1,000 samples"
|
| 40 |
-
type: openslr/librispeech_asr
|
| 41 |
-
config: clean
|
| 42 |
-
split: test
|
| 43 |
-
metrics:
|
| 44 |
-
- type: wer
|
| 45 |
-
value: 1.84
|
| 46 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 47 |
-
- task:
|
| 48 |
-
type: automatic-speech-recognition
|
| 49 |
-
name: Automatic Speech Recognition
|
| 50 |
-
dataset:
|
| 51 |
-
name: "LibriSpeech test-other, subset of up to 1,000 samples"
|
| 52 |
-
type: openslr/librispeech_asr
|
| 53 |
-
config: other
|
| 54 |
-
split: test
|
| 55 |
-
metrics:
|
| 56 |
-
- type: wer
|
| 57 |
-
value: 6.38
|
| 58 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 59 |
-
- task:
|
| 60 |
-
type: automatic-speech-recognition
|
| 61 |
-
name: Automatic Speech Recognition
|
| 62 |
-
dataset:
|
| 63 |
-
name: "SPGISpeech, subset of up to 1,000 samples"
|
| 64 |
-
type: kensho/spgispeech
|
| 65 |
-
config: test
|
| 66 |
-
split: test
|
| 67 |
-
metrics:
|
| 68 |
-
- type: wer
|
| 69 |
-
value: 2.29
|
| 70 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 71 |
-
- task:
|
| 72 |
-
type: automatic-speech-recognition
|
| 73 |
-
name: Automatic Speech Recognition
|
| 74 |
-
dataset:
|
| 75 |
-
name: "TED-LIUM, subset of up to 1,000 samples"
|
| 76 |
-
type: sanchit-gandhi/tedlium-data
|
| 77 |
-
config: default
|
| 78 |
-
split: test
|
| 79 |
-
metrics:
|
| 80 |
-
- type: wer
|
| 81 |
-
value: 3.71
|
| 82 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 83 |
-
- task:
|
| 84 |
-
type: automatic-speech-recognition
|
| 85 |
-
name: Automatic Speech Recognition
|
| 86 |
-
dataset:
|
| 87 |
-
name: "LoquaciousSet (held out), subset of up to 1,000 samples"
|
| 88 |
-
type: speechbrain/LoquaciousSet
|
| 89 |
-
config: small
|
| 90 |
-
split: test
|
| 91 |
-
metrics:
|
| 92 |
-
- type: wer
|
| 93 |
-
value: 6.2
|
| 94 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 95 |
-
- task:
|
| 96 |
-
type: automatic-speech-recognition
|
| 97 |
-
name: Automatic Speech Recognition
|
| 98 |
-
dataset:
|
| 99 |
-
name: "VoxPopuli, subset of up to 1,000 samples"
|
| 100 |
-
type: facebook/voxpopuli
|
| 101 |
-
config: en
|
| 102 |
-
split: test
|
| 103 |
-
metrics:
|
| 104 |
-
- type: wer
|
| 105 |
-
value: 7.11
|
| 106 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 107 |
-
- task:
|
| 108 |
-
type: automatic-speech-recognition
|
| 109 |
-
name: Automatic Speech Recognition
|
| 110 |
-
dataset:
|
| 111 |
-
name: "Common Voice 17, subset of up to 1,000 samples"
|
| 112 |
-
type: fixie-ai/common_voice_17_0
|
| 113 |
-
config: en
|
| 114 |
-
split: test
|
| 115 |
-
metrics:
|
| 116 |
-
- type: wer
|
| 117 |
-
value: 7.18
|
| 118 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 119 |
-
- task:
|
| 120 |
-
type: automatic-speech-recognition
|
| 121 |
-
name: Automatic Speech Recognition
|
| 122 |
-
dataset:
|
| 123 |
-
name: "AMI (IHM), subset of up to 1,000 samples"
|
| 124 |
-
type: edinburghcstr/ami
|
| 125 |
-
config: ihm
|
| 126 |
-
split: test
|
| 127 |
-
metrics:
|
| 128 |
-
- type: wer
|
| 129 |
-
value: 8.99
|
| 130 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 131 |
-
- task:
|
| 132 |
-
type: automatic-speech-recognition
|
| 133 |
-
name: Automatic Speech Recognition
|
| 134 |
-
dataset:
|
| 135 |
-
name: "GigaSpeech (dev), subset of up to 1,000 samples"
|
| 136 |
-
type: fixie-ai/gigaspeech
|
| 137 |
-
config: dev
|
| 138 |
-
split: dev
|
| 139 |
-
metrics:
|
| 140 |
-
- type: wer
|
| 141 |
-
value: 9.06
|
| 142 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 143 |
-
- task:
|
| 144 |
-
type: automatic-speech-recognition
|
| 145 |
-
name: Automatic Speech Recognition
|
| 146 |
-
dataset:
|
| 147 |
-
name: "Earnings22 (held out), subset of up to 1,000 samples"
|
| 148 |
-
type: sanchit-gandhi/earnings22_robust_split
|
| 149 |
-
config: default
|
| 150 |
-
split: test
|
| 151 |
-
metrics:
|
| 152 |
-
- type: wer
|
| 153 |
-
value: 10.58
|
| 154 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 155 |
-
- task:
|
| 156 |
-
type: automatic-speech-recognition
|
| 157 |
-
name: Automatic Speech Recognition
|
| 158 |
-
dataset:
|
| 159 |
-
name: "People's Speech, subset of up to 1,000 samples"
|
| 160 |
-
type: fixie-ai/peoples_speech
|
| 161 |
-
config: clean
|
| 162 |
-
split: test
|
| 163 |
-
metrics:
|
| 164 |
-
- type: wer
|
| 165 |
-
value: 17.59
|
| 166 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 167 |
-
- task:
|
| 168 |
-
type: automatic-speech-recognition
|
| 169 |
-
name: Automatic Speech Recognition
|
| 170 |
-
dataset:
|
| 171 |
-
name: "AMI (SDM), subset of up to 1,000 samples"
|
| 172 |
-
type: edinburghcstr/ami
|
| 173 |
-
config: sdm
|
| 174 |
-
split: test
|
| 175 |
-
metrics:
|
| 176 |
-
- type: wer
|
| 177 |
-
value: 23.53
|
| 178 |
-
name: WER (normalized, subset of up to 1,000 samples)
|
| 179 |
---
|
| 180 |
|
| 181 |
# Tiny Audio
|
| 182 |
|
| 183 |
**A tiny, hackable, open-source speech LLM.**
|
| 184 |
|
| 185 |
-
It transcribes English with punctuation, capitalization, word timestamps, and speaker labels
|
| 186 |
-
|
| 187 |
-
trained with [Tiny Audio](https://github.com/alexkroman/tiny-audio).
|
| 188 |
|
| 189 |
**[Try the live demo](https://huggingface.co/spaces/mazesmazes/tiny-audio)** ·
|
| 190 |
**[Train your own](https://github.com/alexkroman/tiny-audio)** ·
|
| 191 |
**[Free 3.5-hour course](https://github.com/alexkroman/tiny-audio/blob/main/docs/course/0-course-overview.md)**
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
## Quick Start
|
| 194 |
|
| 195 |
```bash
|
|
@@ -211,36 +89,13 @@ the model writes punctuation, capitalization, and numbers itself.
|
|
| 211 |
|
| 212 |
## Benchmarks
|
| 213 |
|
| 214 |
-
|
| 215 |
[Tiny Audio eval harness](https://github.com/alexkroman/tiny-audio/tree/main/scripts/eval)
|
| 216 |
-
(`ta eval`)
|
| 217 |
-
|
| 218 |
-
| Dataset | WER |
|
| 219 |
-
| --------------------------- | -------: |
|
| 220 |
-
| LibriSpeech test-clean | 1.84 |
|
| 221 |
-
| SPGISpeech | 2.29 |
|
| 222 |
-
| TED-LIUM | 3.71 |
|
| 223 |
-
| LoquaciousSet † | 6.20 |
|
| 224 |
-
| LibriSpeech test-other | 6.38 |
|
| 225 |
-
| VoxPopuli | 7.11 |
|
| 226 |
-
| Common Voice | 7.18 |
|
| 227 |
-
| AMI (IHM) | 8.99 |
|
| 228 |
-
| GigaSpeech | 9.06 |
|
| 229 |
-
| Earnings22 † | 10.58 |
|
| 230 |
-
| People's Speech | 17.59 |
|
| 231 |
-
| AMI (SDM) | 23.53 |
|
| 232 |
-
| **Mean (12 sets)** | **8.71** |
|
| 233 |
-
| **Pooled (11,822 samples)** | **7.42** |
|
| 234 |
-
|
| 235 |
-
† Held out: no data from this source was used in training.
|
| 236 |
-
|
| 237 |
-
To reproduce a row, or score AssemblyAI, Deepgram, ElevenLabs, or Apple's on-device recognizer on
|
| 238 |
-
the same samples:
|
| 239 |
|
| 240 |
```bash
|
| 241 |
git clone https://github.com/alexkroman/tiny-audio.git && cd tiny-audio && poetry install
|
| 242 |
poetry run ta eval -m mazesmazes/tiny-audio -d loquacious -n 100
|
| 243 |
-
ASSEMBLYAI_API_KEY=... poetry run ta eval -m assemblyai -d loquacious -n 100
|
| 244 |
```
|
| 245 |
|
| 246 |
## More Than Plain Text
|
|
@@ -265,9 +120,9 @@ for r in pipe(["audio1.wav", "audio2.wav", "audio3.wav"], batch_size=4):
|
|
| 265 |
|
| 266 |
`return_timestamps=True` times every word with
|
| 267 |
[Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) and returns them
|
| 268 |
-
under a `words` key. Audio of any length works: the pipeline transcribes it in
|
| 269 |
-
pauses (the model trained on clips up to
|
| 270 |
-
batches, and places every word on the recording's timeline.
|
| 271 |
|
| 272 |
```python
|
| 273 |
result = pipe("audio.wav", return_timestamps=True)
|
|
@@ -323,12 +178,12 @@ support), the transcript is still returned and the error is reported under
|
|
| 323 |
## Train Your Own
|
| 324 |
|
| 325 |
Everything that produced this model is open: the code, the data mix, and the recipe. Swap the
|
| 326 |
-
encoder, the LLM, or the projector from config and train on your own data.
|
| 327 |
-
|
| 328 |
|
| 329 |
```bash
|
| 330 |
git clone https://github.com/alexkroman/tiny-audio.git && cd tiny-audio && poetry install
|
| 331 |
-
poetry run python scripts/train.py
|
| 332 |
```
|
| 333 |
|
| 334 |
The
|
|
@@ -336,39 +191,48 @@ The
|
|
| 336 |
walks through how the pieces fit, training a model, evaluating it, and publishing it with a demo
|
| 337 |
like [this one](https://huggingface.co/spaces/mazesmazes/tiny-audio).
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
## Model Specifications
|
| 340 |
|
| 341 |
| Specification | Value |
|
| 342 |
| ---------------- | ------------------------------------------------------------------ |
|
| 343 |
| Input | Audio (16kHz mono) |
|
| 344 |
| Output | Punctuated, capitalized text with formatted numbers |
|
| 345 |
-
| Max Audio Length |
|
| 346 |
-
| Vocabulary | Qwen3
|
| 347 |
| Languages | English only |
|
| 348 |
| Generation | Greedy decoding (num_beams=1, do_sample=False), max 256 new tokens |
|
|
|
|
|
|
|
| 349 |
|
| 350 |
## Training Details
|
| 351 |
|
| 352 |
-
| |
|
| 353 |
-
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------
|
| 354 |
-
| **Data** | LibriHeavy (medium), People's Speech (clean), Common Voice 17, GigaSpeech (M), SPGISpeech (M),
|
| 355 |
-
| **
|
| 356 |
-
| **
|
| 357 |
-
| **
|
| 358 |
-
| **
|
| 359 |
-
| **
|
| 360 |
-
| **Precision** | bf16 (projector held in fp32) |
|
| 361 |
|
| 362 |
-
The training recipe is
|
| 363 |
-
[`configs/
|
|
|
|
| 364 |
|
| 365 |
## Limitations
|
| 366 |
|
| 367 |
- **English only**: Not trained on other languages.
|
| 368 |
- **Sample rate**: Expects 16kHz audio (other rates are resampled automatically).
|
| 369 |
-
- **Audio length**:
|
| 370 |
-
|
| 371 |
-
`return_speakers=True`, which transcribe in 8-18 s chunks automatically.
|
| 372 |
- **Speaker diarization**: At most 8 speakers per recording. The count is detected automatically;
|
| 373 |
`num_speakers` and `max_speakers` can cap it, but `min_speakers` is not supported (passing it
|
| 374 |
raises a `ValueError`). Needs transformers `main` until the next release.
|
|
@@ -382,22 +246,22 @@ The training recipe is
|
|
| 382 |
| File | Description |
|
| 383 |
| ------------------------------------------------------------------ | -------------------------------------------------------- |
|
| 384 |
| `config.json` | Model configuration |
|
| 385 |
-
| `model.safetensors` | Projector weights (~
|
| 386 |
-
| `adapter_config.json` / `adapter_model.safetensors` | LoRA adapters for the decoder (~270MB) |
|
| 387 |
| `preprocessor_config.json` | Audio preprocessing config |
|
| 388 |
| `tokenizer.json` / `tokenizer_config.json` / `chat_template.jinja` | Tokenizer |
|
| 389 |
| `asr_*.py`, `projectors.py`, `alignment.py`, `diarization.py` | Custom model code (loaded with `trust_remote_code=True`) |
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
|
| 394 |
-
|
|
|
|
| 395 |
|
| 396 |
```python
|
| 397 |
pipe = pipeline(
|
| 398 |
"automatic-speech-recognition",
|
| 399 |
model="mazesmazes/tiny-audio",
|
| 400 |
-
revision="
|
| 401 |
trust_remote_code=True,
|
| 402 |
)
|
| 403 |
```
|
|
@@ -407,10 +271,10 @@ pipe = pipeline(
|
|
| 407 |
If you use this model, please cite:
|
| 408 |
|
| 409 |
```bibtex
|
| 410 |
-
@misc{
|
| 411 |
author = {Alex Kroman},
|
| 412 |
title = {Tiny Audio: Minimal ASR Training},
|
| 413 |
-
year = {
|
| 414 |
publisher = {GitHub},
|
| 415 |
url = {https://github.com/alexkroman/tiny-audio}
|
| 416 |
}
|
|
@@ -418,15 +282,18 @@ If you use this model, please cite:
|
|
| 418 |
|
| 419 |
## Acknowledgments
|
| 420 |
|
| 421 |
-
- [Granite Speech](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc) for the
|
| 422 |
audio encoder
|
| 423 |
-
- [Qwen3
|
| 424 |
- [Qwen3-ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) for word timestamps
|
| 425 |
- [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization) for speaker
|
| 426 |
diarization
|
| 427 |
-
- The LibriHeavy, People's Speech, Common Voice, GigaSpeech, SPGISpeech,
|
| 428 |
-
|
| 429 |
|
| 430 |
## License
|
| 431 |
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
license: cc-by-nc-sa-4.0
|
| 3 |
language:
|
| 4 |
- en
|
| 5 |
datasets:
|
|
|
|
| 8 |
- fixie-ai/common_voice_17_0
|
| 9 |
- speechcolab/gigaspeech
|
| 10 |
- kensho/spgispeech
|
|
|
|
| 11 |
- edinburghcstr/ami
|
| 12 |
- sanchit-gandhi/tedlium-data
|
| 13 |
+
- edinburghcstr/edacc
|
| 14 |
+
- mazesmazes/notsofar-train
|
| 15 |
base_model:
|
| 16 |
+
- ibm-granite/granite-speech-5.0-470m-turboctc-nc
|
| 17 |
+
- Qwen/Qwen3-0.6B
|
| 18 |
pipeline_tag: automatic-speech-recognition
|
| 19 |
tags:
|
| 20 |
- asr
|
|
|
|
| 26 |
- speaker-diarization
|
| 27 |
- qwen
|
| 28 |
- granite-speech
|
|
|
|
| 29 |
library_name: transformers
|
| 30 |
metrics:
|
| 31 |
- wer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
---
|
| 33 |
|
| 34 |
# Tiny Audio
|
| 35 |
|
| 36 |
**A tiny, hackable, open-source speech LLM.**
|
| 37 |
|
| 38 |
+
It transcribes English with punctuation, capitalization, word timestamps, and speaker labels. Built
|
| 39 |
+
and trained with [Tiny Audio](https://github.com/alexkroman/tiny-audio).
|
|
|
|
| 40 |
|
| 41 |
**[Try the live demo](https://huggingface.co/spaces/mazesmazes/tiny-audio)** ·
|
| 42 |
**[Train your own](https://github.com/alexkroman/tiny-audio)** ·
|
| 43 |
**[Free 3.5-hour course](https://github.com/alexkroman/tiny-audio/blob/main/docs/course/0-course-overview.md)**
|
| 44 |
|
| 45 |
+
## Model Description
|
| 46 |
+
|
| 47 |
+
Tiny Audio connects a frozen, pretrained speech encoder to a pretrained LLM with a small trainable
|
| 48 |
+
projector:
|
| 49 |
+
|
| 50 |
+
```text
|
| 51 |
+
Audio (16 kHz) → speech encoder (frozen) → MLP projector (trained) → LLM decoder → Text
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
The published model pairs the non-causal Granite Speech 5.0 470M encoder (kept frozen) with the
|
| 55 |
+
Qwen3-0.6B decoder. The MLP projector maps encoder frames into the decoder's embedding space; it is
|
| 56 |
+
the only part trained from scratch, and the whole decoder is fine-tuned alongside it: ~604M trained
|
| 57 |
+
parameters (596M decoder, 8.4M projector). The encoder, projector, and decoder are each swappable
|
| 58 |
+
from config in the [training repo](https://github.com/alexkroman/tiny-audio).
|
| 59 |
+
|
| 60 |
+
## Intended Use
|
| 61 |
+
|
| 62 |
+
Tiny Audio is for transcribing English speech — meetings, calls, lectures, and other recorded audio
|
| 63 |
+
— when you want the finished transcript itself: punctuation, capitalization, and formatted numbers
|
| 64 |
+
included, plus word-level timestamps and speaker labels when you need them. It is also built to be
|
| 65 |
+
read and retrained: the training code, data mix, and recipe are open, so you can adapt it to your
|
| 66 |
+
own audio.
|
| 67 |
+
|
| 68 |
+
Out of scope: non-English transcription, and audio far from the training distribution — far-field,
|
| 69 |
+
noisy, or heavily overlapping speech — where word error rate rises (see Benchmarks and Limitations).
|
| 70 |
+
|
| 71 |
## Quick Start
|
| 72 |
|
| 73 |
```bash
|
|
|
|
| 89 |
|
| 90 |
## Benchmarks
|
| 91 |
|
| 92 |
+
Benchmark results for this release have not been published yet. To score it yourself with the
|
| 93 |
[Tiny Audio eval harness](https://github.com/alexkroman/tiny-audio/tree/main/scripts/eval)
|
| 94 |
+
(`ta eval`):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
```bash
|
| 97 |
git clone https://github.com/alexkroman/tiny-audio.git && cd tiny-audio && poetry install
|
| 98 |
poetry run ta eval -m mazesmazes/tiny-audio -d loquacious -n 100
|
|
|
|
| 99 |
```
|
| 100 |
|
| 101 |
## More Than Plain Text
|
|
|
|
| 120 |
|
| 121 |
`return_timestamps=True` times every word with
|
| 122 |
[Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) and returns them
|
| 123 |
+
under a `words` key. Audio of any length works: the pipeline transcribes it in chunks of up to 28 s
|
| 124 |
+
cut at pauses (the model trained on clips up to 30 s), aligns each chunk against its own transcript
|
| 125 |
+
in batches, and places every word on the recording's timeline.
|
| 126 |
|
| 127 |
```python
|
| 128 |
result = pipe("audio.wav", return_timestamps=True)
|
|
|
|
| 178 |
## Train Your Own
|
| 179 |
|
| 180 |
Everything that produced this model is open: the code, the data mix, and the recipe. Swap the
|
| 181 |
+
encoder, the LLM, or the projector from config and train on your own data. The recipe is the repo's
|
| 182 |
+
default config:
|
| 183 |
|
| 184 |
```bash
|
| 185 |
git clone https://github.com/alexkroman/tiny-audio.git && cd tiny-audio && poetry install
|
| 186 |
+
poetry run python scripts/train.py
|
| 187 |
```
|
| 188 |
|
| 189 |
The
|
|
|
|
| 191 |
walks through how the pieces fit, training a model, evaluating it, and publishing it with a demo
|
| 192 |
like [this one](https://huggingface.co/spaces/mazesmazes/tiny-audio).
|
| 193 |
|
| 194 |
+
## Serving and Streaming
|
| 195 |
+
|
| 196 |
+
For more than one-off calls, the [training repo](https://github.com/alexkroman/tiny-audio) ships a
|
| 197 |
+
batched HTTP server (`ta serve`) with an async jobs API for long recordings, and the model class
|
| 198 |
+
exposes token-by-token streaming output via `ASRModel.generate_streaming`. See the
|
| 199 |
+
[repo README](https://github.com/alexkroman/tiny-audio#as-an-http-api-on-runpod) for setup and the
|
| 200 |
+
request format.
|
| 201 |
+
|
| 202 |
## Model Specifications
|
| 203 |
|
| 204 |
| Specification | Value |
|
| 205 |
| ---------------- | ------------------------------------------------------------------ |
|
| 206 |
| Input | Audio (16kHz mono) |
|
| 207 |
| Output | Punctuated, capitalized text with formatted numbers |
|
| 208 |
+
| Max Audio Length | Any length; audio over 28 s is transcribed in chunks cut at pauses |
|
| 209 |
+
| Vocabulary | Qwen3 tokenizer |
|
| 210 |
| Languages | English only |
|
| 211 |
| Generation | Greedy decoding (num_beams=1, do_sample=False), max 256 new tokens |
|
| 212 |
+
| Optional output | Word-level timestamps; speaker-labelled words and speaker segments |
|
| 213 |
+
| Weights | ~2.1 GB in bf16 (encoder + projector + decoder) |
|
| 214 |
|
| 215 |
## Training Details
|
| 216 |
|
| 217 |
+
| | |
|
| 218 |
+
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 219 |
+
| **Data** | LibriHeavy (medium), People's Speech (clean), Common Voice 17, GigaSpeech (M), SPGISpeech (M), AMI (IHM + SDM), TED-LIUM, NOTSOFAR, EdAcc: one pass |
|
| 220 |
+
| **Steps** | 16,159 |
|
| 221 |
+
| **Batch Size** | 304 |
|
| 222 |
+
| **Optimizer** | AdamW (fused), cosine schedule, 1,000 warmup steps, no weight decay |
|
| 223 |
+
| **Learning Rate** | 1e-3 (projector), 2e-5 (decoder) |
|
| 224 |
+
| **Precision** | bf16 mixed precision (weights held in fp32) |
|
|
|
|
| 225 |
|
| 226 |
+
The training recipe is the default config,
|
| 227 |
+
[`configs/config.yaml`](https://github.com/alexkroman/tiny-audio/blob/main/configs/config.yaml) with
|
| 228 |
+
[`configs/training/production.yaml`](https://github.com/alexkroman/tiny-audio/blob/main/configs/training/production.yaml).
|
| 229 |
|
| 230 |
## Limitations
|
| 231 |
|
| 232 |
- **English only**: Not trained on other languages.
|
| 233 |
- **Sample rate**: Expects 16kHz audio (other rates are resampled automatically).
|
| 234 |
+
- **Audio length**: Audio longer than 28 seconds is cut into chunks at pauses and transcribed chunk
|
| 235 |
+
by chunk, so context does not carry across chunk boundaries.
|
|
|
|
| 236 |
- **Speaker diarization**: At most 8 speakers per recording. The count is detected automatically;
|
| 237 |
`num_speakers` and `max_speakers` can cap it, but `min_speakers` is not supported (passing it
|
| 238 |
raises a `ValueError`). Needs transformers `main` until the next release.
|
|
|
|
| 246 |
| File | Description |
|
| 247 |
| ------------------------------------------------------------------ | -------------------------------------------------------- |
|
| 248 |
| `config.json` | Model configuration |
|
| 249 |
+
| `model.safetensors` | Projector and fine-tuned decoder weights (~2.4 GB, fp32) |
|
|
|
|
| 250 |
| `preprocessor_config.json` | Audio preprocessing config |
|
| 251 |
| `tokenizer.json` / `tokenizer_config.json` / `chat_template.jinja` | Tokenizer |
|
| 252 |
| `asr_*.py`, `projectors.py`, `alignment.py`, `diarization.py` | Custom model code (loaded with `trust_remote_code=True`) |
|
| 253 |
|
| 254 |
+
The encoder (Granite Speech) is downloaded from its own Hugging Face repo. `config.json` loads the
|
| 255 |
+
weights in bf16, as the eval harness runs them.
|
| 256 |
|
| 257 |
+
Earlier models are still available by revision: `granite-qwen-frozen-4` (Granite Speech 470M +
|
| 258 |
+
Qwen3.5-2B with LoRA, MIT) and `glm-asr-qwen3-0.6b` (GLM-ASR + Qwen3-0.6B):
|
| 259 |
|
| 260 |
```python
|
| 261 |
pipe = pipeline(
|
| 262 |
"automatic-speech-recognition",
|
| 263 |
model="mazesmazes/tiny-audio",
|
| 264 |
+
revision="granite-qwen-frozen-4",
|
| 265 |
trust_remote_code=True,
|
| 266 |
)
|
| 267 |
```
|
|
|
|
| 271 |
If you use this model, please cite:
|
| 272 |
|
| 273 |
```bibtex
|
| 274 |
+
@misc{tinyaudio2025,
|
| 275 |
author = {Alex Kroman},
|
| 276 |
title = {Tiny Audio: Minimal ASR Training},
|
| 277 |
+
year = {2025},
|
| 278 |
publisher = {GitHub},
|
| 279 |
url = {https://github.com/alexkroman/tiny-audio}
|
| 280 |
}
|
|
|
|
| 282 |
|
| 283 |
## Acknowledgments
|
| 284 |
|
| 285 |
+
- [Granite Speech](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc-nc) for the
|
| 286 |
audio encoder
|
| 287 |
+
- [Qwen3](https://huggingface.co/Qwen/Qwen3-0.6B) for the language model
|
| 288 |
- [Qwen3-ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) for word timestamps
|
| 289 |
- [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization) for speaker
|
| 290 |
diarization
|
| 291 |
+
- The LibriHeavy, People's Speech, Common Voice, GigaSpeech, SPGISpeech, AMI, TED-LIUM, NOTSOFAR,
|
| 292 |
+
and EdAcc teams for training data
|
| 293 |
|
| 294 |
## License
|
| 295 |
|
| 296 |
+
CC BY-NC-SA 4.0 (non-commercial, share-alike), inherited from the
|
| 297 |
+
[Granite Speech 5.0 470M non-causal encoder](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc-nc)
|
| 298 |
+
this model is built on. For commercial use, see the MIT-licensed `granite-qwen-frozen-4` revision
|
| 299 |
+
above. The Tiny Audio training code is MIT.
|
adapter_config.json
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"alora_invocation_tokens": null,
|
| 3 |
-
"alpha_pattern": {},
|
| 4 |
-
"arrow_config": null,
|
| 5 |
-
"auto_mapping": null,
|
| 6 |
-
"base_model_name_or_path": "mazesmazes/tiny-audio",
|
| 7 |
-
"bias": "none",
|
| 8 |
-
"corda_config": null,
|
| 9 |
-
"ensure_weight_tying": false,
|
| 10 |
-
"eva_config": null,
|
| 11 |
-
"exclude_modules": null,
|
| 12 |
-
"fan_in_fan_out": false,
|
| 13 |
-
"inference_mode": true,
|
| 14 |
-
"init_lora_weights": true,
|
| 15 |
-
"layer_replication": null,
|
| 16 |
-
"layers_pattern": null,
|
| 17 |
-
"layers_to_transform": null,
|
| 18 |
-
"loftq_config": {},
|
| 19 |
-
"lora_alpha": 64,
|
| 20 |
-
"lora_bias": false,
|
| 21 |
-
"lora_dropout": 0.0,
|
| 22 |
-
"lora_ga_config": null,
|
| 23 |
-
"megatron_config": null,
|
| 24 |
-
"megatron_core": "megatron.core",
|
| 25 |
-
"modules_to_save": null,
|
| 26 |
-
"monteclora_config": null,
|
| 27 |
-
"peft_type": "LORA",
|
| 28 |
-
"peft_version": "0.20.0",
|
| 29 |
-
"qalora_group_size": 16,
|
| 30 |
-
"r": 64,
|
| 31 |
-
"rank_pattern": {},
|
| 32 |
-
"revision": null,
|
| 33 |
-
"target_modules": [
|
| 34 |
-
"in_proj_qkv",
|
| 35 |
-
"in_proj_z",
|
| 36 |
-
"q_proj",
|
| 37 |
-
"o_proj",
|
| 38 |
-
"down_proj",
|
| 39 |
-
"in_proj_a",
|
| 40 |
-
"out_proj",
|
| 41 |
-
"in_proj_b",
|
| 42 |
-
"gate_proj",
|
| 43 |
-
"k_proj",
|
| 44 |
-
"v_proj",
|
| 45 |
-
"up_proj"
|
| 46 |
-
],
|
| 47 |
-
"target_parameters": null,
|
| 48 |
-
"task_type": "CAUSAL_LM",
|
| 49 |
-
"trainable_token_indices": null,
|
| 50 |
-
"use_bdlora": null,
|
| 51 |
-
"use_dora": false,
|
| 52 |
-
"use_qalora": false,
|
| 53 |
-
"use_rslora": false,
|
| 54 |
-
"velora_config": null
|
| 55 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
adapter_model.safetensors
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:a1d0d0f7f86efcf43b4453400a10d81b3ec2b24150690dca9e6a8f7d1085680a
|
| 3 |
-
size 269158056
|
|
|
|
|
|
|
|
|
|
|
|
alignment.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import itertools
|
| 6 |
from collections.abc import Sequence
|
| 7 |
-
from typing import TYPE_CHECKING, TypedDict
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
import numpy.typing as npt
|
|
@@ -93,7 +93,7 @@ class QwenForcedAligner:
|
|
| 93 |
cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
|
| 94 |
)
|
| 95 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 96 |
-
model.to(device)
|
| 97 |
model.eval()
|
| 98 |
cls._model = model
|
| 99 |
cls._processor = Qwen3ASRProcessor.from_pretrained(cls.MODEL_ID)
|
|
|
|
| 4 |
|
| 5 |
import itertools
|
| 6 |
from collections.abc import Sequence
|
| 7 |
+
from typing import TYPE_CHECKING, TypedDict, cast
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
import numpy.typing as npt
|
|
|
|
| 93 |
cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
|
| 94 |
)
|
| 95 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 96 |
+
cast(torch.nn.Module, model).to(device)
|
| 97 |
model.eval()
|
| 98 |
cls._model = model
|
| 99 |
cls._processor = Qwen3ASRProcessor.from_pretrained(cls.MODEL_ID)
|
asr_attention.py
CHANGED
|
@@ -12,14 +12,23 @@ import logging
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
import torch
|
| 15 |
-
from transformers import AutoConfig
|
| 16 |
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
# FlashAttention's kernels are compiled for head dimensions up to 256.
|
| 20 |
FLASH_ATTENTION_MAX_HEAD_DIM = 256
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def _max_attention_head_dim(text_config: object) -> int | None:
|
| 24 |
"""Largest attention head dim across layers, or None if undeterminable.
|
| 25 |
|
|
@@ -35,8 +44,9 @@ def _max_attention_head_dim(text_config: object) -> int | None:
|
|
| 35 |
else:
|
| 36 |
try:
|
| 37 |
dims = [getattr(text_config, "head_dim", None)]
|
| 38 |
-
except
|
| 39 |
-
#
|
|
|
|
| 40 |
dims = []
|
| 41 |
usable = [d for d in dims if isinstance(d, int) and d > 0]
|
| 42 |
return max(usable) if usable else None
|
|
@@ -72,6 +82,7 @@ def _has_sliding_window_attention(model_id: str) -> bool:
|
|
| 72 |
"Could not read the config for %s to check for sliding-window "
|
| 73 |
"attention; assuming it has some and using eager attention on MPS.",
|
| 74 |
model_id,
|
|
|
|
| 75 |
)
|
| 76 |
return True
|
| 77 |
|
|
@@ -148,9 +159,31 @@ def resolve_attn_implementation(requested: str | None, model_id: str | None = No
|
|
| 148 |
"falling back to sdpa."
|
| 149 |
)
|
| 150 |
return "sdpa"
|
|
|
|
|
|
|
| 151 |
return requested
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def resolve_decoder_attn_implementation(requested: str | None, model_id: str) -> str | None:
|
| 155 |
"""`resolve_attn_implementation` for the decoder, plus FlashAttention's head-dim limit.
|
| 156 |
|
|
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
import torch
|
| 15 |
+
from transformers import AutoConfig, PretrainedConfig
|
| 16 |
|
| 17 |
+
logger: logging.Logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
# FlashAttention's kernels are compiled for head dimensions up to 256.
|
| 20 |
FLASH_ATTENTION_MAX_HEAD_DIM = 256
|
| 21 |
|
| 22 |
|
| 23 |
+
def active_attn_implementation(config: PretrainedConfig) -> object:
|
| 24 |
+
"""The attention kernel a loaded model runs (e.g. "sdpa"), read from its config.
|
| 25 |
+
|
| 26 |
+
transformers exposes it only as the private `_attn_implementation` field,
|
| 27 |
+
so this is the one place that reads it.
|
| 28 |
+
"""
|
| 29 |
+
return config._attn_implementation # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
def _max_attention_head_dim(text_config: object) -> int | None:
|
| 33 |
"""Largest attention head dim across layers, or None if undeterminable.
|
| 34 |
|
|
|
|
| 44 |
else:
|
| 45 |
try:
|
| 46 |
dims = [getattr(text_config, "head_dim", None)]
|
| 47 |
+
except RuntimeError:
|
| 48 |
+
# transformers' AmbiguousGlobalPerLayerAttributeError (a RuntimeError)
|
| 49 |
+
# from the heterogeneity guard; treated as "unknown".
|
| 50 |
dims = []
|
| 51 |
usable = [d for d in dims if isinstance(d, int) and d > 0]
|
| 52 |
return max(usable) if usable else None
|
|
|
|
| 82 |
"Could not read the config for %s to check for sliding-window "
|
| 83 |
"attention; assuming it has some and using eager attention on MPS.",
|
| 84 |
model_id,
|
| 85 |
+
exc_info=True,
|
| 86 |
)
|
| 87 |
return True
|
| 88 |
|
|
|
|
| 159 |
"falling back to sdpa."
|
| 160 |
)
|
| 161 |
return "sdpa"
|
| 162 |
+
if not _flash_attention_runs():
|
| 163 |
+
return "sdpa"
|
| 164 |
return requested
|
| 165 |
|
| 166 |
|
| 167 |
+
@functools.cache
|
| 168 |
+
def _flash_attention_runs() -> bool:
|
| 169 |
+
"""Whether flash-attn's kernels actually run, forward and backward, on this GPU.
|
| 170 |
+
|
| 171 |
+
Importing proves only that the package is installed: its kernels are
|
| 172 |
+
compiled per GPU architecture, so a build without this card's (e.g. the
|
| 173 |
+
Blackwell RTX PRO 6000, sm_120) imports cleanly and fails at the first
|
| 174 |
+
attention call, mid-load or mid-step. Checked once per process.
|
| 175 |
+
"""
|
| 176 |
+
try:
|
| 177 |
+
flash_attn = importlib.import_module("flash_attn")
|
| 178 |
+
q = torch.randn(1, 8, 2, 64, device="cuda", dtype=torch.bfloat16, requires_grad=True)
|
| 179 |
+
flash_attn.flash_attn_func(q, q, q, causal=True).sum().backward()
|
| 180 |
+
# AssertionError: torch's own "not compiled with CUDA enabled".
|
| 181 |
+
except (RuntimeError, ImportError, AttributeError, AssertionError) as exc:
|
| 182 |
+
logger.warning("flash_attn is installed but its kernels fail here (%s); using sdpa", exc)
|
| 183 |
+
return False
|
| 184 |
+
return True
|
| 185 |
+
|
| 186 |
+
|
| 187 |
def resolve_decoder_attn_implementation(requested: str | None, model_id: str) -> str | None:
|
| 188 |
"""`resolve_attn_implementation` for the decoder, plus FlashAttention's head-dim limit.
|
| 189 |
|
asr_config.py
CHANGED
|
@@ -24,50 +24,75 @@ DEFAULT_ENCODER_CONV_LAYERS: list[ConvLayerSpec] = [(1, 3, 1), (1, 3, 2)]
|
|
| 24 |
# Full path: 100 Hz mel -> 2x FE stacking -> 4x encoder -> 12.5 Hz.
|
| 25 |
GRANITE_ENCODER_CONV_LAYERS: list[ConvLayerSpec] = [(0, 2, 2), (0, 2, 2)]
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# Encoders whose `input_features` are (batch, time, feature_dim) rather than
|
| 28 |
# Whisper/GLM-ASR's (batch, n_mels, mel_len). Conformer-family checkpoints
|
| 29 |
# (Granite Speech 5.0, Parakeet, Nemotron) are all time-major.
|
| 30 |
_TIME_MAJOR_ENCODER_MARKERS = ("granite-speech", "parakeet", "nemotron")
|
| 31 |
|
| 32 |
-
# Decoders that already ship a native audio-placeholder token. Reusing it is
|
| 33 |
-
# strictly better than adding "<audio>" and resizing, above all when the
|
| 34 |
-
# decoder is frozen: masked_scatter overwrites the placeholder's *input*
|
| 35 |
-
# embedding, but Gemma4 also looks the token up in `embed_tokens_per_layer` to
|
| 36 |
-
# build its per-layer embeddings (PLE), and that row is NOT overwritten. A
|
| 37 |
-
# freshly added row would hand every audio position a random, permanently
|
| 38 |
-
# untrainable PLE vector. Gemma's own "<|audio|>" row is pretrained for
|
| 39 |
-
# exactly this placeholder role.
|
| 40 |
-
_NATIVE_AUDIO_TOKENS = {"gemma-4": "<|audio|>"}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def native_audio_token(text_model_id: str | None) -> str | None:
|
| 44 |
-
"""Return the decoder's built-in audio placeholder token, if it has one."""
|
| 45 |
-
lowered = (text_model_id or "").lower()
|
| 46 |
-
return next((tok for key, tok in _NATIVE_AUDIO_TOKENS.items() if key in lowered), None)
|
| 47 |
-
|
| 48 |
|
| 49 |
def is_time_major_encoder(audio_model_id: str | None) -> bool:
|
| 50 |
"""Whether `audio_model_id` expects (batch, time, feature) input_features."""
|
| 51 |
return any(m in (audio_model_id or "").lower() for m in _TIME_MAJOR_ENCODER_MARKERS)
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
@overload
|
| 55 |
def compute_encoder_output_length(
|
| 56 |
-
mel_length: int,
|
|
|
|
|
|
|
| 57 |
) -> int: ...
|
| 58 |
@overload
|
| 59 |
def compute_encoder_output_length(
|
| 60 |
-
mel_length: "torch.Tensor",
|
|
|
|
|
|
|
| 61 |
) -> "torch.Tensor": ...
|
| 62 |
def compute_encoder_output_length(
|
| 63 |
-
mel_length: "int | torch.Tensor",
|
|
|
|
|
|
|
| 64 |
) -> "int | torch.Tensor":
|
| 65 |
"""Apply encoder conv layer formulas to compute output length.
|
| 66 |
|
| 67 |
Works with both Python ints and torch tensors of mel lengths; the formula
|
| 68 |
`(L + 2*p - (k-1) - 1) // s + 1` per layer is identical for both.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
layers = conv_layers if conv_layers is not None else DEFAULT_ENCODER_CONV_LAYERS
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
length = mel_length
|
| 72 |
for padding, kernel_size, stride in layers:
|
| 73 |
length = (length + 2 * padding - (kernel_size - 1) - 1) // stride + 1
|
|
@@ -114,14 +139,19 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 114 |
# Encoder conv layers: list of (padding, kernel_size, stride) tuples
|
| 115 |
# Default is Whisper/GLM-ASR structure: conv1(k=3,s=1,p=1) + conv2(k=3,s=2,p=1)
|
| 116 |
encoder_conv_layers: list[ConvLayerSpec] | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
audio_sample_rate: int = 16000,
|
| 118 |
-
# Whether the encoder takes `input_features` as (batch, time, feature)
|
| 119 |
-
# instead of Whisper/GLM-ASR's (batch, n_mels, mel_len). Only
|
| 120 |
-
# SpecAugment unpacks all three dims, so this exists to keep
|
| 121 |
-
# `_mask_input_features` masking the time axis rather than the feature
|
| 122 |
-
# axis. Left as None it is auto-detected from `audio_model_id`, so a
|
| 123 |
-
# Granite/Parakeet swap can't silently mask the wrong axis.
|
| 124 |
-
audio_features_time_major: bool | None = None,
|
| 125 |
# Whether to forward the mel padding mask into the audio encoder.
|
| 126 |
# This matters a lot for Granite: it uses block attention over fixed
|
| 127 |
# 128-frame blocks, so with `padding="longest"` batches the pad frames
|
|
@@ -135,16 +165,13 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 135 |
# incomparable to those baselines. Flip it explicitly to test.
|
| 136 |
encoder_attention_mask: bool | None = None,
|
| 137 |
# Placeholder token whose embeddings get replaced by projector output.
|
| 138 |
-
# Defaults to
|
| 139 |
-
# otherwise "<audio>", which is added to the tokenizer and requires an
|
| 140 |
# embedding resize.
|
| 141 |
audio_token: str | None = None,
|
| 142 |
# dtype for the projector alone. The fp32-master-weights argument only
|
| 143 |
# applies to parameters an optimizer actually updates, so pinning the
|
| 144 |
# whole stack to float32 to protect a 10M-param projector wastes 2
|
| 145 |
-
# bytes on every frozen parameter
|
| 146 |
-
# (5.12B stored params; "E2B" counts *active* params, and the
|
| 147 |
-
# per-layer-embedding table alone is 2.35B). Set this to float32 while
|
| 148 |
# model_dtype stays bfloat16 to get master-weight precision where it
|
| 149 |
# matters at frozen-model memory cost. Defaults to model_dtype, so
|
| 150 |
# existing recipes are unchanged.
|
|
@@ -164,41 +191,12 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 164 |
encoder_dtype: str | None = None,
|
| 165 |
projector_pool_stride: int = 4,
|
| 166 |
projector_hidden_dim: int | None = None,
|
| 167 |
-
projector_type: str = "mlp",
|
| 168 |
-
# Label smoothing applied inside the LM's loss function (not HF Trainer's
|
| 169 |
-
# LabelSmoother). Train-only — ASRModel.forward zeros it on eval. Routing
|
| 170 |
-
# smoothing through the loss_function flows through liger's fused linear
|
| 171 |
-
# CE when apply_liger_kernel_to_qwen3() is active, avoiding the
|
| 172 |
-
# (B,T,V) fp32 log_softmax materialization that the HF LabelSmoother
|
| 173 |
-
# path requires (~15GB at B=50/V=152k on Qwen3-0.6B).
|
| 174 |
-
label_smoothing: float = 0.0,
|
| 175 |
# LoRA configuration (for Stage 2 fine-tuning)
|
| 176 |
use_lora: bool = False,
|
| 177 |
lora_rank: int = 8, # SALMONN default
|
| 178 |
lora_alpha: int = 32, # SALMONN default (scaling factor 4.0)
|
| 179 |
lora_dropout: float = 0.0,
|
| 180 |
lora_target_modules: list[str] | None = None, # Default: all linear layers
|
| 181 |
-
# Per-module rank/alpha overrides, keyed by the module's leaf name and
|
| 182 |
-
# matched by PEFT as `(.*\.)?<key>$` against the full module path.
|
| 183 |
-
#
|
| 184 |
-
# These exist because `all-linear` is blind to a matrix's shape. On
|
| 185 |
-
# Qwen3.5 the gated-DeltaNet gates `in_proj_a` / `in_proj_b` are
|
| 186 |
-
# (16, 2048) -- one scalar per value head -- so a rank-64 adapter on
|
| 187 |
-
# them is capped at rank 16 by the output dimension and spends
|
| 188 |
-
# 64*2048 + 16*64 = 132K parameters to express an update that a full
|
| 189 |
-
# fine-tune of the same matrix would carry in 32K. Measured on the
|
| 190 |
-
# step-20000 granite_qwen_top4 checkpoint: 4.76M LoRA params across the
|
| 191 |
-
# 36 gate matrices, 4.03x what full fine-tuning them costs, and the
|
| 192 |
-
# realised update's effective rank was 6.3 / 7.3 against the 64
|
| 193 |
-
# allocated.
|
| 194 |
-
#
|
| 195 |
-
# PEFT looks up rank_pattern and alpha_pattern INDEPENDENTLY, each
|
| 196 |
-
# falling back to `r` / `lora_alpha`. Overriding rank alone therefore
|
| 197 |
-
# changes the scale: alpha/r goes 128/64 = 2.0 to 128/16 = 8.0. Always
|
| 198 |
-
# set both, and keep the ratio equal to the global one unless the
|
| 199 |
-
# scale change is the point.
|
| 200 |
-
lora_rank_pattern: dict[str, int] | None = None,
|
| 201 |
-
lora_alpha_pattern: dict[str, int] | None = None,
|
| 202 |
# Seconds of silence prepended to every clip at INFERENCE. Measured on
|
| 203 |
# Peoples (n=500, paired bootstrap): 20.51% -> 19.28% WER, delta -1.22
|
| 204 |
# CI [-1.83, -0.64], with utterances losing a leading reference word
|
|
@@ -241,23 +239,6 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 241 |
# those two carry 33.57M params for 0.27% of the selection's squared
|
| 242 |
# gradient -- see unfreeze_encoder_top_layers for the measurement.
|
| 243 |
encoder_trainable_post_projections: bool = True,
|
| 244 |
-
# SpecAugment on mel input (training-only), parameters match
|
| 245 |
-
# transformers' WhisperConfig / Wav2Vec2 conventions. Most relevant
|
| 246 |
-
# when the encoder is trainable (`freeze_audio_encoder=False`) —
|
| 247 |
-
# without augmentation the encoder sees identical mel inputs on
|
| 248 |
-
# every visit and overfits fast. Standard for ASR encoder fine-
|
| 249 |
-
# tuning (Whisper, Conformer, wav2vec2 all use it). Applied to
|
| 250 |
-
# log-mel input where zero is in-distribution (silence);
|
| 251 |
-
# structurally different from the prior encoder-output ZM which
|
| 252 |
-
# was removed because zero was OOD for the encoder's emission
|
| 253 |
-
# distribution. Uses `_compute_mask_indices` from
|
| 254 |
-
# transformers.models.whisper.modeling_whisper — the same helper
|
| 255 |
-
# Whisper itself uses, vectorized over the batch and torch.compile
|
| 256 |
-
# compatible. Default values match Whisper's defaults.
|
| 257 |
-
apply_spec_augment: bool = False,
|
| 258 |
-
mask_time_prob: float = 0.05,
|
| 259 |
-
mask_time_length: int = 10,
|
| 260 |
-
mask_time_min_masks: int = 2,
|
| 261 |
max_new_tokens: int | None = None,
|
| 262 |
use_cache: bool | None = None,
|
| 263 |
no_repeat_ngram_size: int | None = None,
|
|
@@ -270,7 +251,6 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 270 |
text_model_id: HuggingFace model ID for text decoder (Qwen)
|
| 271 |
attn_implementation: Attention implementation ("flash_attention_2", "sdpa", "eager")
|
| 272 |
model_dtype: Model dtype ("bfloat16", "float16", "float32")
|
| 273 |
-
projector_type: Projector architecture ("mlp")
|
| 274 |
use_lora: Enable LoRA adapters for Stage 2 fine-tuning
|
| 275 |
"""
|
| 276 |
# Set default generation parameters (greedy decoding only).
|
|
@@ -285,55 +265,42 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 285 |
# `_truncate_repetitions` postprocess missed it because its regex is
|
| 286 |
# anchored to end-of-string and the loop was truncated mid-phrase.
|
| 287 |
#
|
| 288 |
-
# 12 is chosen to be inert on real speech: it blocks only exact 12-gram
|
| 289 |
-
# repeats, and natural English -- including genuine stutters, list
|
| 290 |
-
# recitation and "very very very" -- effectively never repeats a
|
| 291 |
-
# 12-token span verbatim. Measured rate of runaway loops is 1 in 10,630
|
| 292 |
-
# rows, so this should fire almost never; it bounds the tail rather
|
| 293 |
-
# than changing normal output.
|
| 294 |
-
generation_defaults = {
|
| 295 |
-
"max_new_tokens": 128,
|
| 296 |
-
"use_cache": True,
|
| 297 |
-
"no_repeat_ngram_size": 12,
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
self.audio_model_id = audio_model_id
|
| 301 |
self.text_model_id = text_model_id
|
| 302 |
self.attn_implementation = attn_implementation
|
| 303 |
self.model_dtype = model_dtype
|
| 304 |
self.encoder_dim = encoder_dim
|
| 305 |
self.llm_dim = llm_dim
|
| 306 |
-
self.encoder_conv_layers =
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
)
|
| 312 |
-
|
| 313 |
-
|
|
|
|
| 314 |
if encoder_attention_mask is None
|
| 315 |
else encoder_attention_mask
|
| 316 |
)
|
| 317 |
-
self.audio_token = audio_token or
|
| 318 |
-
self.projector_dtype = projector_dtype or model_dtype
|
| 319 |
-
self.encoder_dtype = encoder_dtype or model_dtype
|
| 320 |
self.audio_sample_rate = audio_sample_rate
|
| 321 |
self.projector_pool_stride = projector_pool_stride
|
| 322 |
self.projector_hidden_dim = projector_hidden_dim
|
| 323 |
-
self.projector_type = projector_type
|
| 324 |
self.transcribe_prompt = transcribe_prompt
|
| 325 |
-
self.label_smoothing = label_smoothing
|
| 326 |
# LoRA configuration
|
| 327 |
self.use_lora = use_lora
|
| 328 |
self.lora_rank = lora_rank
|
| 329 |
self.lora_alpha = lora_alpha
|
| 330 |
self.lora_dropout = lora_dropout
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
self.lora_rank_pattern = dict(lora_rank_pattern or {})
|
| 334 |
-
self.lora_alpha_pattern = dict(lora_alpha_pattern or {})
|
| 335 |
-
self.inference_lead_in_seconds = float(inference_lead_in_seconds)
|
| 336 |
-
self.lora_target_modules = lora_target_modules or [
|
| 337 |
"q_proj",
|
| 338 |
"k_proj",
|
| 339 |
"v_proj",
|
|
@@ -348,17 +315,13 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 348 |
self.freeze_audio_encoder = freeze_audio_encoder
|
| 349 |
self.encoder_trainable_top_layers = encoder_trainable_top_layers
|
| 350 |
self.encoder_trainable_post_projections = encoder_trainable_post_projections
|
| 351 |
-
self.apply_spec_augment = apply_spec_augment
|
| 352 |
-
self.mask_time_prob = mask_time_prob
|
| 353 |
-
self.mask_time_length = mask_time_length
|
| 354 |
-
self.mask_time_min_masks = mask_time_min_masks
|
| 355 |
|
| 356 |
explicit_generation_args = {
|
| 357 |
"max_new_tokens": max_new_tokens,
|
| 358 |
"use_cache": use_cache,
|
| 359 |
"no_repeat_ngram_size": no_repeat_ngram_size,
|
| 360 |
}
|
| 361 |
-
for key, default in
|
| 362 |
value = explicit_generation_args[key]
|
| 363 |
setattr(self, key, value if value is not None else default)
|
| 364 |
|
|
@@ -395,17 +358,17 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 395 |
|
| 396 |
# Point encoder to audio_config so pipeline uses correct feature extractor
|
| 397 |
# The pipeline looks for config.encoder._name_or_path for feature extractor
|
| 398 |
-
self.encoder = self.audio_config
|
| 399 |
|
| 400 |
# The Hub infers the model's task from the first model class listed, so
|
| 401 |
# AutoModelForSpeechSeq2Seq must precede AutoModel (else: feature-extraction).
|
| 402 |
-
self.auto_map = {
|
| 403 |
"AutoConfig": "asr_config.ASRConfig",
|
| 404 |
"AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
|
| 405 |
"AutoModel": "asr_modeling.ASRModel",
|
| 406 |
"AutoProcessor": "asr_processing.ASRProcessor",
|
| 407 |
}
|
| 408 |
-
self.custom_pipelines = {
|
| 409 |
"automatic-speech-recognition": {
|
| 410 |
"impl": "asr_pipeline.ASRPipeline",
|
| 411 |
"pt": ["AutoModelForSpeechSeq2Seq"],
|
|
@@ -413,7 +376,7 @@ class ASRConfig(transformers.PretrainedConfig):
|
|
| 413 |
"type": "audio",
|
| 414 |
}
|
| 415 |
}
|
| 416 |
-
self.architectures = ["ASRModel"]
|
| 417 |
self.pipeline_tag = "automatic-speech-recognition"
|
| 418 |
|
| 419 |
|
|
|
|
| 24 |
# Full path: 100 Hz mel -> 2x FE stacking -> 4x encoder -> 12.5 Hz.
|
| 25 |
GRANITE_ENCODER_CONV_LAYERS: list[ConvLayerSpec] = [(0, 2, 2), (0, 2, 2)]
|
| 26 |
|
| 27 |
+
# The decoding knobs ASRConfig owns, with their defaults. ASRModel copies
|
| 28 |
+
# exactly these onto its GenerationConfig, so a new knob is one entry here.
|
| 29 |
+
# 12 is chosen to be inert on real speech: it blocks only exact 12-gram
|
| 30 |
+
# repeats, and natural English -- including genuine stutters, list
|
| 31 |
+
# recitation and "very very very" -- effectively never repeats a
|
| 32 |
+
# 12-token span verbatim. Measured rate of runaway loops is 1 in 10,630
|
| 33 |
+
# rows, so this should fire almost never; it bounds the tail rather
|
| 34 |
+
# than changing normal output.
|
| 35 |
+
GENERATION_DEFAULTS: dict[str, Any] = {
|
| 36 |
+
"max_new_tokens": 128,
|
| 37 |
+
"use_cache": True,
|
| 38 |
+
"no_repeat_ngram_size": 12,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
# Encoders whose `input_features` are (batch, time, feature_dim) rather than
|
| 42 |
# Whisper/GLM-ASR's (batch, n_mels, mel_len). Conformer-family checkpoints
|
| 43 |
# (Granite Speech 5.0, Parakeet, Nemotron) are all time-major.
|
| 44 |
_TIME_MAJOR_ENCODER_MARKERS = ("granite-speech", "parakeet", "nemotron")
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def is_time_major_encoder(audio_model_id: str | None) -> bool:
|
| 48 |
"""Whether `audio_model_id` expects (batch, time, feature) input_features."""
|
| 49 |
return any(m in (audio_model_id or "").lower() for m in _TIME_MAJOR_ENCODER_MARKERS)
|
| 50 |
|
| 51 |
|
| 52 |
+
# Qwen3-ASR's encoder (tiny_audio/qwen3_asr_encoder.py) runs its convolutions on
|
| 53 |
+
# independent 1 s chunks of 100 mel frames, three (pad 1, kernel 3, stride 2)
|
| 54 |
+
# layers each, so a full chunk yields 13 frames: transformers'
|
| 55 |
+
# `_get_feat_extract_output_lengths` for that model, restated here.
|
| 56 |
+
QWEN3_ASR_CHUNK_FRAMES = 100
|
| 57 |
+
QWEN3_ASR_CONV_LAYERS: list[ConvLayerSpec] = [(1, 3, 2), (1, 3, 2), (1, 3, 2)]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def is_qwen3_asr_encoder(audio_model_id: str | None) -> bool:
|
| 61 |
+
"""Whether `audio_model_id` names a Qwen3-ASR checkpoint (its encoder is used)."""
|
| 62 |
+
return "qwen3-asr" in (audio_model_id or "").lower()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
@overload
|
| 66 |
def compute_encoder_output_length(
|
| 67 |
+
mel_length: int,
|
| 68 |
+
conv_layers: Sequence[ConvLayerSpec] | None = None,
|
| 69 |
+
chunk_frames: int | None = None,
|
| 70 |
) -> int: ...
|
| 71 |
@overload
|
| 72 |
def compute_encoder_output_length(
|
| 73 |
+
mel_length: "torch.Tensor",
|
| 74 |
+
conv_layers: Sequence[ConvLayerSpec] | None = None,
|
| 75 |
+
chunk_frames: int | None = None,
|
| 76 |
) -> "torch.Tensor": ...
|
| 77 |
def compute_encoder_output_length(
|
| 78 |
+
mel_length: "int | torch.Tensor",
|
| 79 |
+
conv_layers: Sequence[ConvLayerSpec] | None = None,
|
| 80 |
+
chunk_frames: int | None = None,
|
| 81 |
) -> "int | torch.Tensor":
|
| 82 |
"""Apply encoder conv layer formulas to compute output length.
|
| 83 |
|
| 84 |
Works with both Python ints and torch tensors of mel lengths; the formula
|
| 85 |
`(L + 2*p - (k-1) - 1) // s + 1` per layer is identical for both.
|
| 86 |
+
|
| 87 |
+
With `chunk_frames`, the encoder convolves independent chunks of that many
|
| 88 |
+
mel frames (Qwen3-ASR): every full chunk contributes the same count and the
|
| 89 |
+
remainder its own, and an empty remainder contributes zero.
|
| 90 |
"""
|
| 91 |
layers = conv_layers if conv_layers is not None else DEFAULT_ENCODER_CONV_LAYERS
|
| 92 |
+
if chunk_frames:
|
| 93 |
+
per_chunk = compute_encoder_output_length(chunk_frames, layers)
|
| 94 |
+
rest = compute_encoder_output_length(mel_length % chunk_frames, layers)
|
| 95 |
+
return (mel_length // chunk_frames) * per_chunk + rest
|
| 96 |
length = mel_length
|
| 97 |
for padding, kernel_size, stride in layers:
|
| 98 |
length = (length + 2 * padding - (kernel_size - 1) - 1) // stride + 1
|
|
|
|
| 139 |
# Encoder conv layers: list of (padding, kernel_size, stride) tuples
|
| 140 |
# Default is Whisper/GLM-ASR structure: conv1(k=3,s=1,p=1) + conv2(k=3,s=2,p=1)
|
| 141 |
encoder_conv_layers: list[ConvLayerSpec] | None = None,
|
| 142 |
+
# Mel frames per independently convolved encoder chunk (Qwen3-ASR's
|
| 143 |
+
# 100); None picks it from `audio_model_id`, 0 means no chunking.
|
| 144 |
+
encoder_chunk_frames: int | None = None,
|
| 145 |
+
# Pack labelled batches padding-free when the decoder runs FA2
|
| 146 |
+
# (asr_packing.py). Ignored under SDPA/eager, where it would be wrong.
|
| 147 |
+
pack_sequences: bool = True,
|
| 148 |
+
# Longest chunk the pipeline cuts long audio into. Must stay under the
|
| 149 |
+
# clip length the model trained on: a model trained on <=19 s skipped
|
| 150 |
+
# and paraphrased 25-30 s chunks (E22 8.00 -> 9.41, 2026-10-09). 18 s
|
| 151 |
+
# matches that 19 s training cap and is what older checkpoints, whose
|
| 152 |
+
# config.json lacks this key, keep using.
|
| 153 |
+
inference_chunk_seconds: float = 18.0,
|
| 154 |
audio_sample_rate: int = 16000,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
# Whether to forward the mel padding mask into the audio encoder.
|
| 156 |
# This matters a lot for Granite: it uses block attention over fixed
|
| 157 |
# 128-frame blocks, so with `padding="longest"` batches the pad frames
|
|
|
|
| 165 |
# incomparable to those baselines. Flip it explicitly to test.
|
| 166 |
encoder_attention_mask: bool | None = None,
|
| 167 |
# Placeholder token whose embeddings get replaced by projector output.
|
| 168 |
+
# Defaults to "<audio>", which is added to the tokenizer and requires an
|
|
|
|
| 169 |
# embedding resize.
|
| 170 |
audio_token: str | None = None,
|
| 171 |
# dtype for the projector alone. The fp32-master-weights argument only
|
| 172 |
# applies to parameters an optimizer actually updates, so pinning the
|
| 173 |
# whole stack to float32 to protect a 10M-param projector wastes 2
|
| 174 |
+
# bytes on every frozen parameter. Set this to float32 while
|
|
|
|
|
|
|
| 175 |
# model_dtype stays bfloat16 to get master-weight precision where it
|
| 176 |
# matters at frozen-model memory cost. Defaults to model_dtype, so
|
| 177 |
# existing recipes are unchanged.
|
|
|
|
| 191 |
encoder_dtype: str | None = None,
|
| 192 |
projector_pool_stride: int = 4,
|
| 193 |
projector_hidden_dim: int | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
# LoRA configuration (for Stage 2 fine-tuning)
|
| 195 |
use_lora: bool = False,
|
| 196 |
lora_rank: int = 8, # SALMONN default
|
| 197 |
lora_alpha: int = 32, # SALMONN default (scaling factor 4.0)
|
| 198 |
lora_dropout: float = 0.0,
|
| 199 |
lora_target_modules: list[str] | None = None, # Default: all linear layers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
# Seconds of silence prepended to every clip at INFERENCE. Measured on
|
| 201 |
# Peoples (n=500, paired bootstrap): 20.51% -> 19.28% WER, delta -1.22
|
| 202 |
# CI [-1.83, -0.64], with utterances losing a leading reference word
|
|
|
|
| 239 |
# those two carry 33.57M params for 0.27% of the selection's squared
|
| 240 |
# gradient -- see unfreeze_encoder_top_layers for the measurement.
|
| 241 |
encoder_trainable_post_projections: bool = True,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
max_new_tokens: int | None = None,
|
| 243 |
use_cache: bool | None = None,
|
| 244 |
no_repeat_ngram_size: int | None = None,
|
|
|
|
| 251 |
text_model_id: HuggingFace model ID for text decoder (Qwen)
|
| 252 |
attn_implementation: Attention implementation ("flash_attention_2", "sdpa", "eager")
|
| 253 |
model_dtype: Model dtype ("bfloat16", "float16", "float32")
|
|
|
|
| 254 |
use_lora: Enable LoRA adapters for Stage 2 fine-tuning
|
| 255 |
"""
|
| 256 |
# Set default generation parameters (greedy decoding only).
|
|
|
|
| 265 |
# `_truncate_repetitions` postprocess missed it because its regex is
|
| 266 |
# anchored to end-of-string and the loop was truncated mid-phrase.
|
| 267 |
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
self.audio_model_id = audio_model_id
|
| 269 |
self.text_model_id = text_model_id
|
| 270 |
self.attn_implementation = attn_implementation
|
| 271 |
self.model_dtype = model_dtype
|
| 272 |
self.encoder_dim = encoder_dim
|
| 273 |
self.llm_dim = llm_dim
|
| 274 |
+
self.encoder_conv_layers: list[ConvLayerSpec] = (
|
| 275 |
+
encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
|
| 276 |
+
)
|
| 277 |
+
self.pack_sequences: bool = pack_sequences
|
| 278 |
+
self.inference_chunk_seconds: float = inference_chunk_seconds
|
| 279 |
+
self.encoder_chunk_frames: int = (
|
| 280 |
+
(QWEN3_ASR_CHUNK_FRAMES if is_qwen3_asr_encoder(audio_model_id) else 0)
|
| 281 |
+
if encoder_chunk_frames is None
|
| 282 |
+
else encoder_chunk_frames
|
| 283 |
)
|
| 284 |
+
# Qwen3-ASR's encoder takes the mel mask to drop padding itself.
|
| 285 |
+
self.encoder_attention_mask: bool = (
|
| 286 |
+
(is_time_major_encoder(audio_model_id) or is_qwen3_asr_encoder(audio_model_id))
|
| 287 |
if encoder_attention_mask is None
|
| 288 |
else encoder_attention_mask
|
| 289 |
)
|
| 290 |
+
self.audio_token: str = audio_token or "<audio>"
|
| 291 |
+
self.projector_dtype: str = projector_dtype or model_dtype
|
| 292 |
+
self.encoder_dtype: str = encoder_dtype or model_dtype
|
| 293 |
self.audio_sample_rate = audio_sample_rate
|
| 294 |
self.projector_pool_stride = projector_pool_stride
|
| 295 |
self.projector_hidden_dim = projector_hidden_dim
|
|
|
|
| 296 |
self.transcribe_prompt = transcribe_prompt
|
|
|
|
| 297 |
# LoRA configuration
|
| 298 |
self.use_lora = use_lora
|
| 299 |
self.lora_rank = lora_rank
|
| 300 |
self.lora_alpha = lora_alpha
|
| 301 |
self.lora_dropout = lora_dropout
|
| 302 |
+
self.inference_lead_in_seconds: float = float(inference_lead_in_seconds)
|
| 303 |
+
self.lora_target_modules: list[str] = lora_target_modules or [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
"q_proj",
|
| 305 |
"k_proj",
|
| 306 |
"v_proj",
|
|
|
|
| 315 |
self.freeze_audio_encoder = freeze_audio_encoder
|
| 316 |
self.encoder_trainable_top_layers = encoder_trainable_top_layers
|
| 317 |
self.encoder_trainable_post_projections = encoder_trainable_post_projections
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
explicit_generation_args = {
|
| 320 |
"max_new_tokens": max_new_tokens,
|
| 321 |
"use_cache": use_cache,
|
| 322 |
"no_repeat_ngram_size": no_repeat_ngram_size,
|
| 323 |
}
|
| 324 |
+
for key, default in GENERATION_DEFAULTS.items():
|
| 325 |
value = explicit_generation_args[key]
|
| 326 |
setattr(self, key, value if value is not None else default)
|
| 327 |
|
|
|
|
| 358 |
|
| 359 |
# Point encoder to audio_config so pipeline uses correct feature extractor
|
| 360 |
# The pipeline looks for config.encoder._name_or_path for feature extractor
|
| 361 |
+
self.encoder: transformers.PretrainedConfig | dict[str, Any] = self.audio_config
|
| 362 |
|
| 363 |
# The Hub infers the model's task from the first model class listed, so
|
| 364 |
# AutoModelForSpeechSeq2Seq must precede AutoModel (else: feature-extraction).
|
| 365 |
+
self.auto_map: dict[str, str] = {
|
| 366 |
"AutoConfig": "asr_config.ASRConfig",
|
| 367 |
"AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
|
| 368 |
"AutoModel": "asr_modeling.ASRModel",
|
| 369 |
"AutoProcessor": "asr_processing.ASRProcessor",
|
| 370 |
}
|
| 371 |
+
self.custom_pipelines: dict[str, dict[str, Any]] = {
|
| 372 |
"automatic-speech-recognition": {
|
| 373 |
"impl": "asr_pipeline.ASRPipeline",
|
| 374 |
"pt": ["AutoModelForSpeechSeq2Seq"],
|
|
|
|
| 376 |
"type": "audio",
|
| 377 |
}
|
| 378 |
}
|
| 379 |
+
self.architectures: list[str] | None = ["ASRModel"]
|
| 380 |
self.pipeline_tag = "automatic-speech-recognition"
|
| 381 |
|
| 382 |
|
asr_layers.py
CHANGED
|
@@ -1,108 +1,10 @@
|
|
| 1 |
-
"""Module-surgery helpers for ASRModel:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
- `mps_unsafe_parameters` / `ChunkedEmbedding` / `chunk_oversized_embeddings`
|
| 7 |
-
work around PyTorch's 32-bit indexing limit in its Metal kernels.
|
| 8 |
-
- `find_encoder_layer_stack` / `unfreeze_encoder_top_layers` locate an
|
| 9 |
-
encoder's transformer-block list and switch its top blocks back to trainable.
|
| 10 |
"""
|
| 11 |
|
| 12 |
-
import
|
| 13 |
-
|
| 14 |
-
import torch
|
| 15 |
-
import torch.nn as nn
|
| 16 |
-
from torch.nn import functional
|
| 17 |
-
|
| 18 |
-
# PyTorch's Metal kernels index tensor storage with 32-bit offsets, so a gather
|
| 19 |
-
# into a tensor holding more than INT32_MAX elements wraps around and silently
|
| 20 |
-
# returns the wrong rows -- no error, no warning, just corrupt values.
|
| 21 |
-
MPS_MAX_TENSOR_ELEMENTS = 2**31 - 1
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def mps_unsafe_parameters(model: nn.Module) -> list[tuple[str, int]]:
|
| 25 |
-
"""Parameters too large for MPS to index correctly, as (name, numel) pairs.
|
| 26 |
-
|
| 27 |
-
Gemma 4 E2B trips this on `embed_tokens_per_layer`: its per-layer embedding
|
| 28 |
-
table is 262144 x (35*256) = 2,348,810,240 elements, 201M past INT32_MAX.
|
| 29 |
-
On MPS the lookup returns garbage, which Gemma then adds into all 35 layers
|
| 30 |
-
at every position. The failure is entirely silent -- the model loads, runs,
|
| 31 |
-
and emits fluent, confident, wrong transcripts. Measured on this stack:
|
| 32 |
-
teacher-forced loss 0.14 on CPU versus 3.80 on MPS from identical weights.
|
| 33 |
-
|
| 34 |
-
Returns an empty list for models that are safe, so callers can treat a
|
| 35 |
-
non-empty result as "this model needs `chunk_oversized_embeddings`".
|
| 36 |
-
"""
|
| 37 |
-
return [
|
| 38 |
-
(name, param.numel())
|
| 39 |
-
for name, param in model.named_parameters()
|
| 40 |
-
if param.numel() > MPS_MAX_TENSOR_ELEMENTS
|
| 41 |
-
]
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class ChunkedEmbedding(nn.Module):
|
| 45 |
-
"""Drop-in `nn.Embedding` that splits its table to stay within 32-bit indexing.
|
| 46 |
-
|
| 47 |
-
The overflow lives in the kernel's arithmetic over the weight's *storage*,
|
| 48 |
-
so slicing is not enough -- views share that storage and stay broken. Each
|
| 49 |
-
chunk therefore has to be its own contiguous allocation. Gathering per
|
| 50 |
-
chunk and concatenating on the feature axis reproduces the full gather
|
| 51 |
-
exactly: verified bit-identical to CPU on MPS, where the single-tensor
|
| 52 |
-
lookup was off by up to 3.16.
|
| 53 |
-
|
| 54 |
-
Wraps rather than replaces the semantics of `Gemma4TextScaledWordEmbedding`,
|
| 55 |
-
whose forward multiplies the lookup by `embed_scale` -- dropping that would
|
| 56 |
-
shrink every per-layer embedding by sqrt(hidden_size_per_layer_input).
|
| 57 |
-
"""
|
| 58 |
-
|
| 59 |
-
def __init__(self, embedding: nn.Embedding) -> None:
|
| 60 |
-
"""Split `embedding`'s table into MPS-safe column chunks, keeping its metadata."""
|
| 61 |
-
super().__init__()
|
| 62 |
-
self.num_embeddings = embedding.num_embeddings
|
| 63 |
-
self.embedding_dim = embedding.embedding_dim
|
| 64 |
-
self.padding_idx = embedding.padding_idx
|
| 65 |
-
# Gemma 4's scaled embedding carries this; a plain nn.Embedding does not.
|
| 66 |
-
self.embed_scale = getattr(embedding, "embed_scale", None)
|
| 67 |
-
|
| 68 |
-
num_chunks = math.ceil(embedding.weight.numel() / MPS_MAX_TENSOR_ELEMENTS)
|
| 69 |
-
device = embedding.weight.device
|
| 70 |
-
# Chunk from a CPU copy: carving contiguous pieces out of an
|
| 71 |
-
# already-oversized MPS tensor would run the same overflowing kernel
|
| 72 |
-
# this class exists to avoid.
|
| 73 |
-
source = embedding.weight.detach().cpu()
|
| 74 |
-
self.chunks = nn.ParameterList(
|
| 75 |
-
[
|
| 76 |
-
nn.Parameter(
|
| 77 |
-
chunk.contiguous().to(device), requires_grad=embedding.weight.requires_grad
|
| 78 |
-
)
|
| 79 |
-
for chunk in torch.chunk(source, num_chunks, dim=1)
|
| 80 |
-
]
|
| 81 |
-
)
|
| 82 |
-
del source
|
| 83 |
-
|
| 84 |
-
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
|
| 85 |
-
"""Look up `input_ids` in every chunk and concatenate along the feature dim."""
|
| 86 |
-
out = torch.cat([functional.embedding(input_ids, chunk) for chunk in self.chunks], dim=-1)
|
| 87 |
-
if self.embed_scale is not None:
|
| 88 |
-
out = out * self.embed_scale.to(out.dtype)
|
| 89 |
-
return out
|
| 90 |
-
|
| 91 |
-
@property
|
| 92 |
-
def weight(self) -> torch.Tensor:
|
| 93 |
-
"""Reassembled table, for callers that introspect `.weight`.
|
| 94 |
-
|
| 95 |
-
Materializes the full tensor, so this is a debugging/serialization
|
| 96 |
-
convenience rather than something to call on a hot path.
|
| 97 |
-
"""
|
| 98 |
-
return torch.cat(list(self.chunks), dim=1)
|
| 99 |
-
|
| 100 |
-
def extra_repr(self) -> str:
|
| 101 |
-
"""Describe the table like `nn.Embedding` does, plus the chunk count."""
|
| 102 |
-
return (
|
| 103 |
-
f"{self.num_embeddings}, {self.embedding_dim}, "
|
| 104 |
-
f"chunks={len(self.chunks)} (MPS int32-indexing workaround)"
|
| 105 |
-
)
|
| 106 |
|
| 107 |
|
| 108 |
def find_encoder_layer_stack(encoder: nn.Module) -> tuple[str, nn.ModuleList] | None:
|
|
@@ -182,19 +84,3 @@ def unfreeze_encoder_top_layers(
|
|
| 182 |
param.requires_grad_(True)
|
| 183 |
unfrozen.append(f"{name}.{pname}")
|
| 184 |
return sorted(unfrozen)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def chunk_oversized_embeddings(root: nn.Module) -> list[str]:
|
| 188 |
-
"""Replace every embedding MPS cannot index with a `ChunkedEmbedding`.
|
| 189 |
-
|
| 190 |
-
Returns the qualified names that were replaced, empty when nothing needed
|
| 191 |
-
it. Idempotent: `ChunkedEmbedding` is not an `nn.Embedding`, so a second
|
| 192 |
-
pass finds nothing.
|
| 193 |
-
"""
|
| 194 |
-
replaced: list[str] = []
|
| 195 |
-
for module_name, module in list(root.named_modules()):
|
| 196 |
-
for child_name, child in list(module.named_children()):
|
| 197 |
-
if isinstance(child, nn.Embedding) and child.weight.numel() > MPS_MAX_TENSOR_ELEMENTS:
|
| 198 |
-
setattr(module, child_name, ChunkedEmbedding(child))
|
| 199 |
-
replaced.append(f"{module_name}.{child_name}" if module_name else child_name)
|
| 200 |
-
return replaced
|
|
|
|
| 1 |
+
"""Module-surgery helpers for ASRModel: encoder unfreezing.
|
| 2 |
|
| 3 |
+
`find_encoder_layer_stack` / `unfreeze_encoder_top_layers` locate an encoder's
|
| 4 |
+
transformer-block list and switch its top blocks back to trainable.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
from torch import nn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def find_encoder_layer_stack(encoder: nn.Module) -> tuple[str, nn.ModuleList] | None:
|
|
|
|
| 84 |
param.requires_grad_(True)
|
| 85 |
unfrozen.append(f"{name}.{pname}")
|
| 86 |
return sorted(unfrozen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
asr_modeling.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import contextlib
|
| 2 |
-
import functools
|
| 3 |
import importlib.util
|
| 4 |
import inspect
|
| 5 |
import json
|
|
@@ -13,9 +12,9 @@ from threading import Thread
|
|
| 13 |
from typing import TYPE_CHECKING, Any, Self, cast, overload
|
| 14 |
|
| 15 |
import torch
|
| 16 |
-
import
|
| 17 |
-
from peft import LoraConfig, PeftModel, get_peft_model
|
| 18 |
from safetensors.torch import load_file
|
|
|
|
| 19 |
from torch.nn import functional
|
| 20 |
from transformers import (
|
| 21 |
AutoFeatureExtractor,
|
|
@@ -23,6 +22,10 @@ from transformers import (
|
|
| 23 |
AutoModelForCausalLM,
|
| 24 |
AutoModelForSeq2SeqLM,
|
| 25 |
AutoTokenizer,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
PreTrainedModel,
|
| 27 |
PreTrainedTokenizerBase,
|
| 28 |
SequenceFeatureExtractor,
|
|
@@ -38,15 +41,20 @@ from transformers.utils.hub import (
|
|
| 38 |
|
| 39 |
if TYPE_CHECKING:
|
| 40 |
from transformers import GraniteSpeech5Encoder as _GraniteSpeech5Encoder
|
| 41 |
-
from transformers.models.gemma4.configuration_gemma4 import Gemma4TextConfig
|
| 42 |
-
from transformers.models.gemma4.modeling_gemma4 import (
|
| 43 |
-
Gemma4ForCausalLM as _Gemma4ForCausalLM,
|
| 44 |
-
)
|
| 45 |
-
from transformers.models.gemma4.modeling_gemma4 import Gemma4TextModel
|
| 46 |
|
| 47 |
-
from .asr_attention import
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
from .asr_processing import (
|
| 51 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 52 |
ASRProcessor,
|
|
@@ -62,16 +70,26 @@ if TYPE_CHECKING:
|
|
| 62 |
OutputLengthProjector,
|
| 63 |
StateDictT,
|
| 64 |
)
|
| 65 |
-
from .projectors import
|
|
|
|
| 66 |
|
| 67 |
# None at runtime on a transformers without the architecture; see below.
|
| 68 |
GraniteSpeech5Encoder: type[_GraniteSpeech5Encoder] | None
|
| 69 |
-
Gemma4ForCausalLM: type[_Gemma4ForCausalLM] | None
|
| 70 |
else:
|
| 71 |
try:
|
| 72 |
-
from .asr_attention import
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
from .asr_processing import (
|
| 76 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 77 |
ASRProcessor,
|
|
@@ -79,11 +97,22 @@ else:
|
|
| 79 |
render_audio_prompt,
|
| 80 |
)
|
| 81 |
from .asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
|
| 82 |
-
from .projectors import
|
|
|
|
| 83 |
except ImportError: # flat layout on the Hub: sibling modules, no package
|
| 84 |
-
from asr_attention import
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
from asr_processing import (
|
| 88 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 89 |
ASRProcessor,
|
|
@@ -91,22 +120,19 @@ else:
|
|
| 91 |
render_audio_prompt,
|
| 92 |
)
|
| 93 |
from asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
|
| 94 |
-
from projectors import
|
|
|
|
| 95 |
|
| 96 |
-
#
|
| 97 |
-
#
|
| 98 |
-
#
|
| 99 |
try:
|
| 100 |
from transformers import GraniteSpeech5Encoder
|
| 101 |
except ImportError: # transformers < 5.16: no native granite_speech5
|
| 102 |
GraniteSpeech5Encoder = None
|
| 103 |
-
try:
|
| 104 |
-
from transformers.models.gemma4.modeling_gemma4 import Gemma4ForCausalLM
|
| 105 |
-
except ImportError: # transformers without the gemma4 architecture
|
| 106 |
-
Gemma4ForCausalLM = None
|
| 107 |
|
| 108 |
|
| 109 |
-
logger = logging.getLogger(__name__)
|
| 110 |
|
| 111 |
# Vocab dimension the embedding table is padded to after adding the audio
|
| 112 |
# token. 128 is what transformers' own `pad_to_multiple_of` docs recommend for
|
|
@@ -220,44 +246,34 @@ def _splice_audio(
|
|
| 220 |
)
|
| 221 |
|
| 222 |
|
| 223 |
-
|
| 224 |
-
"""
|
| 225 |
-
|
| 226 |
-
`Gemma4ForConditionalGeneration` does this itself; the causal LM does not.
|
| 227 |
-
Without it, step 2 of generation hands PLE to forward alongside
|
| 228 |
-
`input_ids` and raises "You cannot specify per_layer_inputs if input_ids is
|
| 229 |
-
provided". Dropping is semantically correct rather than a workaround: PLE
|
| 230 |
-
describes the prompt, and from step 2 on generation feeds a single freshly
|
| 231 |
-
sampled token whose PLE Gemma derives from that token's own id.
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
"""
|
| 237 |
-
inner_prepare = model.prepare_inputs_for_generation
|
| 238 |
-
|
| 239 |
-
# functools.wraps is load-bearing, not cosmetic. Generation's
|
| 240 |
-
# _prepare_model_inputs decides whether a model can accept `inputs_embeds`
|
| 241 |
-
# by testing for it in
|
| 242 |
-
# inspect.signature(prepare_inputs_for_generation).parameters. A bare
|
| 243 |
-
# *args/**kwargs wrapper hides the parameter, and generate() then refuses
|
| 244 |
-
# inputs_embeds with "doesn't have its forwarding implemented" -- fatal
|
| 245 |
-
# here, since audio only ever reaches the decoder as inputs_embeds.
|
| 246 |
-
# `wraps` sets __wrapped__, which inspect.signature follows.
|
| 247 |
-
@functools.wraps(inner_prepare)
|
| 248 |
-
def prepare_inputs_for_generation(
|
| 249 |
-
*args: Any, is_first_iteration: bool = False, **kwargs: Any
|
| 250 |
-
) -> dict[str, Any]:
|
| 251 |
-
"""Drop stale `per_layer_inputs` on every decode step after the first."""
|
| 252 |
-
model_inputs = inner_prepare(*args, is_first_iteration=is_first_iteration, **kwargs)
|
| 253 |
-
if not is_first_iteration:
|
| 254 |
-
model_inputs.pop("per_layer_inputs", None)
|
| 255 |
-
return model_inputs
|
| 256 |
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
|
| 260 |
-
def _assert_projector_loaded(incompatible_keys: LoadStateDictResult
|
| 261 |
"""Fail loudly when a checkpoint's projector doesn't match the built one.
|
| 262 |
|
| 263 |
`save_pretrained` serializes `projector.*` (plus the language model when
|
|
@@ -304,8 +320,7 @@ def _assert_projector_loaded(incompatible_keys: LoadStateDictResult, projector_t
|
|
| 304 |
)
|
| 305 |
|
| 306 |
raise RuntimeError(
|
| 307 |
-
|
| 308 |
-
f"(projector_type={projector_type!r}): " + "; ".join(detail) + hint
|
| 309 |
)
|
| 310 |
|
| 311 |
|
|
@@ -337,7 +352,7 @@ def _log_linear_attention_backends(level: int = logging.WARNING) -> None:
|
|
| 337 |
class ASRModel(PreTrainedModel, GenerationMixin):
|
| 338 |
"""Audio-to-text model combining an audio encoder, projector, and language model."""
|
| 339 |
|
| 340 |
-
config_class = ASRConfig
|
| 341 |
base_model_prefix = "model"
|
| 342 |
main_input_name = "input_features"
|
| 343 |
_supports_flash_attn_2 = True
|
|
@@ -387,7 +402,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 387 |
if model_file is not None:
|
| 388 |
state_dict = load_file(model_file)
|
| 389 |
incompatible = model.load_state_dict(state_dict, strict=False)
|
| 390 |
-
_assert_projector_loaded(incompatible
|
| 391 |
|
| 392 |
# Load LoRA adapters if use_lora is enabled
|
| 393 |
if getattr(config, "use_lora", False):
|
|
@@ -442,7 +457,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 442 |
target_dtype = getattr(torch, config.model_dtype)
|
| 443 |
|
| 444 |
# Audio encoder (frozen)
|
| 445 |
-
self.audio_tower = self._load_audio_encoder(config, target_dtype)
|
| 446 |
|
| 447 |
# Language model (frozen). Typed as the decoder even with LoRA attached:
|
| 448 |
# a PeftModel forwards every attribute it does not define to the
|
|
@@ -472,7 +487,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 472 |
# text_model_id, liger missing (it is linux-only), or a patcher
|
| 473 |
# that patched a different class than the one that got loaded.
|
| 474 |
logger.log(
|
| 475 |
-
logging.INFO if
|
| 476 |
"%s's forward does not accept `skip_logits` — liger's fused "
|
| 477 |
"linear cross-entropy is NOT active, so every training step "
|
| 478 |
"materializes a (batch, seq, %s) logits tensor. Check for an "
|
|
@@ -485,26 +500,25 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 485 |
self._init_tokenizer(config)
|
| 486 |
|
| 487 |
# Set up generation config with greedy decoding defaults
|
| 488 |
-
self.generation_config = self.language_model.generation_config
|
| 489 |
-
self.generation_config.max_new_tokens = config.max_new_tokens
|
| 490 |
-
self.generation_config.use_cache = config.use_cache
|
| 491 |
# `generate()` reads the GenerationConfig and nothing else, so every
|
| 492 |
-
# decoding knob ASRConfig owns has to be copied across here.
|
| 493 |
-
#
|
| 494 |
# set on the config, serialized into config.json, and never consulted.
|
| 495 |
-
|
|
|
|
| 496 |
self.generation_config.eos_token_id = self._resolve_eos_token_ids()
|
| 497 |
self.generation_config.pad_token_id = self.tokenizer.pad_token_id
|
| 498 |
|
| 499 |
# Feature extractor for audio preprocessing
|
| 500 |
-
self.feature_extractor = self._create_feature_extractor(config)
|
| 501 |
|
| 502 |
# Audio projector (trainable unless freeze_projector is set)
|
| 503 |
-
self.projector = self._create_projector(config, target_dtype)
|
| 504 |
|
| 505 |
# Setup LoRA if enabled (Stage 2 fine-tuning)
|
| 506 |
# Skip if loading from pretrained - from_pretrained will handle adapter loading
|
| 507 |
-
if getattr(config, "use_lora", False) and not
|
| 508 |
self._setup_lora(config)
|
| 509 |
|
| 510 |
# Freeze projector if specified (for Stage 2 LoRA-only training)
|
|
@@ -530,12 +544,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 530 |
feature_extractor: SequenceFeatureExtractor = AutoFeatureExtractor.from_pretrained(
|
| 531 |
config.audio_model_id
|
| 532 |
)
|
| 533 |
-
# Whisper's encoder requires a fixed 3000 mel frames (30s) and the
|
| 534 |
-
# feature extractor pads to that by default — leave it alone. Other
|
| 535 |
-
# encoders (e.g. GLM-ASR) accept variable-length input, so we disable
|
| 536 |
-
# padding to avoid wasting compute on silent frames.
|
| 537 |
-
if "whisper" not in config.audio_model_id.lower():
|
| 538 |
-
feature_extractor.padding = False # pyright: ignore[reportAttributeAccessIssue]
|
| 539 |
return feature_extractor
|
| 540 |
|
| 541 |
@classmethod
|
|
@@ -557,7 +565,12 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 557 |
}
|
| 558 |
|
| 559 |
encoder: nn.Module
|
| 560 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
full_model = WhisperModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
|
| 562 |
encoder = full_model.encoder
|
| 563 |
del full_model
|
|
@@ -633,8 +646,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 633 |
# trainable blocks; it must be fp32 for Adam's step to survive
|
| 634 |
# rounding. See ASRConfig.encoder_dtype for the arithmetic.
|
| 635 |
encoder_dtype = _resolve_dtype(getattr(config, "encoder_dtype", None), dtype)
|
| 636 |
-
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a
|
| 637 |
-
|
|
|
|
| 638 |
if getattr(config, "freeze_audio_encoder", True):
|
| 639 |
encoder.requires_grad_(False)
|
| 640 |
encoder.train(False) # equivalent to .eval(); avoids a security hook false-positive
|
|
@@ -714,95 +728,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 714 |
AutoModelForCausalLM.from_pretrained(config.text_model_id, **decoder_kwargs),
|
| 715 |
)
|
| 716 |
|
| 717 |
-
# Gemma 4 checkpoints are natively multimodal: AutoModelForCausalLM maps
|
| 718 |
-
# `gemma4` to Gemma4ForConditionalGeneration, which is both wasteful
|
| 719 |
-
# (a vision tower + audio tower this model never calls) and unusable
|
| 720 |
-
# for inference. Convert it to the text-only causal LM.
|
| 721 |
-
#
|
| 722 |
-
# Gated on the model id, not on the `.model.language_model` layout:
|
| 723 |
-
# that layout is the generic transformers composite-model shape, shared
|
| 724 |
-
# by GLM-ASR and Llava/Qwen-Omni style checkpoints. Probing for it
|
| 725 |
-
# structurally would funnel those into _gemma_text_only, which imports
|
| 726 |
-
# transformers.models.gemma4 and transplants weights into a
|
| 727 |
-
# Gemma4ForCausalLM shell built from a foreign text_config. The
|
| 728 |
-
# hasattr check is kept as a guard so an already-text-only gemma-4
|
| 729 |
-
# checkpoint passes through untouched.
|
| 730 |
-
gemma_inner = getattr(decoder, "model", None)
|
| 731 |
-
is_gemma4 = "gemma-4" in (config.text_model_id or "").lower()
|
| 732 |
-
if is_gemma4 and gemma_inner is not None and hasattr(gemma_inner, "language_model"):
|
| 733 |
-
decoder = cls._gemma_text_only(decoder)
|
| 734 |
# See _load_audio_encoder note: idempotent post-load cast to dodge the
|
| 735 |
# FA2 "current dype is fp32" warning when from_pretrained's dtype kwarg
|
| 736 |
# isn't fully propagated to every submodule.
|
| 737 |
# `to` returns the module itself, so the cast happens in place.
|
| 738 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 739 |
-
decoder.to(dtype=dtype)
|
| 740 |
decoder.config.use_cache = getattr(config, "use_cache", True)
|
| 741 |
if getattr(config, "freeze_language_model", True):
|
| 742 |
decoder.requires_grad_(False)
|
| 743 |
decoder.train(False)
|
| 744 |
return decoder
|
| 745 |
|
| 746 |
-
@staticmethod
|
| 747 |
-
def _gemma_text_only(loaded: "GenerativeDecoder") -> "GenerativeDecoder":
|
| 748 |
-
"""Turn a loaded Gemma4ForConditionalGeneration into a text-only CausalLM.
|
| 749 |
-
|
| 750 |
-
Two independent problems force this transplant rather than just loading
|
| 751 |
-
the right class directly:
|
| 752 |
-
|
| 753 |
-
1. Only `Gemma4ForCausalLM` supports `inputs_embeds` in `.generate()`.
|
| 754 |
-
Its `prepare_inputs_for_generation` accepts the argument; the
|
| 755 |
-
multimodal wrapper's does not, so generation dies with "You passed
|
| 756 |
-
`inputs_embeds` to `.generate()`, but the model class
|
| 757 |
-
Gemma4ForConditionalGeneration doesn't have its forwarding
|
| 758 |
-
implemented." Audio reaches this decoder exclusively as
|
| 759 |
-
inputs_embeds, so the wrapper cannot do inference at all.
|
| 760 |
-
|
| 761 |
-
2. `Gemma4ForCausalLM.from_pretrained` cannot read the published
|
| 762 |
-
checkpoint. Its keys are `model.language_model.*` (the multimodal
|
| 763 |
-
layout) while the causal LM expects `model.*`, and gemma4 ships no
|
| 764 |
-
checkpoint conversion mapping. Critically it fails *silently*:
|
| 765 |
-
every tensor is reported MISSING and randomly initialized
|
| 766 |
-
(embedding std lands at initializer_range), producing a model that
|
| 767 |
-
trains without error and learns nothing.
|
| 768 |
-
|
| 769 |
-
So the wrapper is loaded for its correct key layout, then its text
|
| 770 |
-
model and lm_head are moved into a shell built on the meta device --
|
| 771 |
-
which allocates nothing, because every real parameter is transplanted.
|
| 772 |
-
Dropping the wrapper also frees the vision and audio towers: the
|
| 773 |
-
returned text-only model holds 4,628,569,344 parameters against the
|
| 774 |
-
checkpoint's 5,123,178,979, so ~494M are released.
|
| 775 |
-
"""
|
| 776 |
-
if Gemma4ForCausalLM is None:
|
| 777 |
-
msg = f"Gemma 4 decoders need the gemma4 architecture; found {transformers_version}."
|
| 778 |
-
raise ImportError(msg)
|
| 779 |
-
|
| 780 |
-
# Only reached for gemma-4 checkpoints, whose text config is Gemma4TextConfig.
|
| 781 |
-
text_config = cast("Gemma4TextConfig", loaded.config.get_text_config())
|
| 782 |
-
with torch.device("meta"):
|
| 783 |
-
shell = Gemma4ForCausalLM(text_config)
|
| 784 |
-
|
| 785 |
-
shell.model = cast("Gemma4TextModel", loaded.get_submodule("model.language_model"))
|
| 786 |
-
shell.lm_head = cast(nn.Linear, loaded.get_submodule("lm_head"))
|
| 787 |
-
shell.generation_config = loaded.generation_config
|
| 788 |
-
shell.tie_weights()
|
| 789 |
-
|
| 790 |
-
# See _patch_gemma_decode_loop for why PLE is dropped after step 1.
|
| 791 |
-
# Gemma4ForCausalLM is a PreTrainedModel with GenerationMixin, which is
|
| 792 |
-
# all `GenerativeDecoder` describes.
|
| 793 |
-
decoder = cast("GenerativeDecoder", shell)
|
| 794 |
-
_patch_gemma_decode_loop(decoder)
|
| 795 |
-
|
| 796 |
-
stranded = [n for n, t in shell.named_parameters() if t.device.type == "meta"]
|
| 797 |
-
stranded += [n for n, t in shell.named_buffers() if t.device.type == "meta"]
|
| 798 |
-
if stranded:
|
| 799 |
-
msg = (
|
| 800 |
-
"Gemma text-only conversion left parameters on the meta device "
|
| 801 |
-
f"(would silently produce garbage): {stranded[:5]}"
|
| 802 |
-
)
|
| 803 |
-
raise RuntimeError(msg)
|
| 804 |
-
return decoder
|
| 805 |
-
|
| 806 |
def _create_projector(self, config: ASRConfig, dtype: torch.dtype) -> "MLPAudioProjector":
|
| 807 |
"""Create the trainable audio projector."""
|
| 808 |
# Auto-detect dimensions if not specified
|
|
@@ -817,8 +754,8 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 817 |
|
| 818 |
if config.llm_dim is None:
|
| 819 |
dec_cfg = self.language_model.config
|
| 820 |
-
# Composite configs
|
| 821 |
-
# get_text_config() returns self for plain decoders
|
| 822 |
if hasattr(dec_cfg, "get_text_config"):
|
| 823 |
dec_cfg = dec_cfg.get_text_config()
|
| 824 |
config.llm_dim = getattr(dec_cfg, "hidden_size", None) or getattr(
|
|
@@ -828,16 +765,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 828 |
msg = "Could not auto-detect llm_dim. Please specify in config."
|
| 829 |
raise ValueError(msg)
|
| 830 |
|
| 831 |
-
|
| 832 |
-
projector_type = getattr(config, "projector_type", "mlp")
|
| 833 |
-
projector_class = PROJECTOR_CLASSES.get(projector_type)
|
| 834 |
-
if projector_class is None:
|
| 835 |
-
msg = (
|
| 836 |
-
f"Unknown projector_type: {projector_type}. "
|
| 837 |
-
f"Valid options: {list(PROJECTOR_CLASSES.keys())}"
|
| 838 |
-
)
|
| 839 |
-
raise ValueError(msg)
|
| 840 |
-
projector = projector_class(config)
|
| 841 |
|
| 842 |
# Move projector to same device as language model (important when using quantization)
|
| 843 |
device = next(self.language_model.parameters()).device
|
|
@@ -855,13 +783,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 855 |
lora_alpha=config.lora_alpha,
|
| 856 |
target_modules=config.lora_target_modules,
|
| 857 |
lora_dropout=config.lora_dropout,
|
| 858 |
-
# Per-module overrides for targets whose shape makes the global
|
| 859 |
-
# rank wrong -- see ASRConfig.lora_rank_pattern. PEFT resolves the
|
| 860 |
-
# two patterns independently, so a recipe that sets one and not the
|
| 861 |
-
# other silently changes that module's alpha/r scale; the config
|
| 862 |
-
# field's docstring spells out the arithmetic.
|
| 863 |
-
rank_pattern=dict(getattr(config, "lora_rank_pattern", None) or {}),
|
| 864 |
-
alpha_pattern=dict(getattr(config, "lora_alpha_pattern", None) or {}),
|
| 865 |
bias="none",
|
| 866 |
task_type="CAUSAL_LM",
|
| 867 |
)
|
|
@@ -891,7 +812,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 891 |
self.tokenizer.pad_token = self.tokenizer.eos_token
|
| 892 |
|
| 893 |
# Audio placeholder token. Prefer a token the tokenizer already knows
|
| 894 |
-
#
|
| 895 |
# `unk_token_id` is the sentinel convert_tokens_to_ids returns for an
|
| 896 |
# unknown token, so it means "not in vocab" rather than a usable id.
|
| 897 |
self.audio_token = getattr(config, "audio_token", None) or "<audio>"
|
|
@@ -1033,7 +954,8 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1033 |
tokenize=False,
|
| 1034 |
add_generation_prompt=False,
|
| 1035 |
)
|
| 1036 |
-
except Exception:
|
|
|
|
| 1037 |
return None
|
| 1038 |
if not isinstance(rendered, str) or sentinel not in rendered:
|
| 1039 |
return None
|
|
@@ -1048,33 +970,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1048 |
first = tail_ids[0]
|
| 1049 |
return first if first in set(self.tokenizer.all_special_ids) else None
|
| 1050 |
|
| 1051 |
-
def _apply(self, *args: Any, **kwargs: Any) -> Self:
|
| 1052 |
-
"""Repair MPS-unsafe embeddings whenever the model lands on MPS.
|
| 1053 |
-
|
| 1054 |
-
Device placement happens after construction -- `pipeline` builds the
|
| 1055 |
-
model, then moves it -- so the check belongs on the move, not in
|
| 1056 |
-
__init__. `_apply` is the single funnel every relocation goes through
|
| 1057 |
-
(`.to()`, `.cuda()`, `.mps()`, dtype casts), which keeps this off the
|
| 1058 |
-
many individual entry points.
|
| 1059 |
-
|
| 1060 |
-
Without it, Gemma 4 E2B on MPS reads the wrong rows out of its 2.35B-
|
| 1061 |
-
element per-layer embedding table and transcribes confident nonsense at
|
| 1062 |
-
full speed. Chunking keeps the model on the GPU -- falling back to CPU
|
| 1063 |
-
would be correct but costs roughly an order of magnitude in latency.
|
| 1064 |
-
"""
|
| 1065 |
-
module: Self = super()._apply(*args, **kwargs)
|
| 1066 |
-
try:
|
| 1067 |
-
on_mps = any(p.device.type == "mps" for p in module.parameters())
|
| 1068 |
-
except StopIteration: # pragma: no cover - parameterless model
|
| 1069 |
-
return module
|
| 1070 |
-
if on_mps and (replaced := chunk_oversized_embeddings(module)):
|
| 1071 |
-
logger.warning(
|
| 1072 |
-
"MPS cannot index %s with 32-bit offsets; split into chunks to "
|
| 1073 |
-
"keep the lookup correct on GPU.",
|
| 1074 |
-
", ".join(replaced),
|
| 1075 |
-
)
|
| 1076 |
-
return module
|
| 1077 |
-
|
| 1078 |
def train(self, mode: bool = True) -> Self:
|
| 1079 |
"""Set train/eval mode, but keep frozen submodules out of train mode.
|
| 1080 |
|
|
@@ -1097,7 +992,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1097 |
# statistics anyway. Granite Speech 5.0 carries 16 BatchNorm1d and
|
| 1098 |
# zero Dropout, and for non-Whisper extractors the collator pads to
|
| 1099 |
# the batch longest (`DataCollator._audio_padding`) with
|
| 1100 |
-
# group_by_length off, so clips spanning 0.
|
| 1101 |
# pad fraction. Granite's conv module zeroes pad positions and THEN
|
| 1102 |
# runs BatchNorm1d over the full padded (B, C, T); BN reduces over
|
| 1103 |
# B*T, so those zeros are counted as data -- deflating the mean and
|
|
@@ -1223,8 +1118,30 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1223 |
projector=self.projector,
|
| 1224 |
encoder_conv_layers=self.config.encoder_conv_layers,
|
| 1225 |
audio_token=self.audio_token,
|
|
|
|
| 1226 |
)
|
| 1227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1228 |
@overload
|
| 1229 |
def state_dict(
|
| 1230 |
self, *, destination: StateDictT, prefix: str = ..., keep_vars: bool = ...
|
|
@@ -1301,50 +1218,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1301 |
destination.update({f"{prefix}{k}": v for k, v in sd.items()})
|
| 1302 |
return destination
|
| 1303 |
|
| 1304 |
-
def _ple_text_model(self) -> "Gemma4TextModel | None":
|
| 1305 |
-
"""Locate the submodule owning `get_per_layer_inputs`, or None.
|
| 1306 |
-
|
| 1307 |
-
Only Gemma 4 style decoders have one. The attribute sits on
|
| 1308 |
-
Gemma4TextModel, which is `language_model.model.language_model` for the
|
| 1309 |
-
multimodal checkpoint and `language_model.model` for a text-only one.
|
| 1310 |
-
"""
|
| 1311 |
-
candidates = (
|
| 1312 |
-
getattr(getattr(self.language_model, "model", None), "language_model", None),
|
| 1313 |
-
getattr(self.language_model, "model", None),
|
| 1314 |
-
self.language_model,
|
| 1315 |
-
)
|
| 1316 |
-
for candidate in candidates:
|
| 1317 |
-
if candidate is not None and hasattr(candidate, "get_per_layer_inputs"):
|
| 1318 |
-
# Checked just above; only Gemma 4 text models define it.
|
| 1319 |
-
return cast("Gemma4TextModel", candidate)
|
| 1320 |
-
return None
|
| 1321 |
-
|
| 1322 |
-
def _per_layer_kwargs(self, input_ids: torch.Tensor | None) -> dict[str, torch.Tensor]:
|
| 1323 |
-
"""Precompute Gemma 4 per-layer embeddings (PLE) from clean `input_ids`.
|
| 1324 |
-
|
| 1325 |
-
Gemma 4 builds a token-identity PLE component by looking `input_ids` up
|
| 1326 |
-
in `embed_tokens_per_layer`. It must be computed *before* masked_scatter
|
| 1327 |
-
overwrites the audio positions, because Gemma's `inputs_embeds`-only
|
| 1328 |
-
path tries to recover ids by reversing the main embedding -- an exact
|
| 1329 |
-
row match against `embed_tokens.weight * sqrt(hidden_size)`. Projector
|
| 1330 |
-
output matches no row, so that path raises RuntimeError (and would be
|
| 1331 |
-
an O(B*T*vocab*hidden) comparison even if it matched).
|
| 1332 |
-
|
| 1333 |
-
Computing it here keeps every text token's PLE row intact and leaves
|
| 1334 |
-
audio positions on the audio placeholder's own row, which is exactly
|
| 1335 |
-
what Gemma does natively for image/audio placeholder tokens.
|
| 1336 |
-
|
| 1337 |
-
Returns {} for decoders without PLE, so callers can splat it blindly.
|
| 1338 |
-
"""
|
| 1339 |
-
if input_ids is None:
|
| 1340 |
-
return {}
|
| 1341 |
-
text_model = self._ple_text_model()
|
| 1342 |
-
if text_model is None:
|
| 1343 |
-
return {}
|
| 1344 |
-
if not getattr(text_model.config, "hidden_size_per_layer_input", None):
|
| 1345 |
-
return {}
|
| 1346 |
-
return {"per_layer_inputs": text_model.get_per_layer_inputs(input_ids, None)}
|
| 1347 |
-
|
| 1348 |
def _embed_tokens(self, input_ids: torch.Tensor | None) -> torch.Tensor:
|
| 1349 |
"""Look `input_ids` up in the decoder's token embedding table."""
|
| 1350 |
embeds: torch.Tensor = self.language_model.get_input_embeddings()(input_ids)
|
|
@@ -1358,6 +1231,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1358 |
return compute_encoder_output_length(
|
| 1359 |
audio_attention_mask.sum(dim=-1),
|
| 1360 |
self.config.encoder_conv_layers,
|
|
|
|
| 1361 |
)
|
| 1362 |
|
| 1363 |
def _encode_audio(
|
|
@@ -1377,17 +1251,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1377 |
Returns:
|
| 1378 |
Flattened audio embeddings of shape (sum(expected_token_counts), hidden_dim).
|
| 1379 |
"""
|
| 1380 |
-
# SpecAugment is applied on the mel input, training-only. Most useful
|
| 1381 |
-
# when the encoder is trainable; on the frozen-encoder path it still
|
| 1382 |
-
# perturbs the projector's input slightly but with no gradient flowing
|
| 1383 |
-
# back to the encoder to leverage the diversity.
|
| 1384 |
-
if (
|
| 1385 |
-
self.training
|
| 1386 |
-
and getattr(self.config, "apply_spec_augment", False)
|
| 1387 |
-
and audio_features.numel() > 0
|
| 1388 |
-
):
|
| 1389 |
-
audio_features = self._mask_input_features(audio_features, audio_attention_mask)
|
| 1390 |
-
|
| 1391 |
# When the encoder is frozen, skip gradient tracking through it — cuts
|
| 1392 |
# activation memory and matches the prior published recipe's behavior.
|
| 1393 |
# When trainable, we MUST allow gradients to flow back to encoder
|
|
@@ -1434,161 +1297,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1434 |
)
|
| 1435 |
return _gather_audio_embeds(audio_embeds, token_counts, max_tokens)
|
| 1436 |
|
| 1437 |
-
def _mask_input_features(
|
| 1438 |
-
self,
|
| 1439 |
-
input_features: torch.Tensor,
|
| 1440 |
-
attention_mask: torch.Tensor | None = None,
|
| 1441 |
-
) -> torch.Tensor:
|
| 1442 |
-
"""SpecAugment on mel input (pure-torch, vectorized, compile-ready).
|
| 1443 |
-
|
| 1444 |
-
Follows the same semantics as
|
| 1445 |
-
`transformers.models.whisper.modeling_whisper.WhisperModel._mask_input_features`
|
| 1446 |
-
(wav2vec2-style mask sampling: sample N start positions per sample,
|
| 1447 |
-
mask `mask_length` frames forward from each), but reimplemented in
|
| 1448 |
-
pure torch so it stays inside the autograd graph without crossing
|
| 1449 |
-
the numpy boundary. This avoids inductor codegen failures
|
| 1450 |
-
(e.g. the `'zuf0' was not declared` error from the prior numpy ->
|
| 1451 |
-
torch.tensor round-trip) AND avoids the per-forward host-to-GPU
|
| 1452 |
-
sync that the numpy path required.
|
| 1453 |
-
|
| 1454 |
-
One minor semantic divergence vs the upstream helper: this version
|
| 1455 |
-
allows mask spans to overlap, while upstream rejects overlapping
|
| 1456 |
-
samples. For ASR purposes this is irrelevant — occasional region
|
| 1457 |
-
double-coverage has no measurable effect on the regularization
|
| 1458 |
-
signal.
|
| 1459 |
-
|
| 1460 |
-
Reads ASRConfig fields by Whisper naming convention: mask_time_prob,
|
| 1461 |
-
mask_time_length, mask_time_min_masks.
|
| 1462 |
-
|
| 1463 |
-
Args:
|
| 1464 |
-
input_features: (batch, n_mels, mel_len) log-mel features.
|
| 1465 |
-
attention_mask: (batch, mel_len) mel-frame padding mask. Like
|
| 1466 |
-
upstream, it confines time-axis span count and start
|
| 1467 |
-
positions to each sample's unpadded length. Without it,
|
| 1468 |
-
spans are drawn uniformly over the padded axis, so under
|
| 1469 |
-
`padding="longest"` a 1s clip batched with a 19s clip gets
|
| 1470 |
-
roughly 1/19 of its spans in real audio — short utterances
|
| 1471 |
-
go effectively unaugmented while long ones take the full
|
| 1472 |
-
dose. Whisper is unaffected either way (fixed 3000 frames);
|
| 1473 |
-
the variable-length encoders are the ones that need it.
|
| 1474 |
-
Pass None to treat the whole axis as valid.
|
| 1475 |
-
|
| 1476 |
-
Returns:
|
| 1477 |
-
Same-shape tensor with time-axis masks zeroed.
|
| 1478 |
-
"""
|
| 1479 |
-
input_features = input_features.clone()
|
| 1480 |
-
config = self.config
|
| 1481 |
-
# Whisper/GLM-ASR hand us (batch, n_mels, mel_len); Conformer encoders
|
| 1482 |
-
# (Granite, Parakeet, Nemotron) hand us (batch, time, feature_dim).
|
| 1483 |
-
# Masking the wrong axis is silent -- it still runs and still zeroes
|
| 1484 |
-
# cells -- so the axis order is read from config rather than guessed.
|
| 1485 |
-
time_major = getattr(config, "audio_features_time_major", False)
|
| 1486 |
-
if time_major:
|
| 1487 |
-
batch_size, sequence_length, _ = input_features.size()
|
| 1488 |
-
feature_dim = 2
|
| 1489 |
-
else:
|
| 1490 |
-
batch_size, _, sequence_length = input_features.size()
|
| 1491 |
-
feature_dim = 1
|
| 1492 |
-
device = input_features.device
|
| 1493 |
-
|
| 1494 |
-
valid_lengths = None
|
| 1495 |
-
if attention_mask is not None:
|
| 1496 |
-
if attention_mask.shape[-1] != sequence_length:
|
| 1497 |
-
msg = (
|
| 1498 |
-
"SpecAugment attention_mask time axis "
|
| 1499 |
-
f"({attention_mask.shape[-1]}) does not match input_features "
|
| 1500 |
-
f"({sequence_length}). Masking against the wrong axis is silent, "
|
| 1501 |
-
"so this is raised rather than ignored."
|
| 1502 |
-
)
|
| 1503 |
-
raise ValueError(msg)
|
| 1504 |
-
valid_lengths = attention_mask.to(device).sum(dim=-1)
|
| 1505 |
-
|
| 1506 |
-
if getattr(config, "mask_time_prob", 0.0) > 0:
|
| 1507 |
-
mask_time = self._sample_mask_indices(
|
| 1508 |
-
batch_size,
|
| 1509 |
-
sequence_length,
|
| 1510 |
-
mask_prob=config.mask_time_prob,
|
| 1511 |
-
mask_length=config.mask_time_length,
|
| 1512 |
-
min_masks=config.mask_time_min_masks,
|
| 1513 |
-
device=device,
|
| 1514 |
-
valid_lengths=valid_lengths,
|
| 1515 |
-
)
|
| 1516 |
-
# Broadcast (B, T) over the feature axis to mask all bins at
|
| 1517 |
-
# masked times: (B, 1, T) feature-major, (B, T, 1) time-major.
|
| 1518 |
-
input_features.masked_fill_(mask_time.unsqueeze(feature_dim), 0)
|
| 1519 |
-
|
| 1520 |
-
return input_features
|
| 1521 |
-
|
| 1522 |
-
@staticmethod
|
| 1523 |
-
def _sample_mask_indices(
|
| 1524 |
-
batch_size: int,
|
| 1525 |
-
axis_length: int,
|
| 1526 |
-
mask_prob: float,
|
| 1527 |
-
mask_length: int,
|
| 1528 |
-
min_masks: int,
|
| 1529 |
-
device: torch.device,
|
| 1530 |
-
valid_lengths: torch.Tensor | None = None,
|
| 1531 |
-
) -> torch.Tensor:
|
| 1532 |
-
"""Vectorized SpecAugment mask sampler — torch.compile-friendly.
|
| 1533 |
-
|
| 1534 |
-
Returns a (batch_size, axis_length) bool tensor where True marks
|
| 1535 |
-
a position covered by at least one mask span. Spans may overlap
|
| 1536 |
-
(see _mask_input_features docstring on the semantic difference vs
|
| 1537 |
-
the upstream Whisper helper).
|
| 1538 |
-
|
| 1539 |
-
`valid_lengths` is a (batch_size,) tensor of unpadded lengths along
|
| 1540 |
-
this axis; both the span count and the start positions are derived
|
| 1541 |
-
per sample from it, so every span lands inside real content. Pass
|
| 1542 |
-
None for an axis with no padding.
|
| 1543 |
-
"""
|
| 1544 |
-
# Worst-case span count, from the full axis. Computed as a Python int
|
| 1545 |
-
# from axis_length (an upper bound on any sample's valid length)
|
| 1546 |
-
# rather than from valid_lengths.max(), which would need a .item()
|
| 1547 |
-
# call and reintroduce the per-forward GPU->host sync this function
|
| 1548 |
-
# was rewritten to avoid. Surplus spans are masked off per sample
|
| 1549 |
-
# below, so over-allocating here is free apart from memory.
|
| 1550 |
-
#
|
| 1551 |
-
# Matches the upstream formula (ignoring the epsilon noise term, which
|
| 1552 |
-
# only shifts the count by ±1 stochastically — negligible at the
|
| 1553 |
-
# default mask_time_prob=0.05 / mask_length=10 setting which gives
|
| 1554 |
-
# ~5 spans for a typical 1500-frame mel input).
|
| 1555 |
-
max_spans = max(int(mask_prob * axis_length / mask_length + 0.5), min_masks)
|
| 1556 |
-
if max_spans == 0:
|
| 1557 |
-
return torch.zeros(batch_size, axis_length, device=device, dtype=torch.bool)
|
| 1558 |
-
|
| 1559 |
-
if valid_lengths is None:
|
| 1560 |
-
lengths = torch.full((batch_size,), axis_length, device=device, dtype=torch.long)
|
| 1561 |
-
else:
|
| 1562 |
-
lengths = valid_lengths.to(device=device, dtype=torch.long).clamp(
|
| 1563 |
-
min=1, max=axis_length
|
| 1564 |
-
)
|
| 1565 |
-
|
| 1566 |
-
# Per-sample span count, so a short clip is not given a long clip's
|
| 1567 |
-
# dose of masking. Bounded above by max_spans since lengths <= axis_length.
|
| 1568 |
-
num_spans = torch.clamp(
|
| 1569 |
-
(mask_prob * lengths.float() / mask_length + 0.5).long(), min=min_masks
|
| 1570 |
-
) # (B,)
|
| 1571 |
-
|
| 1572 |
-
# Sample start positions independently per sample x span, inside that
|
| 1573 |
-
# sample's own valid range. Clamp so a span of length mask_length never
|
| 1574 |
-
# starts past the end of short content. torch.rand is [0, 1), so the
|
| 1575 |
-
# floor below never reaches max_start.
|
| 1576 |
-
max_start = torch.clamp(lengths - mask_length + 1, min=1) # (B,)
|
| 1577 |
-
starts = (
|
| 1578 |
-
torch.rand(batch_size, max_spans, device=device) * max_start.unsqueeze(1)
|
| 1579 |
-
).long() # (B, N)
|
| 1580 |
-
|
| 1581 |
-
# Drop the spans a sample didn't earn (its num_spans < max_spans).
|
| 1582 |
-
span_active = torch.arange(max_spans, device=device).unsqueeze(0) < num_spans.unsqueeze(1)
|
| 1583 |
-
|
| 1584 |
-
# For each (sample, span, position), True iff position ∈ [start, start+mask_length).
|
| 1585 |
-
positions = torch.arange(axis_length, device=device).view(1, 1, -1) # (1, 1, T)
|
| 1586 |
-
starts_b = starts.unsqueeze(-1) # (B, N, 1)
|
| 1587 |
-
span_mask = (positions >= starts_b) & (positions < starts_b + mask_length)
|
| 1588 |
-
span_mask &= span_active.unsqueeze(-1)
|
| 1589 |
-
# Reduce over the span dim: True if ANY span covers this position.
|
| 1590 |
-
return span_mask.any(dim=1)
|
| 1591 |
-
|
| 1592 |
def forward(
|
| 1593 |
self,
|
| 1594 |
input_ids: torch.Tensor | None = None,
|
|
@@ -1608,10 +1316,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1608 |
if inputs_embeds is None:
|
| 1609 |
inputs_embeds = self._embed_tokens(input_ids)
|
| 1610 |
|
| 1611 |
-
# Gemma 4 PLE: derived from input_ids, so it must be captured before the
|
| 1612 |
-
# masked_scatter below replaces the audio rows. No-op ({}) elsewhere.
|
| 1613 |
-
ple_kwargs = self._per_layer_kwargs(input_ids)
|
| 1614 |
-
|
| 1615 |
if input_features is not None and input_ids is not None:
|
| 1616 |
is_audio_token = input_ids == self.audio_token_id
|
| 1617 |
if audio_token_counts is None:
|
|
@@ -1627,14 +1331,22 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1627 |
|
| 1628 |
inputs_embeds = _splice_audio(inputs_embeds, is_audio_token, audio_embeds)
|
| 1629 |
|
| 1630 |
-
#
|
| 1631 |
-
#
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1638 |
|
| 1639 |
# Ask for the dataclass output explicitly. Every consumer below and in
|
| 1640 |
# generate() reads `outputs.loss` / `outputs.logits` by attribute, so
|
|
@@ -1653,11 +1365,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1653 |
# the (B, T, V) tensor is pure cost on both train and eval.
|
| 1654 |
#
|
| 1655 |
# Requesting it EXPLICITLY on both, rather than leaning on liger's
|
| 1656 |
-
# default, because that default is wrong here in both directions
|
| 1657 |
-
#
|
| 1658 |
-
#
|
| 1659 |
-
#
|
| 1660 |
-
# where `self` is the LANGUAGE MODEL. Two ways that misfires:
|
| 1661 |
#
|
| 1662 |
# eval -- runs under model.eval(), so the default is False and the
|
| 1663 |
# full tensor gets built. granite_qwen.yaml's claim that
|
|
@@ -1681,11 +1391,25 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1681 |
# Done here rather than via `use_liger_kernel: true` because that flag
|
| 1682 |
# makes transformers call apply_liger_kernel(), which *raises* when
|
| 1683 |
# liger-kernel is unavailable -- and liger is a linux-only dependency
|
| 1684 |
-
# (pyproject.toml), so it would break every
|
| 1685 |
# Gating on the patched signature is equivalent where it matters and
|
| 1686 |
# inert everywhere else.
|
| 1687 |
if labels is not None and self._lm_accepts_skip_logits:
|
| 1688 |
kwargs.setdefault("skip_logits", True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1689 |
|
| 1690 |
outputs: CausalLMOutputWithPast = self.language_model(
|
| 1691 |
attention_mask=attention_mask,
|
|
@@ -1695,11 +1419,23 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1695 |
labels=labels,
|
| 1696 |
use_cache=use_cache,
|
| 1697 |
cache_position=cache_position,
|
| 1698 |
-
**ple_kwargs,
|
| 1699 |
**kwargs,
|
| 1700 |
)
|
| 1701 |
return outputs
|
| 1702 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1703 |
def prepare_inputs_for_generation(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
| 1704 |
"""Prepare inputs for generation, handling audio features for cached decoding."""
|
| 1705 |
input_features = kwargs.pop("input_features", None)
|
|
@@ -1732,7 +1468,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1732 |
input_ids, attention_mask = left_pad_prompt_rows(rows, self.tokenizer)
|
| 1733 |
return input_ids.to(device), attention_mask.to(device)
|
| 1734 |
|
| 1735 |
-
def
|
| 1736 |
self,
|
| 1737 |
input_features: torch.Tensor,
|
| 1738 |
audio_attention_mask: torch.Tensor,
|
|
@@ -1793,10 +1529,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1793 |
"""
|
| 1794 |
if attention_mask is None or attention_mask.device.type != "mps":
|
| 1795 |
return
|
| 1796 |
-
if (
|
| 1797 |
-
self.language_model.config._attn_implementation # pyright: ignore[reportPrivateUsage] # transformers has no public accessor
|
| 1798 |
-
!= "sdpa"
|
| 1799 |
-
):
|
| 1800 |
return
|
| 1801 |
if not bool((attention_mask == 0).any()):
|
| 1802 |
return
|
|
@@ -1811,7 +1544,6 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1811 |
)
|
| 1812 |
raise ValueError(msg)
|
| 1813 |
|
| 1814 |
-
@torch.no_grad() # pyright: ignore[reportUntypedFunctionDecorator]
|
| 1815 |
def generate(
|
| 1816 |
self,
|
| 1817 |
*args: Any,
|
|
@@ -1831,79 +1563,83 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1831 |
signature stays compatible with `GenerationMixin.generate`, whose
|
| 1832 |
positional parameters mean something else entirely.
|
| 1833 |
"""
|
| 1834 |
-
|
| 1835 |
-
|
| 1836 |
-
|
| 1837 |
-
|
| 1838 |
-
|
| 1839 |
-
|
| 1840 |
-
|
| 1841 |
-
|
| 1842 |
-
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
|
|
|
|
|
|
|
|
|
|
| 1846 |
|
| 1847 |
-
|
| 1848 |
-
|
| 1849 |
-
|
| 1850 |
-
|
| 1851 |
-
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
| 1855 |
-
|
| 1856 |
-
|
| 1857 |
-
|
| 1858 |
-
|
| 1859 |
-
|
| 1860 |
-
|
| 1861 |
-
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
| 1874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1875 |
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
|
| 1885 |
-
|
| 1886 |
-
|
| 1887 |
-
|
| 1888 |
-
|
| 1889 |
|
| 1890 |
def _lm_generate_inputs(
|
| 1891 |
self, input_ids: torch.Tensor, inputs_embeds: torch.Tensor
|
| 1892 |
) -> dict[str, torch.Tensor]:
|
| 1893 |
"""The prompt arguments for `language_model.generate`.
|
| 1894 |
|
| 1895 |
-
|
| 1896 |
-
|
| 1897 |
-
|
| 1898 |
-
combination outright -- "You must specify exactly one of input_ids or
|
| 1899 |
-
inputs_embeds" -- so there we pass embeds plus the PLE precomputed from
|
| 1900 |
-
the clean prompt ids. Gemma's prepare_inputs_for_generation drops
|
| 1901 |
-
per_layer_inputs after the first step, so it is correct to hand it to
|
| 1902 |
-
generate().
|
| 1903 |
"""
|
| 1904 |
-
ple_kwargs = self._per_layer_kwargs(input_ids)
|
| 1905 |
-
if ple_kwargs:
|
| 1906 |
-
return {"inputs_embeds": inputs_embeds, **ple_kwargs}
|
| 1907 |
return {"input_ids": input_ids, "inputs_embeds": inputs_embeds}
|
| 1908 |
|
| 1909 |
def generate_streaming(
|
|
@@ -1925,7 +1661,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1925 |
Yields:
|
| 1926 |
Partial transcript text as each token is generated
|
| 1927 |
"""
|
| 1928 |
-
input_ids, attention_mask, inputs_embeds = self.
|
| 1929 |
input_features, audio_attention_mask
|
| 1930 |
)
|
| 1931 |
|
|
@@ -1997,11 +1733,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 1997 |
save_dir = Path(save_directory)
|
| 1998 |
save_dir.mkdir(parents=True, exist_ok=True)
|
| 1999 |
|
| 2000 |
-
# Update config with actual vocab size. Composite decoder configs
|
| 2001 |
-
#
|
| 2002 |
-
#
|
| 2003 |
-
#
|
| 2004 |
-
# save, i.e. after the run had already spent real training time.
|
| 2005 |
lm_config = self.language_model.config
|
| 2006 |
if hasattr(lm_config, "get_text_config"):
|
| 2007 |
lm_config = lm_config.get_text_config()
|
|
@@ -2085,7 +1820,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 2085 |
|
| 2086 |
# Copy source files for auto-loading
|
| 2087 |
src_dir = Path(__file__).parent
|
| 2088 |
-
siblings = ("projectors.py", "alignment.py", "diarization.py")
|
| 2089 |
for src in [*src_dir.glob("asr_*.py"), *(src_dir / name for name in siblings)]:
|
| 2090 |
shutil.copy(src, save_dir / src.name)
|
| 2091 |
|
|
@@ -2135,7 +1870,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 2135 |
"""
|
| 2136 |
# Store repo_id in config so save_pretrained can access it
|
| 2137 |
self.config.pretrained_model_path = repo_id
|
| 2138 |
-
#
|
|
|
|
|
|
|
|
|
|
| 2139 |
return PreTrainedModel.push_to_hub(
|
| 2140 |
self,
|
| 2141 |
repo_id,
|
|
@@ -2146,7 +1884,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
|
|
| 2146 |
revision=revision,
|
| 2147 |
create_pr=create_pr,
|
| 2148 |
max_shard_size=max_shard_size,
|
| 2149 |
-
|
| 2150 |
)
|
| 2151 |
|
| 2152 |
|
|
|
|
| 1 |
import contextlib
|
|
|
|
| 2 |
import importlib.util
|
| 3 |
import inspect
|
| 4 |
import json
|
|
|
|
| 12 |
from typing import TYPE_CHECKING, Any, Self, cast, overload
|
| 13 |
|
| 14 |
import torch
|
| 15 |
+
from peft import LoraConfig, PeftModel, get_peft_model, set_peft_model_state_dict
|
|
|
|
| 16 |
from safetensors.torch import load_file
|
| 17 |
+
from torch import nn
|
| 18 |
from torch.nn import functional
|
| 19 |
from transformers import (
|
| 20 |
AutoFeatureExtractor,
|
|
|
|
| 22 |
AutoModelForCausalLM,
|
| 23 |
AutoModelForSeq2SeqLM,
|
| 24 |
AutoTokenizer,
|
| 25 |
+
GenerationConfig,
|
| 26 |
+
LogitsProcessor,
|
| 27 |
+
LogitsProcessorList,
|
| 28 |
+
PretrainedConfig,
|
| 29 |
PreTrainedModel,
|
| 30 |
PreTrainedTokenizerBase,
|
| 31 |
SequenceFeatureExtractor,
|
|
|
|
| 41 |
|
| 42 |
if TYPE_CHECKING:
|
| 43 |
from transformers import GraniteSpeech5Encoder as _GraniteSpeech5Encoder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
from .asr_attention import (
|
| 46 |
+
active_attn_implementation,
|
| 47 |
+
resolve_attn_implementation,
|
| 48 |
+
resolve_decoder_attn_implementation,
|
| 49 |
+
)
|
| 50 |
+
from .asr_config import (
|
| 51 |
+
GENERATION_DEFAULTS,
|
| 52 |
+
ASRConfig,
|
| 53 |
+
compute_encoder_output_length,
|
| 54 |
+
is_qwen3_asr_encoder,
|
| 55 |
+
)
|
| 56 |
+
from .asr_layers import unfreeze_encoder_top_layers
|
| 57 |
+
from .asr_packing import pack_rows
|
| 58 |
from .asr_processing import (
|
| 59 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 60 |
ASRProcessor,
|
|
|
|
| 70 |
OutputLengthProjector,
|
| 71 |
StateDictT,
|
| 72 |
)
|
| 73 |
+
from .projectors import MLPAudioProjector
|
| 74 |
+
from .qwen3_asr_encoder import Qwen3ASRAudioEncoder
|
| 75 |
|
| 76 |
# None at runtime on a transformers without the architecture; see below.
|
| 77 |
GraniteSpeech5Encoder: type[_GraniteSpeech5Encoder] | None
|
|
|
|
| 78 |
else:
|
| 79 |
try:
|
| 80 |
+
from .asr_attention import (
|
| 81 |
+
active_attn_implementation,
|
| 82 |
+
resolve_attn_implementation,
|
| 83 |
+
resolve_decoder_attn_implementation,
|
| 84 |
+
)
|
| 85 |
+
from .asr_config import (
|
| 86 |
+
GENERATION_DEFAULTS,
|
| 87 |
+
ASRConfig,
|
| 88 |
+
compute_encoder_output_length,
|
| 89 |
+
is_qwen3_asr_encoder,
|
| 90 |
+
)
|
| 91 |
+
from .asr_layers import unfreeze_encoder_top_layers
|
| 92 |
+
from .asr_packing import pack_rows
|
| 93 |
from .asr_processing import (
|
| 94 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 95 |
ASRProcessor,
|
|
|
|
| 97 |
render_audio_prompt,
|
| 98 |
)
|
| 99 |
from .asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
|
| 100 |
+
from .projectors import MLPAudioProjector
|
| 101 |
+
from .qwen3_asr_encoder import Qwen3ASRAudioEncoder
|
| 102 |
except ImportError: # flat layout on the Hub: sibling modules, no package
|
| 103 |
+
from asr_attention import (
|
| 104 |
+
active_attn_implementation,
|
| 105 |
+
resolve_attn_implementation,
|
| 106 |
+
resolve_decoder_attn_implementation,
|
| 107 |
+
)
|
| 108 |
+
from asr_config import (
|
| 109 |
+
GENERATION_DEFAULTS,
|
| 110 |
+
ASRConfig,
|
| 111 |
+
compute_encoder_output_length,
|
| 112 |
+
is_qwen3_asr_encoder,
|
| 113 |
+
)
|
| 114 |
+
from asr_layers import unfreeze_encoder_top_layers
|
| 115 |
+
from asr_packing import pack_rows
|
| 116 |
from asr_processing import (
|
| 117 |
DEFAULT_TRANSCRIBE_PROMPT,
|
| 118 |
ASRProcessor,
|
|
|
|
| 120 |
render_audio_prompt,
|
| 121 |
)
|
| 122 |
from asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
|
| 123 |
+
from projectors import MLPAudioProjector
|
| 124 |
+
from qwen3_asr_encoder import Qwen3ASRAudioEncoder
|
| 125 |
|
| 126 |
+
# Newer than the transformers floor in pyproject.toml. Kept optional so a
|
| 127 |
+
# checkpoint that does not need it still loads on an older install; the
|
| 128 |
+
# branch that needs it raises at its use site instead.
|
| 129 |
try:
|
| 130 |
from transformers import GraniteSpeech5Encoder
|
| 131 |
except ImportError: # transformers < 5.16: no native granite_speech5
|
| 132 |
GraniteSpeech5Encoder = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
+
logger: logging.Logger = logging.getLogger(__name__)
|
| 136 |
|
| 137 |
# Vocab dimension the embedding table is padded to after adding the audio
|
| 138 |
# token. 128 is what transformers' own `pad_to_multiple_of` docs recommend for
|
|
|
|
| 246 |
)
|
| 247 |
|
| 248 |
|
| 249 |
+
class TensorNoRepeatNGram(LogitsProcessor):
|
| 250 |
+
"""`NoRepeatNGramLogitsProcessor`'s bans, computed as tensor ops on the ids' device.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
+
The stock processor rebuilds a Python dict of every n-gram in every row
|
| 253 |
+
each step, with a `.tolist()` (a GPU sync) per row: 0.3-0.7 ms of host work
|
| 254 |
+
a step at batch 8-32 against a 6.8 ms CUDA-graphed decode step. Same
|
| 255 |
+
semantics: a token is banned when the last n-1 ids plus it would repeat an
|
| 256 |
+
n-gram already in the row, prompt included.
|
| 257 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
+
def __init__(self, ngram_size: int) -> None:
|
| 260 |
+
self.ngram_size = ngram_size
|
| 261 |
+
|
| 262 |
+
def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor) -> torch.FloatTensor:
|
| 263 |
+
n = self.ngram_size
|
| 264 |
+
if input_ids.shape[1] < n:
|
| 265 |
+
return scores
|
| 266 |
+
windows = input_ids.unfold(1, n, 1) # (batch, positions, n)
|
| 267 |
+
tail = input_ids[:, None, input_ids.shape[1] - n + 1 :]
|
| 268 |
+
repeats = (windows[..., :-1] == tail).all(dim=-1)
|
| 269 |
+
# Non-repeating windows point at a spare column, so every write is True.
|
| 270 |
+
vocab = scores.shape[-1]
|
| 271 |
+
banned = torch.zeros(scores.shape[0], vocab + 1, dtype=torch.bool, device=scores.device)
|
| 272 |
+
banned.scatter_(1, torch.where(repeats, windows[..., -1], vocab), True)
|
| 273 |
+
return cast("torch.FloatTensor", scores.masked_fill(banned[:, :vocab], -float("inf")))
|
| 274 |
|
| 275 |
|
| 276 |
+
def _assert_projector_loaded(incompatible_keys: LoadStateDictResult) -> None:
|
| 277 |
"""Fail loudly when a checkpoint's projector doesn't match the built one.
|
| 278 |
|
| 279 |
`save_pretrained` serializes `projector.*` (plus the language model when
|
|
|
|
| 320 |
)
|
| 321 |
|
| 322 |
raise RuntimeError(
|
| 323 |
+
"Projector weights do not match the configured projector: " + "; ".join(detail) + hint
|
|
|
|
| 324 |
)
|
| 325 |
|
| 326 |
|
|
|
|
| 352 |
class ASRModel(PreTrainedModel, GenerationMixin):
|
| 353 |
"""Audio-to-text model combining an audio encoder, projector, and language model."""
|
| 354 |
|
| 355 |
+
config_class: type[PretrainedConfig] | None = ASRConfig
|
| 356 |
base_model_prefix = "model"
|
| 357 |
main_input_name = "input_features"
|
| 358 |
_supports_flash_attn_2 = True
|
|
|
|
| 402 |
if model_file is not None:
|
| 403 |
state_dict = load_file(model_file)
|
| 404 |
incompatible = model.load_state_dict(state_dict, strict=False)
|
| 405 |
+
_assert_projector_loaded(incompatible)
|
| 406 |
|
| 407 |
# Load LoRA adapters if use_lora is enabled
|
| 408 |
if getattr(config, "use_lora", False):
|
|
|
|
| 457 |
target_dtype = getattr(torch, config.model_dtype)
|
| 458 |
|
| 459 |
# Audio encoder (frozen)
|
| 460 |
+
self.audio_tower: nn.Module = self._load_audio_encoder(config, target_dtype)
|
| 461 |
|
| 462 |
# Language model (frozen). Typed as the decoder even with LoRA attached:
|
| 463 |
# a PeftModel forwards every attribute it does not define to the
|
|
|
|
| 487 |
# text_model_id, liger missing (it is linux-only), or a patcher
|
| 488 |
# that patched a different class than the one that got loaded.
|
| 489 |
logger.log(
|
| 490 |
+
logging.INFO if self._is_loading_from_pretrained else logging.WARNING,
|
| 491 |
"%s's forward does not accept `skip_logits` — liger's fused "
|
| 492 |
"linear cross-entropy is NOT active, so every training step "
|
| 493 |
"materializes a (batch, seq, %s) logits tensor. Check for an "
|
|
|
|
| 500 |
self._init_tokenizer(config)
|
| 501 |
|
| 502 |
# Set up generation config with greedy decoding defaults
|
| 503 |
+
self.generation_config: GenerationConfig = self.language_model.generation_config
|
|
|
|
|
|
|
| 504 |
# `generate()` reads the GenerationConfig and nothing else, so every
|
| 505 |
+
# decoding knob ASRConfig owns has to be copied across here. A missing
|
| 506 |
+
# copy line once left ASRConfig's 12-gram loop guard inert: the value was
|
| 507 |
# set on the config, serialized into config.json, and never consulted.
|
| 508 |
+
for key in GENERATION_DEFAULTS:
|
| 509 |
+
setattr(self.generation_config, key, getattr(config, key))
|
| 510 |
self.generation_config.eos_token_id = self._resolve_eos_token_ids()
|
| 511 |
self.generation_config.pad_token_id = self.tokenizer.pad_token_id
|
| 512 |
|
| 513 |
# Feature extractor for audio preprocessing
|
| 514 |
+
self.feature_extractor: SequenceFeatureExtractor = self._create_feature_extractor(config)
|
| 515 |
|
| 516 |
# Audio projector (trainable unless freeze_projector is set)
|
| 517 |
+
self.projector: MLPAudioProjector = self._create_projector(config, target_dtype)
|
| 518 |
|
| 519 |
# Setup LoRA if enabled (Stage 2 fine-tuning)
|
| 520 |
# Skip if loading from pretrained - from_pretrained will handle adapter loading
|
| 521 |
+
if getattr(config, "use_lora", False) and not self._is_loading_from_pretrained:
|
| 522 |
self._setup_lora(config)
|
| 523 |
|
| 524 |
# Freeze projector if specified (for Stage 2 LoRA-only training)
|
|
|
|
| 544 |
feature_extractor: SequenceFeatureExtractor = AutoFeatureExtractor.from_pretrained(
|
| 545 |
config.audio_model_id
|
| 546 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
return feature_extractor
|
| 548 |
|
| 549 |
@classmethod
|
|
|
|
| 565 |
}
|
| 566 |
|
| 567 |
encoder: nn.Module
|
| 568 |
+
if is_qwen3_asr_encoder(config.audio_model_id):
|
| 569 |
+
# Only the audio tower of the ASR checkpoint; see qwen3_asr_encoder.py.
|
| 570 |
+
encoder = Qwen3ASRAudioEncoder.from_pretrained(
|
| 571 |
+
config.audio_model_id, dtype, encoder_kwargs["attn_implementation"]
|
| 572 |
+
)
|
| 573 |
+
elif "whisper" in config.audio_model_id.lower():
|
| 574 |
full_model = WhisperModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
|
| 575 |
encoder = full_model.encoder
|
| 576 |
del full_model
|
|
|
|
| 646 |
# trainable blocks; it must be fp32 for Adam's step to survive
|
| 647 |
# rounding. See ASRConfig.encoder_dtype for the arithmetic.
|
| 648 |
encoder_dtype = _resolve_dtype(getattr(config, "encoder_dtype", None), dtype)
|
| 649 |
+
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a
|
| 650 |
+
# method; it casts in place and returns the module itself.
|
| 651 |
+
cast(nn.Module, encoder).to(dtype=encoder_dtype)
|
| 652 |
if getattr(config, "freeze_audio_encoder", True):
|
| 653 |
encoder.requires_grad_(False)
|
| 654 |
encoder.train(False) # equivalent to .eval(); avoids a security hook false-positive
|
|
|
|
| 728 |
AutoModelForCausalLM.from_pretrained(config.text_model_id, **decoder_kwargs),
|
| 729 |
)
|
| 730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
# See _load_audio_encoder note: idempotent post-load cast to dodge the
|
| 732 |
# FA2 "current dype is fp32" warning when from_pretrained's dtype kwarg
|
| 733 |
# isn't fully propagated to every submodule.
|
| 734 |
# `to` returns the module itself, so the cast happens in place.
|
| 735 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 736 |
+
cast(nn.Module, decoder).to(dtype=dtype)
|
| 737 |
decoder.config.use_cache = getattr(config, "use_cache", True)
|
| 738 |
if getattr(config, "freeze_language_model", True):
|
| 739 |
decoder.requires_grad_(False)
|
| 740 |
decoder.train(False)
|
| 741 |
return decoder
|
| 742 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
def _create_projector(self, config: ASRConfig, dtype: torch.dtype) -> "MLPAudioProjector":
|
| 744 |
"""Create the trainable audio projector."""
|
| 745 |
# Auto-detect dimensions if not specified
|
|
|
|
| 754 |
|
| 755 |
if config.llm_dim is None:
|
| 756 |
dec_cfg = self.language_model.config
|
| 757 |
+
# Composite configs keep hidden_size on `text_config`;
|
| 758 |
+
# get_text_config() returns self for plain decoders.
|
| 759 |
if hasattr(dec_cfg, "get_text_config"):
|
| 760 |
dec_cfg = dec_cfg.get_text_config()
|
| 761 |
config.llm_dim = getattr(dec_cfg, "hidden_size", None) or getattr(
|
|
|
|
| 765 |
msg = "Could not auto-detect llm_dim. Please specify in config."
|
| 766 |
raise ValueError(msg)
|
| 767 |
|
| 768 |
+
projector = MLPAudioProjector(config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
|
| 770 |
# Move projector to same device as language model (important when using quantization)
|
| 771 |
device = next(self.language_model.parameters()).device
|
|
|
|
| 783 |
lora_alpha=config.lora_alpha,
|
| 784 |
target_modules=config.lora_target_modules,
|
| 785 |
lora_dropout=config.lora_dropout,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
bias="none",
|
| 787 |
task_type="CAUSAL_LM",
|
| 788 |
)
|
|
|
|
| 812 |
self.tokenizer.pad_token = self.tokenizer.eos_token
|
| 813 |
|
| 814 |
# Audio placeholder token. Prefer a token the tokenizer already knows
|
| 815 |
+
# so no embedding resize is needed at all.
|
| 816 |
# `unk_token_id` is the sentinel convert_tokens_to_ids returns for an
|
| 817 |
# unknown token, so it means "not in vocab" rather than a usable id.
|
| 818 |
self.audio_token = getattr(config, "audio_token", None) or "<audio>"
|
|
|
|
| 954 |
tokenize=False,
|
| 955 |
add_generation_prompt=False,
|
| 956 |
)
|
| 957 |
+
except Exception: # any template that cannot render this probe has no usable tail
|
| 958 |
+
logger.debug("Chat template probe failed", exc_info=True)
|
| 959 |
return None
|
| 960 |
if not isinstance(rendered, str) or sentinel not in rendered:
|
| 961 |
return None
|
|
|
|
| 970 |
first = tail_ids[0]
|
| 971 |
return first if first in set(self.tokenizer.all_special_ids) else None
|
| 972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 973 |
def train(self, mode: bool = True) -> Self:
|
| 974 |
"""Set train/eval mode, but keep frozen submodules out of train mode.
|
| 975 |
|
|
|
|
| 992 |
# statistics anyway. Granite Speech 5.0 carries 16 BatchNorm1d and
|
| 993 |
# zero Dropout, and for non-Whisper extractors the collator pads to
|
| 994 |
# the batch longest (`DataCollator._audio_padding`) with
|
| 995 |
+
# group_by_length off, so clips spanning 0.1-30s leave a large
|
| 996 |
# pad fraction. Granite's conv module zeroes pad positions and THEN
|
| 997 |
# runs BatchNorm1d over the full padded (B, C, T); BN reduces over
|
| 998 |
# B*T, so those zeros are counted as data -- deflating the mean and
|
|
|
|
| 1118 |
projector=self.projector,
|
| 1119 |
encoder_conv_layers=self.config.encoder_conv_layers,
|
| 1120 |
audio_token=self.audio_token,
|
| 1121 |
+
encoder_chunk_frames=self.config.encoder_chunk_frames,
|
| 1122 |
)
|
| 1123 |
|
| 1124 |
+
def load_lora_checkpoint(self, checkpoint_dir: str | PathLike[str]) -> bool:
|
| 1125 |
+
"""Load the LoRA weights a checkpoint saved as adapter_model.safetensors.
|
| 1126 |
+
|
| 1127 |
+
HF Trainer resumes by loading model.safetensors (the projector, see
|
| 1128 |
+
`state_dict`) and, when that file exists, never reaches its PEFT
|
| 1129 |
+
branch, so a resumed run kept the trained projector but restarted LoRA
|
| 1130 |
+
from its initialization under the trained optimizer state
|
| 1131 |
+
(granite-qwen-verbatim-aug-2 resumed at step 52,000 on 2026-10-19:
|
| 1132 |
+
loss 0.28 -> 1.7). Returns whether adapter weights were loaded; raises
|
| 1133 |
+
if a saved tensor matches no LoRA parameter.
|
| 1134 |
+
"""
|
| 1135 |
+
path = Path(checkpoint_dir) / "adapter_model.safetensors"
|
| 1136 |
+
if not path.is_file() or not isinstance(self.language_model, PeftModel):
|
| 1137 |
+
return False
|
| 1138 |
+
result = set_peft_model_state_dict(self.language_model, load_file(path))
|
| 1139 |
+
if result.unexpected_keys:
|
| 1140 |
+
msg = f"adapter weights match no LoRA parameter: {result.unexpected_keys[:3]}"
|
| 1141 |
+
raise ValueError(msg)
|
| 1142 |
+
logger.info("Loaded LoRA adapter weights from %s", path)
|
| 1143 |
+
return True
|
| 1144 |
+
|
| 1145 |
@overload
|
| 1146 |
def state_dict(
|
| 1147 |
self, *, destination: StateDictT, prefix: str = ..., keep_vars: bool = ...
|
|
|
|
| 1218 |
destination.update({f"{prefix}{k}": v for k, v in sd.items()})
|
| 1219 |
return destination
|
| 1220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1221 |
def _embed_tokens(self, input_ids: torch.Tensor | None) -> torch.Tensor:
|
| 1222 |
"""Look `input_ids` up in the decoder's token embedding table."""
|
| 1223 |
embeds: torch.Tensor = self.language_model.get_input_embeddings()(input_ids)
|
|
|
|
| 1231 |
return compute_encoder_output_length(
|
| 1232 |
audio_attention_mask.sum(dim=-1),
|
| 1233 |
self.config.encoder_conv_layers,
|
| 1234 |
+
self.config.encoder_chunk_frames,
|
| 1235 |
)
|
| 1236 |
|
| 1237 |
def _encode_audio(
|
|
|
|
| 1251 |
Returns:
|
| 1252 |
Flattened audio embeddings of shape (sum(expected_token_counts), hidden_dim).
|
| 1253 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
# When the encoder is frozen, skip gradient tracking through it — cuts
|
| 1255 |
# activation memory and matches the prior published recipe's behavior.
|
| 1256 |
# When trainable, we MUST allow gradients to flow back to encoder
|
|
|
|
| 1297 |
)
|
| 1298 |
return _gather_audio_embeds(audio_embeds, token_counts, max_tokens)
|
| 1299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
def forward(
|
| 1301 |
self,
|
| 1302 |
input_ids: torch.Tensor | None = None,
|
|
|
|
| 1316 |
if inputs_embeds is None:
|
| 1317 |
inputs_embeds = self._embed_tokens(input_ids)
|
| 1318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
if input_features is not None and input_ids is not None:
|
| 1320 |
is_audio_token = input_ids == self.audio_token_id
|
| 1321 |
if audio_token_counts is None:
|
|
|
|
| 1331 |
|
| 1332 |
inputs_embeds = _splice_audio(inputs_embeds, is_audio_token, audio_embeds)
|
| 1333 |
|
| 1334 |
+
# Padding-free packing (asr_packing.py): only the real tokens reach the
|
| 1335 |
+
# decoder, so no attention or MLP compute is spent on padding.
|
| 1336 |
+
if (
|
| 1337 |
+
labels is not None
|
| 1338 |
+
and attention_mask is not None
|
| 1339 |
+
and self._can_pack(position_ids, past_key_values)
|
| 1340 |
+
):
|
| 1341 |
+
packed = pack_rows(inputs_embeds, attention_mask, labels)
|
| 1342 |
+
inputs_embeds, labels = packed.inputs_embeds, packed.labels
|
| 1343 |
+
position_ids, attention_mask = packed.position_ids, None
|
| 1344 |
+
kwargs.update(
|
| 1345 |
+
cu_seq_lens_q=packed.cu_seq_lens,
|
| 1346 |
+
cu_seq_lens_k=packed.cu_seq_lens,
|
| 1347 |
+
max_length_q=packed.max_length,
|
| 1348 |
+
max_length_k=packed.max_length,
|
| 1349 |
+
)
|
| 1350 |
|
| 1351 |
# Ask for the dataclass output explicitly. Every consumer below and in
|
| 1352 |
# generate() reads `outputs.loss` / `outputs.logits` by attribute, so
|
|
|
|
| 1365 |
# the (B, T, V) tensor is pure cost on both train and eval.
|
| 1366 |
#
|
| 1367 |
# Requesting it EXPLICITLY on both, rather than leaning on liger's
|
| 1368 |
+
# default, because that default is wrong here in both directions. Liger
|
| 1369 |
+
# sets `skip_logits = self.training and labels is not None`, where
|
| 1370 |
+
# `self` is the LANGUAGE MODEL. Two ways that misfires:
|
|
|
|
|
|
|
| 1371 |
#
|
| 1372 |
# eval -- runs under model.eval(), so the default is False and the
|
| 1373 |
# full tensor gets built. granite_qwen.yaml's claim that
|
|
|
|
| 1391 |
# Done here rather than via `use_liger_kernel: true` because that flag
|
| 1392 |
# makes transformers call apply_liger_kernel(), which *raises* when
|
| 1393 |
# liger-kernel is unavailable -- and liger is a linux-only dependency
|
| 1394 |
+
# (pyproject.toml), so it would break every local run on a mac.
|
| 1395 |
# Gating on the patched signature is equivalent where it matters and
|
| 1396 |
# inert everywhere else.
|
| 1397 |
if labels is not None and self._lm_accepts_skip_logits:
|
| 1398 |
kwargs.setdefault("skip_logits", True)
|
| 1399 |
+
elif labels is not None:
|
| 1400 |
+
# No liger (a mac, or liger missing): the stock forward projects
|
| 1401 |
+
# every position through lm_head, audio and prompt included, though
|
| 1402 |
+
# only the transcript carries labels. Keep just the positions that
|
| 1403 |
+
# predict a label and hand the loss its already-shifted targets,
|
| 1404 |
+
# which ForCausalLMLoss takes as-is. Same loss, same
|
| 1405 |
+
# num_items_in_batch normalization, a (B, K, V) logits tensor with
|
| 1406 |
+
# K ~ the longest transcript instead of T ~ the whole prompt. On
|
| 1407 |
+
# Qwen3.5's 248K vocab that tensor and its fp32 upcast and gradient
|
| 1408 |
+
# are what pushed a batch-4 mac run past 40 GB.
|
| 1409 |
+
shift_labels = nn.functional.pad(labels, (0, 1), value=-100)[:, 1:]
|
| 1410 |
+
keep = (shift_labels != -100).any(dim=0).nonzero().squeeze(-1)
|
| 1411 |
+
kwargs.setdefault("logits_to_keep", keep)
|
| 1412 |
+
kwargs.setdefault("shift_labels", shift_labels[:, keep])
|
| 1413 |
|
| 1414 |
outputs: CausalLMOutputWithPast = self.language_model(
|
| 1415 |
attention_mask=attention_mask,
|
|
|
|
| 1419 |
labels=labels,
|
| 1420 |
use_cache=use_cache,
|
| 1421 |
cache_position=cache_position,
|
|
|
|
| 1422 |
**kwargs,
|
| 1423 |
)
|
| 1424 |
return outputs
|
| 1425 |
|
| 1426 |
+
def _can_pack(self, position_ids: torch.Tensor | None, past_key_values: object) -> bool:
|
| 1427 |
+
"""Whether a labelled, padded batch can run padding-free: uncached and on FA2.
|
| 1428 |
+
|
| 1429 |
+
Only flash_attention_2 keeps packed rows apart (by their cumulative
|
| 1430 |
+
lengths); SDPA or eager would let them attend to each other.
|
| 1431 |
+
"""
|
| 1432 |
+
return (
|
| 1433 |
+
bool(getattr(self.config, "pack_sequences", False))
|
| 1434 |
+
and position_ids is None
|
| 1435 |
+
and past_key_values is None
|
| 1436 |
+
and active_attn_implementation(self.language_model.config) == "flash_attention_2"
|
| 1437 |
+
)
|
| 1438 |
+
|
| 1439 |
def prepare_inputs_for_generation(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
| 1440 |
"""Prepare inputs for generation, handling audio features for cached decoding."""
|
| 1441 |
input_features = kwargs.pop("input_features", None)
|
|
|
|
| 1468 |
input_ids, attention_mask = left_pad_prompt_rows(rows, self.tokenizer)
|
| 1469 |
return input_ids.to(device), attention_mask.to(device)
|
| 1470 |
|
| 1471 |
+
def prepare_audio_inputs(
|
| 1472 |
self,
|
| 1473 |
input_features: torch.Tensor,
|
| 1474 |
audio_attention_mask: torch.Tensor,
|
|
|
|
| 1529 |
"""
|
| 1530 |
if attention_mask is None or attention_mask.device.type != "mps":
|
| 1531 |
return
|
| 1532 |
+
if active_attn_implementation(self.language_model.config) != "sdpa":
|
|
|
|
|
|
|
|
|
|
| 1533 |
return
|
| 1534 |
if not bool((attention_mask == 0).any()):
|
| 1535 |
return
|
|
|
|
| 1544 |
)
|
| 1545 |
raise ValueError(msg)
|
| 1546 |
|
|
|
|
| 1547 |
def generate(
|
| 1548 |
self,
|
| 1549 |
*args: Any,
|
|
|
|
| 1563 |
signature stays compatible with `GenerationMixin.generate`, whose
|
| 1564 |
positional parameters mean something else entirely.
|
| 1565 |
"""
|
| 1566 |
+
# A context manager, not the @torch.no_grad() decorator, which pyright
|
| 1567 |
+
# cannot type and so would erase this signature.
|
| 1568 |
+
with torch.no_grad():
|
| 1569 |
+
if args:
|
| 1570 |
+
msg = (
|
| 1571 |
+
"ASRModel.generate takes input_ids, input_features, "
|
| 1572 |
+
"audio_attention_mask and attention_mask as keyword arguments"
|
| 1573 |
+
)
|
| 1574 |
+
raise TypeError(msg)
|
| 1575 |
+
if input_features is None:
|
| 1576 |
+
msg = "input_features required for generation"
|
| 1577 |
+
raise ValueError(msg)
|
| 1578 |
+
if audio_attention_mask is None:
|
| 1579 |
+
msg = "audio_attention_mask required for generation"
|
| 1580 |
+
raise ValueError(msg)
|
| 1581 |
|
| 1582 |
+
input_ids, attention_mask, inputs_embeds = self.prepare_audio_inputs(
|
| 1583 |
+
input_features,
|
| 1584 |
+
audio_attention_mask,
|
| 1585 |
+
input_ids=input_ids,
|
| 1586 |
+
attention_mask=attention_mask,
|
| 1587 |
+
)
|
| 1588 |
+
self._assert_sdpa_safe_on_mps(attention_mask)
|
| 1589 |
+
|
| 1590 |
+
# Fold generation flags (max_new_tokens, num_beams, output_scores, ...)
|
| 1591 |
+
# into a private copy of generation_config instead of passing them
|
| 1592 |
+
# alongside it: transformers v5 still applies such kwargs but warns that
|
| 1593 |
+
# combining them with `generation_config` is deprecated. update() returns
|
| 1594 |
+
# the kwargs that aren't config fields (streamer, logits_processor, ...),
|
| 1595 |
+
# which still go to generate() directly.
|
| 1596 |
+
gen_cfg = deepcopy(self.generation_config)
|
| 1597 |
+
generate_kwargs = gen_cfg.update(**generate_kwargs)
|
| 1598 |
+
# output_scores requires return_dict_in_generate for HF generate to
|
| 1599 |
+
# actually populate .scores on the output object.
|
| 1600 |
+
if gen_cfg.output_scores:
|
| 1601 |
+
gen_cfg.return_dict_in_generate = True
|
| 1602 |
+
if gen_cfg.no_repeat_ngram_size:
|
| 1603 |
+
processors = LogitsProcessorList(
|
| 1604 |
+
generate_kwargs.pop("logits_processor", None) or []
|
| 1605 |
+
)
|
| 1606 |
+
processors.append(TensorNoRepeatNGram(gen_cfg.no_repeat_ngram_size))
|
| 1607 |
+
generate_kwargs["logits_processor"] = processors
|
| 1608 |
+
# 0, not None: generate refills None from the decoder's own config
|
| 1609 |
+
# and would build the stock processor alongside this one.
|
| 1610 |
+
gen_cfg.no_repeat_ngram_size = 0
|
| 1611 |
+
|
| 1612 |
+
lm_inputs = self._lm_generate_inputs(input_ids, inputs_embeds)
|
| 1613 |
+
output = self.language_model.generate(
|
| 1614 |
+
**lm_inputs,
|
| 1615 |
+
attention_mask=attention_mask,
|
| 1616 |
+
generation_config=gen_cfg,
|
| 1617 |
+
**generate_kwargs,
|
| 1618 |
+
)
|
| 1619 |
|
| 1620 |
+
# With input_ids AND inputs_embeds, generate returns the full sequence
|
| 1621 |
+
# (prompt + generated), so the prompt must be stripped. With
|
| 1622 |
+
# inputs_embeds alone it already returns only the new tokens, so
|
| 1623 |
+
# stripping would eat real output -- hence prompt_len = 0 there.
|
| 1624 |
+
# When scores were requested, preserve the GenerateOutput so callers
|
| 1625 |
+
# can read .scores; otherwise return the bare tensor for backward
|
| 1626 |
+
# compatibility with existing callers.
|
| 1627 |
+
prompt_len = input_ids.shape[1] if "input_ids" in lm_inputs else 0
|
| 1628 |
+
if isinstance(output, torch.Tensor):
|
| 1629 |
+
# Slicing a token-id tensor keeps its int64 dtype.
|
| 1630 |
+
return cast("torch.LongTensor", output[:, prompt_len:])
|
| 1631 |
+
output.sequences = cast("torch.LongTensor", output.sequences[:, prompt_len:])
|
| 1632 |
+
return output
|
| 1633 |
|
| 1634 |
def _lm_generate_inputs(
|
| 1635 |
self, input_ids: torch.Tensor, inputs_embeds: torch.Tensor
|
| 1636 |
) -> dict[str, torch.Tensor]:
|
| 1637 |
"""The prompt arguments for `language_model.generate`.
|
| 1638 |
|
| 1639 |
+
Passes both input_ids and inputs_embeds so repetition_penalty and
|
| 1640 |
+
no_repeat_ngram_size see the prompt (they need input_ids to track which
|
| 1641 |
+
tokens have been used).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
"""
|
|
|
|
|
|
|
|
|
|
| 1643 |
return {"input_ids": input_ids, "inputs_embeds": inputs_embeds}
|
| 1644 |
|
| 1645 |
def generate_streaming(
|
|
|
|
| 1661 |
Yields:
|
| 1662 |
Partial transcript text as each token is generated
|
| 1663 |
"""
|
| 1664 |
+
input_ids, attention_mask, inputs_embeds = self.prepare_audio_inputs(
|
| 1665 |
input_features, audio_attention_mask
|
| 1666 |
)
|
| 1667 |
|
|
|
|
| 1733 |
save_dir = Path(save_directory)
|
| 1734 |
save_dir.mkdir(parents=True, exist_ok=True)
|
| 1735 |
|
| 1736 |
+
# Update config with actual vocab size. Composite decoder configs hold
|
| 1737 |
+
# text/vision/audio sub-configs and have no top-level `vocab_size` --
|
| 1738 |
+
# reading it raises AttributeError, which used to surface only at the
|
| 1739 |
+
# first checkpoint save, after the run had already spent real time.
|
|
|
|
| 1740 |
lm_config = self.language_model.config
|
| 1741 |
if hasattr(lm_config, "get_text_config"):
|
| 1742 |
lm_config = lm_config.get_text_config()
|
|
|
|
| 1820 |
|
| 1821 |
# Copy source files for auto-loading
|
| 1822 |
src_dir = Path(__file__).parent
|
| 1823 |
+
siblings = ("projectors.py", "alignment.py", "diarization.py", "qwen3_asr_encoder.py")
|
| 1824 |
for src in [*src_dir.glob("asr_*.py"), *(src_dir / name for name in siblings)]:
|
| 1825 |
shutil.copy(src, save_dir / src.name)
|
| 1826 |
|
|
|
|
| 1870 |
"""
|
| 1871 |
# Store repo_id in config so save_pretrained can access it
|
| 1872 |
self.config.pretrained_model_path = repo_id
|
| 1873 |
+
# `tags` only when given: transformers reads it as
|
| 1874 |
+
# `kwargs.get("tags", [])` and iterates the result, so an explicit
|
| 1875 |
+
# None is a TypeError after the whole run has trained.
|
| 1876 |
+
extra: dict[str, Any] = {"tags": tags} if tags is not None else {}
|
| 1877 |
return PreTrainedModel.push_to_hub(
|
| 1878 |
self,
|
| 1879 |
repo_id,
|
|
|
|
| 1884 |
revision=revision,
|
| 1885 |
create_pr=create_pr,
|
| 1886 |
max_shard_size=max_shard_size,
|
| 1887 |
+
**extra,
|
| 1888 |
)
|
| 1889 |
|
| 1890 |
|
asr_packing.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Padding-free packing of a labelled decoder batch for FlashAttention's varlen kernels.
|
| 2 |
+
|
| 3 |
+
Training batches are padded to their longest row, and the clips run 0.1-30 s,
|
| 4 |
+
so a large share of every step's decoder compute (attention AND MLP) went to
|
| 5 |
+
padding. Packing keeps only the real tokens: the batch's rows are concatenated
|
| 6 |
+
into one `(1, total_tokens)` sequence, and FlashAttention's variable-length
|
| 7 |
+
kernels keep each row's attention inside its own segment via the cumulative
|
| 8 |
+
sequence lengths passed alongside.
|
| 9 |
+
|
| 10 |
+
The batch's rows, their order and the per-token loss are unchanged:
|
| 11 |
+
|
| 12 |
+
- each row's positions restart at 0. Training batches are left-padded, so
|
| 13 |
+
padded rows used to start at an offset; RoPE scores depend only on relative
|
| 14 |
+
position, and 0-based positions are what `generate()` uses at inference;
|
| 15 |
+
- the first token of every row gets label -100, so no row is trained to
|
| 16 |
+
predict its first token from the previous row's last one. Every row starts
|
| 17 |
+
with a prompt token, which is already unlabelled, so this drops nothing; in
|
| 18 |
+
the padded layout such a target would have been predicted from a fully
|
| 19 |
+
masked pad position anyway.
|
| 20 |
+
|
| 21 |
+
Only valid under flash_attention_2: SDPA and eager would see one unmasked
|
| 22 |
+
sequence and let rows attend to each other. ASRModel checks before packing.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class PackedBatch:
|
| 32 |
+
"""One flattened sequence and the varlen metadata FlashAttention needs."""
|
| 33 |
+
|
| 34 |
+
inputs_embeds: torch.Tensor # (1, total_tokens, hidden)
|
| 35 |
+
labels: torch.Tensor # (1, total_tokens)
|
| 36 |
+
position_ids: torch.Tensor # (1, total_tokens), restarting at 0 per row
|
| 37 |
+
cu_seq_lens: torch.Tensor # (rows + 1,) int32 row boundaries
|
| 38 |
+
max_length: int
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def pack_rows(
|
| 42 |
+
inputs_embeds: torch.Tensor, attention_mask: torch.Tensor, labels: torch.Tensor
|
| 43 |
+
) -> PackedBatch:
|
| 44 |
+
"""Concatenate the real tokens of a padded `(rows, length)` batch into one sequence."""
|
| 45 |
+
valid = attention_mask.bool()
|
| 46 |
+
lengths = valid.sum(dim=-1)
|
| 47 |
+
position_ids = (valid.long().cumsum(dim=-1) - 1)[valid].unsqueeze(0)
|
| 48 |
+
packed_labels = labels[valid].clone()
|
| 49 |
+
starts = torch.cat([lengths.new_zeros(1), lengths.cumsum(dim=0)[:-1]])
|
| 50 |
+
packed_labels[starts[lengths > 0]] = -100
|
| 51 |
+
cu_seq_lens = torch.nn.functional.pad(lengths.cumsum(dim=0), (1, 0)).to(torch.int32)
|
| 52 |
+
return PackedBatch(
|
| 53 |
+
inputs_embeds=inputs_embeds[valid].unsqueeze(0),
|
| 54 |
+
labels=packed_labels.unsqueeze(0),
|
| 55 |
+
position_ids=position_ids,
|
| 56 |
+
cu_seq_lens=cu_seq_lens,
|
| 57 |
+
max_length=int(lengths.max()),
|
| 58 |
+
)
|
asr_pipeline.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
| 1 |
"""ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
|
| 2 |
|
|
|
|
| 3 |
import re
|
| 4 |
from collections.abc import Callable, Iterator, MutableMapping, Sequence
|
| 5 |
-
from pathlib import Path
|
| 6 |
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
import numpy.typing as npt
|
| 10 |
import torch
|
| 11 |
import transformers
|
| 12 |
-
from transformers.pipelines.audio_utils import ffmpeg_read
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
| 15 |
from transformers import PreTrainedTokenizerBase, SequenceFeatureExtractor
|
|
@@ -17,12 +16,15 @@ if TYPE_CHECKING:
|
|
| 17 |
from .alignment import QwenForcedAligner
|
| 18 |
from .asr_modeling import ASRModel
|
| 19 |
from .asr_processing import (
|
|
|
|
| 20 |
audible_chunks,
|
| 21 |
chunk_bounds,
|
| 22 |
collate_chunks,
|
|
|
|
| 23 |
is_silent,
|
| 24 |
prepend_lead_in,
|
| 25 |
)
|
|
|
|
| 26 |
from .asr_types import PreparedChunk
|
| 27 |
from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 28 |
else:
|
|
@@ -30,24 +32,30 @@ else:
|
|
| 30 |
from .alignment import QwenForcedAligner
|
| 31 |
from .asr_modeling import ASRModel
|
| 32 |
from .asr_processing import (
|
|
|
|
| 33 |
audible_chunks,
|
| 34 |
chunk_bounds,
|
| 35 |
collate_chunks,
|
|
|
|
| 36 |
is_silent,
|
| 37 |
prepend_lead_in,
|
| 38 |
)
|
|
|
|
| 39 |
from .asr_types import PreparedChunk
|
| 40 |
from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 41 |
except ImportError: # flat layout on the Hub: sibling modules, no package
|
| 42 |
from alignment import QwenForcedAligner
|
| 43 |
from asr_modeling import ASRModel
|
| 44 |
from asr_processing import (
|
|
|
|
| 45 |
audible_chunks,
|
| 46 |
chunk_bounds,
|
| 47 |
collate_chunks,
|
|
|
|
| 48 |
is_silent,
|
| 49 |
prepend_lead_in,
|
| 50 |
)
|
|
|
|
| 51 |
from asr_types import PreparedChunk
|
| 52 |
from diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 53 |
|
|
@@ -61,12 +69,9 @@ __all__ = [
|
|
| 61 |
]
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
| 64 |
_THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
|
| 65 |
-
_MIN_REPEATS = 3
|
| 66 |
-
_LOOSE_MIN_REPEATS = 6
|
| 67 |
-
_TRAILING_CHAR_RE = re.compile(rf"(.)\1{{{_MIN_REPEATS - 1},}}$")
|
| 68 |
-
_TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
|
| 69 |
-
_NON_WORD_RE = re.compile(r"\W+")
|
| 70 |
|
| 71 |
|
| 72 |
class _RawAudio(TypedDict):
|
|
@@ -127,6 +132,30 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 127 |
**kwargs,
|
| 128 |
)
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _sanitize_parameters(
|
| 131 |
self,
|
| 132 |
chunk_length_s: float | None = None,
|
|
@@ -246,7 +275,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 246 |
return super().__call__(inputs, **kwargs)
|
| 247 |
array, sr = audio["array"], audio["sampling_rate"]
|
| 248 |
|
| 249 |
-
bounds = chunk_bounds(array, sr)
|
| 250 |
if not kwargs:
|
| 251 |
texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr)
|
| 252 |
return {"text": " ".join(t for t in texts if t)}
|
|
@@ -318,7 +347,9 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 318 |
audio_attention_mask=batch["attention_mask"].to(self.model.device),
|
| 319 |
**generate_kwargs,
|
| 320 |
)
|
| 321 |
-
|
|
|
|
|
|
|
| 322 |
return [self.postprocess({"tokens": row})["text"] for row in tokens.cpu()]
|
| 323 |
|
| 324 |
def _transcribe_chunk(
|
|
@@ -362,7 +393,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 362 |
self, array: npt.NDArray[np.float32], sr: int, **kwargs: Any
|
| 363 |
) -> dict[str, Any]:
|
| 364 |
"""`{"text", "words"}` for the whole recording: chunk, transcribe, align, offset."""
|
| 365 |
-
bounds = chunk_bounds(array, sr)
|
| 366 |
texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr, **kwargs)
|
| 367 |
result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
|
| 368 |
|
|
@@ -376,7 +407,10 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 376 |
for (s, _), chunk_words in zip(bounds, aligned, strict=True)
|
| 377 |
for w in chunk_words
|
| 378 |
]
|
| 379 |
-
except Exception as e:
|
|
|
|
|
|
|
|
|
|
| 380 |
result["words"] = []
|
| 381 |
result["timestamp_error"] = str(e)
|
| 382 |
return result
|
|
@@ -401,7 +435,8 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 401 |
try:
|
| 402 |
activity = NemotronDiarizer.activity(array, sample_rate=sr)
|
| 403 |
keep = NemotronDiarizer.top_speakers(activity, **diarization_params)
|
| 404 |
-
except Exception as e:
|
|
|
|
| 405 |
result = self._transcribe_aligned(array, sr, **kwargs)
|
| 406 |
result["speaker_segments"] = []
|
| 407 |
result["diarization_error"] = str(e)
|
|
@@ -448,7 +483,10 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 448 |
[(ch["audio"], text) for ch, text in zip(chunks, texts, strict=True)],
|
| 449 |
sample_rate=sr,
|
| 450 |
)
|
| 451 |
-
except Exception as e:
|
|
|
|
|
|
|
|
|
|
| 452 |
order = sorted(range(len(chunks)), key=lambda k: chunks[k]["start"])
|
| 453 |
text = " ".join(texts[k] for k in order if texts[k])
|
| 454 |
return {"text": text, "words": [], "timestamp_error": str(e)}
|
|
@@ -463,9 +501,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 463 |
return None
|
| 464 |
array, sr = inputs[key], inputs.get("sampling_rate", 16000)
|
| 465 |
elif isinstance(inputs, (str, bytes)):
|
| 466 |
-
|
| 467 |
-
data = Path(inputs).read_bytes() if isinstance(inputs, str) else inputs
|
| 468 |
-
array, sr = ffmpeg_read(data, sampling_rate=16000), 16000
|
| 469 |
elif isinstance(inputs, np.ndarray):
|
| 470 |
array, sr = inputs, 16000
|
| 471 |
else:
|
|
@@ -486,11 +522,10 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 486 |
inputs = _single_input(args, preprocess_params, "inputs")
|
| 487 |
# Handle dict with "array" key (from datasets)
|
| 488 |
if isinstance(inputs, dict) and "array" in inputs:
|
| 489 |
-
assert self.feature_extractor is not None # always set by __init__
|
| 490 |
sample = inputs
|
| 491 |
inputs = {
|
| 492 |
"raw": sample["array"],
|
| 493 |
-
"sampling_rate": sample.get("sampling_rate", self.
|
| 494 |
}
|
| 495 |
|
| 496 |
# Inference-only lead-in silence. See prepend_lead_in for the
|
|
@@ -508,11 +543,10 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 508 |
else 0.0
|
| 509 |
)
|
| 510 |
if lead_in > 0 and isinstance(inputs, dict) and "raw" in inputs:
|
| 511 |
-
assert self.feature_extractor is not None # always set by __init__
|
| 512 |
padded: dict[str, Any] = dict(inputs)
|
| 513 |
padded["raw"] = prepend_lead_in(
|
| 514 |
padded["raw"],
|
| 515 |
-
padded.get("sampling_rate", self.
|
| 516 |
lead_in,
|
| 517 |
)
|
| 518 |
inputs = padded
|
|
@@ -584,21 +618,15 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 584 |
"Call it with one clip per invocation."
|
| 585 |
)
|
| 586 |
raise ValueError(msg)
|
| 587 |
-
|
| 588 |
-
top2_logprobs: list[float] = []
|
| 589 |
if scores:
|
| 590 |
# One batched reduction over every step, then one device transfer.
|
| 591 |
logits = torch.stack([step_logits[0] for step_logits in scores]).float()
|
| 592 |
-
steps
|
| 593 |
-
logits.log_softmax(dim=-1).topk(k=2, dim=-1).values.cpu().tolist()
|
| 594 |
-
)
|
| 595 |
-
for top1, top2 in steps:
|
| 596 |
-
top1_logprobs.append(top1)
|
| 597 |
-
top2_logprobs.append(top2)
|
| 598 |
return {
|
| 599 |
"tokens": sequences,
|
| 600 |
-
"top1_logprob":
|
| 601 |
-
"top2_logprob":
|
| 602 |
"is_last": is_last,
|
| 603 |
}
|
| 604 |
|
|
@@ -648,22 +676,22 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
|
|
| 648 |
token_ids: list[int] = np.asarray(tokens).tolist()
|
| 649 |
tokens = [t for t in token_ids if t not in eos_set]
|
| 650 |
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
|
|
|
| 654 |
text = decoded.strip()
|
| 655 |
# Strip <think>...</think> tags (Qwen3 doesn't respect /no_think prompt)
|
| 656 |
if "<think>" in text:
|
| 657 |
text = _THINK_TAG_RE.sub("", text).strip()
|
| 658 |
-
text =
|
| 659 |
out: dict[str, Any] = {"text": text}
|
| 660 |
# Pass through per-step logprobs when _forward captured them (i.e. caller
|
| 661 |
# passed output_scores=True). Lets eval harnesses compute confidence
|
| 662 |
# stats without re-running the model.
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
out["top2_logprob"] = model_outputs["top2_logprob"]
|
| 667 |
return out
|
| 668 |
|
| 669 |
|
|
@@ -681,71 +709,3 @@ def _single_input(args: tuple[Any, ...], kwargs: dict[str, Any], name: str) -> A
|
|
| 681 |
return kwargs.pop(name)
|
| 682 |
msg = f"expected exactly one {name!r} argument, got {len(args)} positional"
|
| 683 |
raise TypeError(msg)
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
def _truncate_repetitions(text: str) -> str:
|
| 687 |
-
"""Truncate repeated words/phrases/characters at end of text.
|
| 688 |
-
|
| 689 |
-
Detects patterns like:
|
| 690 |
-
- Repeated words: "the the the the" -> "the"
|
| 691 |
-
- Repeated phrases: "i am sorry i am sorry i am sorry" -> "i am sorry"
|
| 692 |
-
- Repeated characters: "444444" -> "4"
|
| 693 |
-
|
| 694 |
-
Words match ignoring case. A run of `_LOOSE_MIN_REPEATS` also matches
|
| 695 |
-
ignoring punctuation, since the model punctuates inside a loop ("Yeah Yeah
|
| 696 |
-
Yeah. Yeah. Yeah,"); shorter punctuated runs are real speech ("No, no,
|
| 697 |
-
no,"), and collapsing those cost 40 errors over 47 rows of the served eval.
|
| 698 |
-
|
| 699 |
-
Args:
|
| 700 |
-
text: Input text to process
|
| 701 |
-
|
| 702 |
-
Returns:
|
| 703 |
-
Text with trailing repetitions removed
|
| 704 |
-
"""
|
| 705 |
-
if not text:
|
| 706 |
-
return text
|
| 707 |
-
|
| 708 |
-
text = _TRAILING_CHAR_RE.sub(r"\1", text)
|
| 709 |
-
while _TRAILING_WORD_RE.search(text):
|
| 710 |
-
text = _TRAILING_WORD_RE.sub(r"\1", text)
|
| 711 |
-
|
| 712 |
-
# 3. Truncate repeated phrases (2-20 words) at end
|
| 713 |
-
# e.g., "i am sorry i am sorry i am sorry" -> "i am sorry"
|
| 714 |
-
words = text.split()
|
| 715 |
-
if len(words) < _MIN_REPEATS * 2:
|
| 716 |
-
return text
|
| 717 |
-
|
| 718 |
-
# Cheap pre-check: trailing window must contain duplicates for any phrase repeat
|
| 719 |
-
# to be possible. set(window) == window means all unique → no repetition.
|
| 720 |
-
window = words[-_MIN_REPEATS * 2 :]
|
| 721 |
-
if len(set(window)) == len(window):
|
| 722 |
-
return _truncate_punctuated_loop(text)
|
| 723 |
-
|
| 724 |
-
for phrase_len in range(2, min(21, len(words) // _MIN_REPEATS + 1)):
|
| 725 |
-
phrase_escaped = re.escape(" ".join(words[-phrase_len:]))
|
| 726 |
-
phrase_pattern = re.compile(
|
| 727 |
-
rf"(^|.*?\s)({phrase_escaped})(?:\s+{phrase_escaped}){{{_MIN_REPEATS - 1},}}\s*$",
|
| 728 |
-
re.IGNORECASE,
|
| 729 |
-
)
|
| 730 |
-
match = phrase_pattern.match(text)
|
| 731 |
-
if match:
|
| 732 |
-
text = (match.group(1) + match.group(2)).strip()
|
| 733 |
-
break
|
| 734 |
-
|
| 735 |
-
return _truncate_punctuated_loop(text)
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
def _truncate_punctuated_loop(text: str) -> str:
|
| 739 |
-
"""Collapse a trailing 1-20 word phrase run `_LOOSE_MIN_REPEATS`+ times, punctuation ignored."""
|
| 740 |
-
words = text.split()
|
| 741 |
-
keys = [_NON_WORD_RE.sub("", w).lower() for w in words]
|
| 742 |
-
end = len(words)
|
| 743 |
-
# Shortest phrase first, so "Yeah Yeah. Yeah," stops at "Yeah".
|
| 744 |
-
for n in range(1, min(21, end // _LOOSE_MIN_REPEATS + 1)):
|
| 745 |
-
phrase = keys[end - n : end]
|
| 746 |
-
reps = 1
|
| 747 |
-
while end - (reps + 1) * n >= 0 and keys[end - (reps + 1) * n : end - reps * n] == phrase:
|
| 748 |
-
reps += 1
|
| 749 |
-
if reps >= _LOOSE_MIN_REPEATS:
|
| 750 |
-
return " ".join(words[: end - (reps - 1) * n])
|
| 751 |
-
return text
|
|
|
|
| 1 |
"""ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
import re
|
| 5 |
from collections.abc import Callable, Iterator, MutableMapping, Sequence
|
|
|
|
| 6 |
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
import numpy.typing as npt
|
| 10 |
import torch
|
| 11 |
import transformers
|
|
|
|
| 12 |
|
| 13 |
if TYPE_CHECKING:
|
| 14 |
from transformers import PreTrainedTokenizerBase, SequenceFeatureExtractor
|
|
|
|
| 16 |
from .alignment import QwenForcedAligner
|
| 17 |
from .asr_modeling import ASRModel
|
| 18 |
from .asr_processing import (
|
| 19 |
+
CHUNK_MAX_S,
|
| 20 |
audible_chunks,
|
| 21 |
chunk_bounds,
|
| 22 |
collate_chunks,
|
| 23 |
+
decode_audio_input,
|
| 24 |
is_silent,
|
| 25 |
prepend_lead_in,
|
| 26 |
)
|
| 27 |
+
from .asr_text import truncate_repetitions
|
| 28 |
from .asr_types import PreparedChunk
|
| 29 |
from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 30 |
else:
|
|
|
|
| 32 |
from .alignment import QwenForcedAligner
|
| 33 |
from .asr_modeling import ASRModel
|
| 34 |
from .asr_processing import (
|
| 35 |
+
CHUNK_MAX_S,
|
| 36 |
audible_chunks,
|
| 37 |
chunk_bounds,
|
| 38 |
collate_chunks,
|
| 39 |
+
decode_audio_input,
|
| 40 |
is_silent,
|
| 41 |
prepend_lead_in,
|
| 42 |
)
|
| 43 |
+
from .asr_text import truncate_repetitions
|
| 44 |
from .asr_types import PreparedChunk
|
| 45 |
from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 46 |
except ImportError: # flat layout on the Hub: sibling modules, no package
|
| 47 |
from alignment import QwenForcedAligner
|
| 48 |
from asr_modeling import ASRModel
|
| 49 |
from asr_processing import (
|
| 50 |
+
CHUNK_MAX_S,
|
| 51 |
audible_chunks,
|
| 52 |
chunk_bounds,
|
| 53 |
collate_chunks,
|
| 54 |
+
decode_audio_input,
|
| 55 |
is_silent,
|
| 56 |
prepend_lead_in,
|
| 57 |
)
|
| 58 |
+
from asr_text import truncate_repetitions
|
| 59 |
from asr_types import PreparedChunk
|
| 60 |
from diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
|
| 61 |
|
|
|
|
| 69 |
]
|
| 70 |
|
| 71 |
|
| 72 |
+
logger: logging.Logger = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
_THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
class _RawAudio(TypedDict):
|
|
|
|
| 132 |
**kwargs,
|
| 133 |
)
|
| 134 |
|
| 135 |
+
@property
|
| 136 |
+
def chunk_max_seconds(self) -> float:
|
| 137 |
+
"""Longest chunk to cut long audio into: the model's trained-for length.
|
| 138 |
+
|
| 139 |
+
`ASRConfig.inference_chunk_seconds`; checkpoints saved before it existed
|
| 140 |
+
fall back to the 18 s they were trained for.
|
| 141 |
+
"""
|
| 142 |
+
value = getattr(self.model.config, "inference_chunk_seconds", None)
|
| 143 |
+
return float(value) if value else CHUNK_MAX_S
|
| 144 |
+
|
| 145 |
+
def _sampling_rate(self) -> int:
|
| 146 |
+
"""The feature extractor's sample rate (`__init__` always sets the extractor)."""
|
| 147 |
+
if self.feature_extractor is None:
|
| 148 |
+
msg = "ASRPipeline has no feature extractor"
|
| 149 |
+
raise RuntimeError(msg)
|
| 150 |
+
return self.feature_extractor.sampling_rate
|
| 151 |
+
|
| 152 |
+
def _tokenizer(self) -> "PreTrainedTokenizerBase":
|
| 153 |
+
"""The tokenizer (`__init__` always sets it; the base class types it Optional)."""
|
| 154 |
+
if self.tokenizer is None:
|
| 155 |
+
msg = "ASRPipeline has no tokenizer"
|
| 156 |
+
raise RuntimeError(msg)
|
| 157 |
+
return self.tokenizer
|
| 158 |
+
|
| 159 |
def _sanitize_parameters(
|
| 160 |
self,
|
| 161 |
chunk_length_s: float | None = None,
|
|
|
|
| 275 |
return super().__call__(inputs, **kwargs)
|
| 276 |
array, sr = audio["array"], audio["sampling_rate"]
|
| 277 |
|
| 278 |
+
bounds = chunk_bounds(array, sr, self.chunk_max_seconds)
|
| 279 |
if not kwargs:
|
| 280 |
texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr)
|
| 281 |
return {"text": " ".join(t for t in texts if t)}
|
|
|
|
| 347 |
audio_attention_mask=batch["attention_mask"].to(self.model.device),
|
| 348 |
**generate_kwargs,
|
| 349 |
)
|
| 350 |
+
if not isinstance(tokens, torch.Tensor): # no output_scores: plain token ids
|
| 351 |
+
msg = f"generate returned {type(tokens).__name__}, expected token ids"
|
| 352 |
+
raise TypeError(msg)
|
| 353 |
return [self.postprocess({"tokens": row})["text"] for row in tokens.cpu()]
|
| 354 |
|
| 355 |
def _transcribe_chunk(
|
|
|
|
| 393 |
self, array: npt.NDArray[np.float32], sr: int, **kwargs: Any
|
| 394 |
) -> dict[str, Any]:
|
| 395 |
"""`{"text", "words"}` for the whole recording: chunk, transcribe, align, offset."""
|
| 396 |
+
bounds = chunk_bounds(array, sr, self.chunk_max_seconds)
|
| 397 |
texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr, **kwargs)
|
| 398 |
result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
|
| 399 |
|
|
|
|
| 407 |
for (s, _), chunk_words in zip(bounds, aligned, strict=True)
|
| 408 |
for w in chunk_words
|
| 409 |
]
|
| 410 |
+
except Exception as e: # timestamps are optional: return the text without them
|
| 411 |
+
logger.warning(
|
| 412 |
+
"Word alignment failed; returning text without timestamps", exc_info=True
|
| 413 |
+
)
|
| 414 |
result["words"] = []
|
| 415 |
result["timestamp_error"] = str(e)
|
| 416 |
return result
|
|
|
|
| 435 |
try:
|
| 436 |
activity = NemotronDiarizer.activity(array, sample_rate=sr)
|
| 437 |
keep = NemotronDiarizer.top_speakers(activity, **diarization_params)
|
| 438 |
+
except Exception as e: # speakers are optional: fall back to the unmasked transcript
|
| 439 |
+
logger.warning("Diarization failed; transcribing without speakers", exc_info=True)
|
| 440 |
result = self._transcribe_aligned(array, sr, **kwargs)
|
| 441 |
result["speaker_segments"] = []
|
| 442 |
result["diarization_error"] = str(e)
|
|
|
|
| 483 |
[(ch["audio"], text) for ch, text in zip(chunks, texts, strict=True)],
|
| 484 |
sample_rate=sr,
|
| 485 |
)
|
| 486 |
+
except Exception as e: # timestamps are optional: order chunks by start time instead
|
| 487 |
+
logger.warning(
|
| 488 |
+
"Word alignment failed; returning text without timestamps", exc_info=True
|
| 489 |
+
)
|
| 490 |
order = sorted(range(len(chunks)), key=lambda k: chunks[k]["start"])
|
| 491 |
text = " ".join(texts[k] for k in order if texts[k])
|
| 492 |
return {"text": text, "words": [], "timestamp_error": str(e)}
|
|
|
|
| 501 |
return None
|
| 502 |
array, sr = inputs[key], inputs.get("sampling_rate", 16000)
|
| 503 |
elif isinstance(inputs, (str, bytes)):
|
| 504 |
+
array, sr = decode_audio_input(inputs), 16000
|
|
|
|
|
|
|
| 505 |
elif isinstance(inputs, np.ndarray):
|
| 506 |
array, sr = inputs, 16000
|
| 507 |
else:
|
|
|
|
| 522 |
inputs = _single_input(args, preprocess_params, "inputs")
|
| 523 |
# Handle dict with "array" key (from datasets)
|
| 524 |
if isinstance(inputs, dict) and "array" in inputs:
|
|
|
|
| 525 |
sample = inputs
|
| 526 |
inputs = {
|
| 527 |
"raw": sample["array"],
|
| 528 |
+
"sampling_rate": sample.get("sampling_rate", self._sampling_rate()),
|
| 529 |
}
|
| 530 |
|
| 531 |
# Inference-only lead-in silence. See prepend_lead_in for the
|
|
|
|
| 543 |
else 0.0
|
| 544 |
)
|
| 545 |
if lead_in > 0 and isinstance(inputs, dict) and "raw" in inputs:
|
|
|
|
| 546 |
padded: dict[str, Any] = dict(inputs)
|
| 547 |
padded["raw"] = prepend_lead_in(
|
| 548 |
padded["raw"],
|
| 549 |
+
padded.get("sampling_rate", self._sampling_rate()),
|
| 550 |
lead_in,
|
| 551 |
)
|
| 552 |
inputs = padded
|
|
|
|
| 618 |
"Call it with one clip per invocation."
|
| 619 |
)
|
| 620 |
raise ValueError(msg)
|
| 621 |
+
steps: list[list[float]] = []
|
|
|
|
| 622 |
if scores:
|
| 623 |
# One batched reduction over every step, then one device transfer.
|
| 624 |
logits = torch.stack([step_logits[0] for step_logits in scores]).float()
|
| 625 |
+
steps = logits.log_softmax(dim=-1).topk(k=2, dim=-1).values.cpu().tolist()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
return {
|
| 627 |
"tokens": sequences,
|
| 628 |
+
"top1_logprob": [step[0] for step in steps],
|
| 629 |
+
"top2_logprob": [step[1] for step in steps],
|
| 630 |
"is_last": is_last,
|
| 631 |
}
|
| 632 |
|
|
|
|
| 676 |
token_ids: list[int] = np.asarray(tokens).tolist()
|
| 677 |
tokens = [t for t in token_ids if t not in eos_set]
|
| 678 |
|
| 679 |
+
decoded = self._tokenizer().decode(tokens, skip_special_tokens=True)
|
| 680 |
+
if not isinstance(decoded, str): # one sequence in, one string out
|
| 681 |
+
msg = f"tokenizer.decode returned {type(decoded).__name__}, expected str"
|
| 682 |
+
raise TypeError(msg)
|
| 683 |
text = decoded.strip()
|
| 684 |
# Strip <think>...</think> tags (Qwen3 doesn't respect /no_think prompt)
|
| 685 |
if "<think>" in text:
|
| 686 |
text = _THINK_TAG_RE.sub("", text).strip()
|
| 687 |
+
text = truncate_repetitions(text)
|
| 688 |
out: dict[str, Any] = {"text": text}
|
| 689 |
# Pass through per-step logprobs when _forward captured them (i.e. caller
|
| 690 |
# passed output_scores=True). Lets eval harnesses compute confidence
|
| 691 |
# stats without re-running the model.
|
| 692 |
+
for key in ("top1_logprob", "top2_logprob"):
|
| 693 |
+
if key in model_outputs:
|
| 694 |
+
out[key] = model_outputs[key]
|
|
|
|
| 695 |
return out
|
| 696 |
|
| 697 |
|
|
|
|
| 709 |
return kwargs.pop(name)
|
| 710 |
msg = f"expected exactly one {name!r} argument, got {len(args)} positional"
|
| 711 |
raise TypeError(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
asr_processing.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
| 1 |
"""Processor that turns raw audio (and optional text) into model inputs."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from collections.abc import Mapping, Sequence
|
|
|
|
| 4 |
from typing import TYPE_CHECKING, Any, ClassVar, cast, overload
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import numpy.typing as npt
|
| 8 |
import torch
|
|
|
|
| 9 |
import transformers
|
|
|
|
| 10 |
from torch.nn.utils.rnn import pad_sequence
|
| 11 |
from transformers import (
|
| 12 |
BatchFeature,
|
|
@@ -14,6 +20,13 @@ from transformers import (
|
|
| 14 |
ProcessorMixin,
|
| 15 |
SequenceFeatureExtractor,
|
| 16 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
if TYPE_CHECKING:
|
| 19 |
from .asr_config import (
|
|
@@ -250,6 +263,35 @@ def is_silent(audio: npt.NDArray[np.float32]) -> bool:
|
|
| 250 |
return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
class ASRProcessor(ProcessorMixin):
|
| 254 |
"""Processor for Whisper-based ASR models."""
|
| 255 |
|
|
@@ -258,12 +300,10 @@ class ASRProcessor(ProcessorMixin):
|
|
| 258 |
tokenizer: PreTrainedTokenizerBase
|
| 259 |
feature_extractor_class = "AutoFeatureExtractor"
|
| 260 |
tokenizer_class = "AutoTokenizer"
|
| 261 |
-
# Fallback only. The real value comes from `ASRConfig.audio_token`
|
| 262 |
-
#
|
| 263 |
-
#
|
| 264 |
-
#
|
| 265 |
-
# never added to that vocab, so it tokenizes into ordinary subwords and
|
| 266 |
-
# the prompt ends up with zero scatter positions for N audio embeddings.
|
| 267 |
AUDIO_TOKEN = "<audio>"
|
| 268 |
TRANSCRIBE_PROMPT = DEFAULT_TRANSCRIBE_PROMPT
|
| 269 |
|
|
@@ -275,6 +315,7 @@ class ASRProcessor(ProcessorMixin):
|
|
| 275 |
encoder_conv_layers: list[ConvLayerSpec] | None = None,
|
| 276 |
audio_token: str | None = None,
|
| 277 |
lead_in_seconds: float = 0.0,
|
|
|
|
| 278 |
):
|
| 279 |
"""Initialize the ASR processor.
|
| 280 |
|
|
@@ -286,14 +327,23 @@ class ASRProcessor(ProcessorMixin):
|
|
| 286 |
audio_token: Placeholder token scattered with audio embeddings.
|
| 287 |
Must match `ASRConfig.audio_token` / `ASRModel.audio_token`;
|
| 288 |
defaults to AUDIO_TOKEN.
|
|
|
|
|
|
|
| 289 |
"""
|
| 290 |
self.feature_extractor = feature_extractor
|
| 291 |
self.tokenizer = tokenizer
|
| 292 |
-
self.audio_token = audio_token or self.AUDIO_TOKEN
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
def _render_prompt(self, num_audio_tokens: int, text: str | None) -> torch.Tensor:
|
| 299 |
"""Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
|
|
@@ -301,10 +351,6 @@ class ASRProcessor(ProcessorMixin):
|
|
| 301 |
self.tokenizer, self.audio_token, num_audio_tokens, self.TRANSCRIBE_PROMPT, text
|
| 302 |
)
|
| 303 |
|
| 304 |
-
def _stack_prompt_rows(self, rows: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
|
| 305 |
-
"""Stack per-sample prompt rows into a batch (see `left_pad_prompt_rows`)."""
|
| 306 |
-
return left_pad_prompt_rows(rows, self.tokenizer)
|
| 307 |
-
|
| 308 |
def __call__(self, *args: Any, **kwargs: Any) -> BatchFeature:
|
| 309 |
"""Process audio and text inputs for inference; see `_process` for the arguments.
|
| 310 |
|
|
@@ -361,15 +407,17 @@ class ASRProcessor(ProcessorMixin):
|
|
| 361 |
# returns batch-1 `input_ids` against batch-B `input_features`, and
|
| 362 |
# gives every shorter row more `<audio>` placeholders than the
|
| 363 |
# projector produced for it. `masked_scatter` then mis-scatters
|
| 364 |
-
# silently. This is the same failure `
|
| 365 |
# documents as fixed on the model side, and it only shows up on a
|
| 366 |
# ragged batch, so batch-1 eval never sees it.
|
| 367 |
mel_lengths = audio_inputs["attention_mask"].sum(dim=-1).reshape(-1).long()
|
| 368 |
-
encoder_lengths = compute_encoder_output_length(
|
|
|
|
|
|
|
| 369 |
token_counts = self.projector.get_output_length(encoder_lengths).tolist()
|
| 370 |
|
| 371 |
rows = [self._render_prompt(n, text) for n in token_counts]
|
| 372 |
-
input_ids, attention_mask =
|
| 373 |
result["input_ids"] = input_ids
|
| 374 |
result["attention_mask"] = attention_mask
|
| 375 |
|
|
|
|
| 1 |
"""Processor that turns raw audio (and optional text) into model inputs."""
|
| 2 |
|
| 3 |
+
import importlib
|
| 4 |
+
import importlib.util
|
| 5 |
+
import io
|
| 6 |
from collections.abc import Mapping, Sequence
|
| 7 |
+
from pathlib import Path
|
| 8 |
from typing import TYPE_CHECKING, Any, ClassVar, cast, overload
|
| 9 |
|
| 10 |
import numpy as np
|
| 11 |
import numpy.typing as npt
|
| 12 |
import torch
|
| 13 |
+
import torchaudio
|
| 14 |
import transformers
|
| 15 |
+
from huggingface_hub.utils import httpx
|
| 16 |
from torch.nn.utils.rnn import pad_sequence
|
| 17 |
from transformers import (
|
| 18 |
BatchFeature,
|
|
|
|
| 20 |
ProcessorMixin,
|
| 21 |
SequenceFeatureExtractor,
|
| 22 |
)
|
| 23 |
+
from transformers.pipelines.audio_utils import ffmpeg_read
|
| 24 |
+
|
| 25 |
+
# soundfile is optional: it arrives with librosa, which the README installs, but
|
| 26 |
+
# it is not in the model's requirements.txt, and transformers refuses to load a
|
| 27 |
+
# remote-code file with a plain `import` of a missing package. Without it every
|
| 28 |
+
# input goes to ffmpeg.
|
| 29 |
+
_soundfile = importlib.import_module("soundfile") if importlib.util.find_spec("soundfile") else None
|
| 30 |
|
| 31 |
if TYPE_CHECKING:
|
| 32 |
from .asr_config import (
|
|
|
|
| 263 |
return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
|
| 264 |
|
| 265 |
|
| 266 |
+
def decode_audio_input(inputs: str | bytes) -> npt.NDArray[np.float32]:
|
| 267 |
+
"""A URL, file path or encoded bytes to mono float32 at 16 kHz.
|
| 268 |
+
|
| 269 |
+
soundfile first: its bundled libsndfile reads WAV, FLAC, OGG and MP3 with
|
| 270 |
+
nothing else installed, so a fresh `pip install` can transcribe a file.
|
| 271 |
+
ffmpeg (a system binary the user may not have) only for what it can't
|
| 272 |
+
read, such as m4a or a video's audio track, or when soundfile is missing.
|
| 273 |
+
URLs are fetched the way transformers' own ASR pipeline fetches them.
|
| 274 |
+
"""
|
| 275 |
+
if isinstance(inputs, bytes):
|
| 276 |
+
data = inputs
|
| 277 |
+
elif inputs.startswith(("http://", "https://")):
|
| 278 |
+
response = httpx.get(inputs, follow_redirects=True)
|
| 279 |
+
response.raise_for_status()
|
| 280 |
+
data = response.content
|
| 281 |
+
else:
|
| 282 |
+
data = Path(inputs).read_bytes()
|
| 283 |
+
try:
|
| 284 |
+
if _soundfile is None:
|
| 285 |
+
return ffmpeg_read(data, sampling_rate=16000)
|
| 286 |
+
array, sr = _soundfile.read(io.BytesIO(data), dtype="float32", always_2d=True)
|
| 287 |
+
except RuntimeError: # LibsndfileError: a format libsndfile can't read
|
| 288 |
+
return ffmpeg_read(data, sampling_rate=16000)
|
| 289 |
+
mono = torch.from_numpy(array.mean(axis=1))
|
| 290 |
+
if sr != 16000:
|
| 291 |
+
mono = torchaudio.functional.resample(mono, orig_freq=sr, new_freq=16000)
|
| 292 |
+
return mono.numpy()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
class ASRProcessor(ProcessorMixin):
|
| 296 |
"""Processor for Whisper-based ASR models."""
|
| 297 |
|
|
|
|
| 300 |
tokenizer: PreTrainedTokenizerBase
|
| 301 |
feature_extractor_class = "AutoFeatureExtractor"
|
| 302 |
tokenizer_class = "AutoTokenizer"
|
| 303 |
+
# Fallback only. The real value comes from `ASRConfig.audio_token`; a
|
| 304 |
+
# config naming a different token must win, or "<audio>" -- never added to
|
| 305 |
+
# that vocab -- tokenizes into ordinary subwords and the prompt ends up
|
| 306 |
+
# with zero scatter positions for N audio embeddings.
|
|
|
|
|
|
|
| 307 |
AUDIO_TOKEN = "<audio>"
|
| 308 |
TRANSCRIBE_PROMPT = DEFAULT_TRANSCRIBE_PROMPT
|
| 309 |
|
|
|
|
| 315 |
encoder_conv_layers: list[ConvLayerSpec] | None = None,
|
| 316 |
audio_token: str | None = None,
|
| 317 |
lead_in_seconds: float = 0.0,
|
| 318 |
+
encoder_chunk_frames: int = 0,
|
| 319 |
):
|
| 320 |
"""Initialize the ASR processor.
|
| 321 |
|
|
|
|
| 327 |
audio_token: Placeholder token scattered with audio embeddings.
|
| 328 |
Must match `ASRConfig.audio_token` / `ASRModel.audio_token`;
|
| 329 |
defaults to AUDIO_TOKEN.
|
| 330 |
+
lead_in_seconds: Silence prepended to every clip.
|
| 331 |
+
encoder_chunk_frames: `ASRConfig.encoder_chunk_frames` (0: no chunking).
|
| 332 |
"""
|
| 333 |
self.feature_extractor = feature_extractor
|
| 334 |
self.tokenizer = tokenizer
|
| 335 |
+
self.audio_token: str = audio_token or self.AUDIO_TOKEN
|
| 336 |
+
token_id = tokenizer.convert_tokens_to_ids(self.audio_token)
|
| 337 |
+
if not isinstance(token_id, int): # a str in always maps to one id
|
| 338 |
+
msg = f"audio token {self.audio_token!r} mapped to {token_id!r}, not one id"
|
| 339 |
+
raise TypeError(msg)
|
| 340 |
+
self.audio_token_id: int = token_id
|
| 341 |
+
self.projector: MLPAudioProjector | None = projector
|
| 342 |
+
self.encoder_conv_layers: list[ConvLayerSpec] = (
|
| 343 |
+
encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
|
| 344 |
+
)
|
| 345 |
+
self.lead_in_seconds: float = float(lead_in_seconds)
|
| 346 |
+
self.encoder_chunk_frames: int = encoder_chunk_frames
|
| 347 |
|
| 348 |
def _render_prompt(self, num_audio_tokens: int, text: str | None) -> torch.Tensor:
|
| 349 |
"""Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
|
|
|
|
| 351 |
self.tokenizer, self.audio_token, num_audio_tokens, self.TRANSCRIBE_PROMPT, text
|
| 352 |
)
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
def __call__(self, *args: Any, **kwargs: Any) -> BatchFeature:
|
| 355 |
"""Process audio and text inputs for inference; see `_process` for the arguments.
|
| 356 |
|
|
|
|
| 407 |
# returns batch-1 `input_ids` against batch-B `input_features`, and
|
| 408 |
# gives every shorter row more `<audio>` placeholders than the
|
| 409 |
# projector produced for it. `masked_scatter` then mis-scatters
|
| 410 |
+
# silently. This is the same failure `prepare_audio_inputs`
|
| 411 |
# documents as fixed on the model side, and it only shows up on a
|
| 412 |
# ragged batch, so batch-1 eval never sees it.
|
| 413 |
mel_lengths = audio_inputs["attention_mask"].sum(dim=-1).reshape(-1).long()
|
| 414 |
+
encoder_lengths = compute_encoder_output_length(
|
| 415 |
+
mel_lengths, self.encoder_conv_layers, self.encoder_chunk_frames
|
| 416 |
+
)
|
| 417 |
token_counts = self.projector.get_output_length(encoder_lengths).tolist()
|
| 418 |
|
| 419 |
rows = [self._render_prompt(n, text) for n in token_counts]
|
| 420 |
+
input_ids, attention_mask = left_pad_prompt_rows(rows, self.tokenizer)
|
| 421 |
result["input_ids"] = input_ids
|
| 422 |
result["attention_mask"] = attention_mask
|
| 423 |
|
asr_text.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transcript text cleanup: collapse the repetition loops a decoder can fall into.
|
| 2 |
+
|
| 3 |
+
A language-model decoder that loses the audio sometimes repeats its last
|
| 4 |
+
character, word or phrase until it hits the token limit. These functions trim
|
| 5 |
+
such a trailing run back to one copy; ASRPipeline applies them to every output.
|
| 6 |
+
`truncate_runaway_loop` also catches a loop that starts mid-transcript and
|
| 7 |
+
drifts ("It's It's It it's It's"), so it never forms an exact trailing run.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
MIN_REPEATS = 3
|
| 13 |
+
LOOSE_MIN_REPEATS = 6
|
| 14 |
+
TRAILING_CHAR_RE = re.compile(rf"(.)\1{{{MIN_REPEATS - 1},}}$")
|
| 15 |
+
TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
|
| 16 |
+
NON_WORD_RE = re.compile(r"\W+")
|
| 17 |
+
# A 1-4 word unit repeated this many times in a row, after which at most this
|
| 18 |
+
# share of the words are distinct: the decoder lost the audio and never came
|
| 19 |
+
# back. Measured on the Open ASR leaderboard runs of 2026-10-16/17: URGENT
|
| 20 |
+
# -0.97 WER (13.12 -> 12.11 on an independent rerun), and no set worse in
|
| 21 |
+
# either run. Cutting at every 6x run regardless of what follows cost up to
|
| 22 |
+
# +0.05 (Monsoon, GigaSpeech): "No, no, no, no, no, no, no, sir, which..." is
|
| 23 |
+
# real speech that recovers.
|
| 24 |
+
RUNAWAY_MIN_REPEATS = 6
|
| 25 |
+
RUNAWAY_MAX_UNIT = 4
|
| 26 |
+
RUNAWAY_MAX_DISTINCT = 0.5
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def truncate_repetitions(text: str) -> str:
|
| 30 |
+
"""Truncate repeated words/phrases/characters at end of text.
|
| 31 |
+
|
| 32 |
+
Detects patterns like:
|
| 33 |
+
- Repeated words: "the the the the" -> "the"
|
| 34 |
+
- Repeated phrases: "i am sorry i am sorry i am sorry" -> "i am sorry"
|
| 35 |
+
- Repeated characters: "444444" -> "4"
|
| 36 |
+
|
| 37 |
+
Words match ignoring case. A run of `LOOSE_MIN_REPEATS` also matches
|
| 38 |
+
ignoring punctuation, since the model punctuates inside a loop ("Yeah Yeah
|
| 39 |
+
Yeah. Yeah. Yeah,"); shorter punctuated runs are real speech ("No, no,
|
| 40 |
+
no,"), and collapsing those cost 40 errors over 47 rows of the served eval.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
text: Input text to process
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Text with trailing repetitions removed
|
| 47 |
+
"""
|
| 48 |
+
if not text:
|
| 49 |
+
return text
|
| 50 |
+
|
| 51 |
+
text = truncate_runaway_loop(text)
|
| 52 |
+
text = TRAILING_CHAR_RE.sub(r"\1", text)
|
| 53 |
+
while TRAILING_WORD_RE.search(text):
|
| 54 |
+
text = TRAILING_WORD_RE.sub(r"\1", text)
|
| 55 |
+
|
| 56 |
+
# 3. Truncate repeated phrases (2-20 words) at end
|
| 57 |
+
# e.g., "i am sorry i am sorry i am sorry" -> "i am sorry"
|
| 58 |
+
words = text.split()
|
| 59 |
+
if len(words) < MIN_REPEATS * 2:
|
| 60 |
+
return text
|
| 61 |
+
|
| 62 |
+
# Cheap pre-check: trailing window must contain duplicates for any phrase repeat
|
| 63 |
+
# to be possible. set(window) == window means all unique → no repetition.
|
| 64 |
+
window = words[-MIN_REPEATS * 2 :]
|
| 65 |
+
if len(set(window)) == len(window):
|
| 66 |
+
return truncate_punctuated_loop(text)
|
| 67 |
+
|
| 68 |
+
for phrase_len in range(2, min(21, len(words) // MIN_REPEATS + 1)):
|
| 69 |
+
phrase_escaped = re.escape(" ".join(words[-phrase_len:]))
|
| 70 |
+
phrase_pattern = re.compile(
|
| 71 |
+
rf"(^|.*?\s)({phrase_escaped})(?:\s+{phrase_escaped}){{{MIN_REPEATS - 1},}}\s*$",
|
| 72 |
+
re.IGNORECASE,
|
| 73 |
+
)
|
| 74 |
+
match = phrase_pattern.match(text)
|
| 75 |
+
if match:
|
| 76 |
+
text = (match.group(1) + match.group(2)).strip()
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
return truncate_punctuated_loop(text)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def truncate_punctuated_loop(text: str) -> str:
|
| 83 |
+
"""Collapse a trailing 1-20 word phrase run `LOOSE_MIN_REPEATS`+ times, punctuation ignored."""
|
| 84 |
+
words = text.split()
|
| 85 |
+
keys = [NON_WORD_RE.sub("", w).lower() for w in words]
|
| 86 |
+
end = len(words)
|
| 87 |
+
# Shortest phrase first, so "Yeah Yeah. Yeah," stops at "Yeah".
|
| 88 |
+
for n in range(1, min(21, end // LOOSE_MIN_REPEATS + 1)):
|
| 89 |
+
phrase = keys[end - n : end]
|
| 90 |
+
reps = 1
|
| 91 |
+
while end - (reps + 1) * n >= 0 and keys[end - (reps + 1) * n : end - reps * n] == phrase:
|
| 92 |
+
reps += 1
|
| 93 |
+
if reps >= LOOSE_MIN_REPEATS:
|
| 94 |
+
return " ".join(words[: end - (reps - 1) * n])
|
| 95 |
+
return text
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def truncate_runaway_loop(text: str) -> str:
|
| 99 |
+
"""Cut at a loop the decoder never leaves, keeping the first copy of its unit.
|
| 100 |
+
|
| 101 |
+
Finds the first 1-`RUNAWAY_MAX_UNIT` word unit repeated
|
| 102 |
+
`RUNAWAY_MIN_REPEATS`+ times in a row (case and punctuation ignored). It
|
| 103 |
+
cuts there only when the rest of the transcript, from that run on, has at
|
| 104 |
+
most `RUNAWAY_MAX_DISTINCT` distinct words per word: a loop that drifts
|
| 105 |
+
("It's It's It it's It's ...") rather than speech that moves on.
|
| 106 |
+
"""
|
| 107 |
+
words = text.split()
|
| 108 |
+
keys = [NON_WORD_RE.sub("", w).lower() for w in words]
|
| 109 |
+
for start in range(len(keys)):
|
| 110 |
+
for size in range(1, RUNAWAY_MAX_UNIT + 1):
|
| 111 |
+
unit = keys[start : start + size]
|
| 112 |
+
if len(unit) < size or not all(unit):
|
| 113 |
+
break
|
| 114 |
+
reps = 1
|
| 115 |
+
while keys[start + reps * size : start + (reps + 1) * size] == unit:
|
| 116 |
+
reps += 1
|
| 117 |
+
if reps < RUNAWAY_MIN_REPEATS:
|
| 118 |
+
continue
|
| 119 |
+
rest = keys[start:]
|
| 120 |
+
if len(set(rest)) / len(rest) > RUNAWAY_MAX_DISTINCT:
|
| 121 |
+
return text # the transcript recovers after the first run
|
| 122 |
+
return " ".join(words[: start + size])
|
| 123 |
+
return text
|
chat_template.jinja
CHANGED
|
@@ -1,90 +1,34 @@
|
|
| 1 |
-
{%-
|
| 2 |
-
{%- set video_count = namespace(value=0) %}
|
| 3 |
-
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
| 4 |
-
{%- if content is string %}
|
| 5 |
-
{{- content }}
|
| 6 |
-
{%- elif content is iterable and content is not mapping %}
|
| 7 |
-
{%- for item in content %}
|
| 8 |
-
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
|
| 9 |
-
{%- if is_system_content %}
|
| 10 |
-
{{- raise_exception('System message cannot contain images.') }}
|
| 11 |
-
{%- endif %}
|
| 12 |
-
{%- if do_vision_count %}
|
| 13 |
-
{%- set image_count.value = image_count.value + 1 %}
|
| 14 |
-
{%- endif %}
|
| 15 |
-
{%- if add_vision_id %}
|
| 16 |
-
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
| 17 |
-
{%- endif %}
|
| 18 |
-
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
| 19 |
-
{%- elif 'video' in item or item.type == 'video' %}
|
| 20 |
-
{%- if is_system_content %}
|
| 21 |
-
{{- raise_exception('System message cannot contain videos.') }}
|
| 22 |
-
{%- endif %}
|
| 23 |
-
{%- if do_vision_count %}
|
| 24 |
-
{%- set video_count.value = video_count.value + 1 %}
|
| 25 |
-
{%- endif %}
|
| 26 |
-
{%- if add_vision_id %}
|
| 27 |
-
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
| 28 |
-
{%- endif %}
|
| 29 |
-
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
| 30 |
-
{%- elif 'text' in item %}
|
| 31 |
-
{{- item.text }}
|
| 32 |
-
{%- else %}
|
| 33 |
-
{{- raise_exception('Unexpected item type in content.') }}
|
| 34 |
-
{%- endif %}
|
| 35 |
-
{%- endfor %}
|
| 36 |
-
{%- elif content is none or content is undefined %}
|
| 37 |
-
{{- '' }}
|
| 38 |
-
{%- else %}
|
| 39 |
-
{{- raise_exception('Unexpected content type.') }}
|
| 40 |
-
{%- endif %}
|
| 41 |
-
{%- endmacro %}
|
| 42 |
-
{%- if not messages %}
|
| 43 |
-
{{- raise_exception('No messages provided.') }}
|
| 44 |
-
{%- endif %}
|
| 45 |
-
{%- if tools and tools is iterable and tools is not mapping %}
|
| 46 |
{{- '<|im_start|>system\n' }}
|
| 47 |
-
{
|
|
|
|
|
|
|
|
|
|
| 48 |
{%- for tool in tools %}
|
| 49 |
{{- "\n" }}
|
| 50 |
{{- tool | tojson }}
|
| 51 |
{%- endfor %}
|
| 52 |
-
{{- "\n</tools>" }}
|
| 53 |
-
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
|
| 54 |
-
{%- if messages[0].role == 'system' %}
|
| 55 |
-
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
| 56 |
-
{%- if content %}
|
| 57 |
-
{{- '\n\n' + content }}
|
| 58 |
-
{%- endif %}
|
| 59 |
-
{%- endif %}
|
| 60 |
-
{{- '<|im_end|>\n' }}
|
| 61 |
{%- else %}
|
| 62 |
{%- if messages[0].role == 'system' %}
|
| 63 |
-
{
|
| 64 |
-
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
|
| 65 |
{%- endif %}
|
| 66 |
{%- endif %}
|
| 67 |
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
| 68 |
{%- for message in messages[::-1] %}
|
| 69 |
{%- set index = (messages|length - 1) - loop.index0 %}
|
| 70 |
-
{%- if ns.multi_step_tool and message.role == "user" %}
|
| 71 |
-
{%- set
|
| 72 |
-
{%-
|
| 73 |
-
{%- set ns.multi_step_tool = false %}
|
| 74 |
-
{%- set ns.last_query_index = index %}
|
| 75 |
-
{%- endif %}
|
| 76 |
{%- endif %}
|
| 77 |
{%- endfor %}
|
| 78 |
-
{%- if ns.multi_step_tool %}
|
| 79 |
-
{{- raise_exception('No user query found in messages.') }}
|
| 80 |
-
{%- endif %}
|
| 81 |
{%- for message in messages %}
|
| 82 |
-
{%-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
{%-
|
| 88 |
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
| 89 |
{%- elif message.role == "assistant" %}
|
| 90 |
{%- set reasoning_content = '' %}
|
|
@@ -96,59 +40,50 @@
|
|
| 96 |
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
|
| 97 |
{%- endif %}
|
| 98 |
{%- endif %}
|
| 99 |
-
{%- set reasoning_content = reasoning_content|trim %}
|
| 100 |
{%- if loop.index0 > ns.last_query_index %}
|
| 101 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
{%- else %}
|
| 103 |
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 104 |
{%- endif %}
|
| 105 |
-
{%- if message.tool_calls
|
| 106 |
{%- for tool_call in message.tool_calls %}
|
| 107 |
-
{%- if
|
|
|
|
|
|
|
|
|
|
| 108 |
{%- set tool_call = tool_call.function %}
|
| 109 |
{%- endif %}
|
| 110 |
-
{
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
{%- endif %}
|
| 116 |
{%- else %}
|
| 117 |
-
{{-
|
| 118 |
-
{%- endif %}
|
| 119 |
-
{%- if tool_call.arguments is defined %}
|
| 120 |
-
{%- for args_name, args_value in tool_call.arguments|items %}
|
| 121 |
-
{{- '<parameter=' + args_name + '>\n' }}
|
| 122 |
-
{%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
|
| 123 |
-
{{- args_value }}
|
| 124 |
-
{{- '\n</parameter>\n' }}
|
| 125 |
-
{%- endfor %}
|
| 126 |
{%- endif %}
|
| 127 |
-
{{- '
|
| 128 |
{%- endfor %}
|
| 129 |
{%- endif %}
|
| 130 |
{{- '<|im_end|>\n' }}
|
| 131 |
{%- elif message.role == "tool" %}
|
| 132 |
-
{%- if loop.
|
| 133 |
{{- '<|im_start|>user' }}
|
| 134 |
{%- endif %}
|
| 135 |
{{- '\n<tool_response>\n' }}
|
| 136 |
{{- content }}
|
| 137 |
{{- '\n</tool_response>' }}
|
| 138 |
-
{%- if
|
| 139 |
-
{{- '<|im_end|>\n' }}
|
| 140 |
-
{%- elif loop.last %}
|
| 141 |
{{- '<|im_end|>\n' }}
|
| 142 |
{%- endif %}
|
| 143 |
-
{%- else %}
|
| 144 |
-
{{- raise_exception('Unexpected message role.') }}
|
| 145 |
{%- endif %}
|
| 146 |
{%- endfor %}
|
| 147 |
{%- if add_generation_prompt %}
|
| 148 |
{{- '<|im_start|>assistant\n' }}
|
| 149 |
-
{%- if
|
| 150 |
-
{{- '<think>\n' }}
|
| 151 |
-
{%- else %}
|
| 152 |
{{- '<think>\n\n</think>\n\n' }}
|
| 153 |
{%- endif %}
|
| 154 |
{%- endif %}
|
|
|
|
| 1 |
+
{%- if tools %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
{{- '<|im_start|>system\n' }}
|
| 3 |
+
{%- if messages[0].role == 'system' %}
|
| 4 |
+
{{- messages[0].content + '\n\n' }}
|
| 5 |
+
{%- endif %}
|
| 6 |
+
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
| 7 |
{%- for tool in tools %}
|
| 8 |
{{- "\n" }}
|
| 9 |
{{- tool | tojson }}
|
| 10 |
{%- endfor %}
|
| 11 |
+
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
{%- else %}
|
| 13 |
{%- if messages[0].role == 'system' %}
|
| 14 |
+
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
|
|
|
|
| 15 |
{%- endif %}
|
| 16 |
{%- endif %}
|
| 17 |
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
| 18 |
{%- for message in messages[::-1] %}
|
| 19 |
{%- set index = (messages|length - 1) - loop.index0 %}
|
| 20 |
+
{%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
|
| 21 |
+
{%- set ns.multi_step_tool = false %}
|
| 22 |
+
{%- set ns.last_query_index = index %}
|
|
|
|
|
|
|
|
|
|
| 23 |
{%- endif %}
|
| 24 |
{%- endfor %}
|
|
|
|
|
|
|
|
|
|
| 25 |
{%- for message in messages %}
|
| 26 |
+
{%- if message.content is string %}
|
| 27 |
+
{%- set content = message.content %}
|
| 28 |
+
{%- else %}
|
| 29 |
+
{%- set content = '' %}
|
| 30 |
+
{%- endif %}
|
| 31 |
+
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
|
| 32 |
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
| 33 |
{%- elif message.role == "assistant" %}
|
| 34 |
{%- set reasoning_content = '' %}
|
|
|
|
| 40 |
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
|
| 41 |
{%- endif %}
|
| 42 |
{%- endif %}
|
|
|
|
| 43 |
{%- if loop.index0 > ns.last_query_index %}
|
| 44 |
+
{%- if loop.last or (not loop.last and reasoning_content) %}
|
| 45 |
+
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
|
| 46 |
+
{%- else %}
|
| 47 |
+
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 48 |
+
{%- endif %}
|
| 49 |
{%- else %}
|
| 50 |
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 51 |
{%- endif %}
|
| 52 |
+
{%- if message.tool_calls %}
|
| 53 |
{%- for tool_call in message.tool_calls %}
|
| 54 |
+
{%- if (loop.first and content) or (not loop.first) %}
|
| 55 |
+
{{- '\n' }}
|
| 56 |
+
{%- endif %}
|
| 57 |
+
{%- if tool_call.function %}
|
| 58 |
{%- set tool_call = tool_call.function %}
|
| 59 |
{%- endif %}
|
| 60 |
+
{{- '<tool_call>\n{"name": "' }}
|
| 61 |
+
{{- tool_call.name }}
|
| 62 |
+
{{- '", "arguments": ' }}
|
| 63 |
+
{%- if tool_call.arguments is string %}
|
| 64 |
+
{{- tool_call.arguments }}
|
|
|
|
| 65 |
{%- else %}
|
| 66 |
+
{{- tool_call.arguments | tojson }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
{%- endif %}
|
| 68 |
+
{{- '}\n</tool_call>' }}
|
| 69 |
{%- endfor %}
|
| 70 |
{%- endif %}
|
| 71 |
{{- '<|im_end|>\n' }}
|
| 72 |
{%- elif message.role == "tool" %}
|
| 73 |
+
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
|
| 74 |
{{- '<|im_start|>user' }}
|
| 75 |
{%- endif %}
|
| 76 |
{{- '\n<tool_response>\n' }}
|
| 77 |
{{- content }}
|
| 78 |
{{- '\n</tool_response>' }}
|
| 79 |
+
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
|
|
|
|
|
|
|
| 80 |
{{- '<|im_end|>\n' }}
|
| 81 |
{%- endif %}
|
|
|
|
|
|
|
| 82 |
{%- endif %}
|
| 83 |
{%- endfor %}
|
| 84 |
{%- if add_generation_prompt %}
|
| 85 |
{{- '<|im_start|>assistant\n' }}
|
| 86 |
+
{%- if true %}
|
|
|
|
|
|
|
| 87 |
{{- '<think>\n\n</think>\n\n' }}
|
| 88 |
{%- endif %}
|
| 89 |
{%- endif %}
|
config.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
| 1 |
{
|
| 2 |
-
"apply_spec_augment": false,
|
| 3 |
"architectures": [
|
| 4 |
"ASRModel"
|
| 5 |
],
|
| 6 |
-
"attn_implementation": "
|
| 7 |
"audio_config": {
|
| 8 |
-
"_name_or_path": "ibm-granite/granite-speech-5.0-470m-turboctc",
|
| 9 |
"architectures": [
|
| 10 |
"GraniteSpeech5ForCTC"
|
| 11 |
],
|
| 12 |
"ctc_loss_reduction": "mean",
|
| 13 |
"ctc_zero_infinity": true,
|
| 14 |
-
"dtype": "
|
| 15 |
"encoder_config": {
|
| 16 |
"_name_or_path": "",
|
| 17 |
"activation_dropout": 0.0,
|
|
@@ -60,14 +59,13 @@
|
|
| 60 |
"tie_word_embeddings": true,
|
| 61 |
"vocab_size": 16384
|
| 62 |
},
|
| 63 |
-
"
|
| 64 |
-
"audio_model_id": "ibm-granite/granite-speech-5.0-470m-turboctc",
|
| 65 |
"audio_sample_rate": 16000,
|
| 66 |
"audio_token": "<audio>",
|
| 67 |
"auto_map": {
|
| 68 |
"AutoConfig": "asr_config.ASRConfig",
|
| 69 |
-
"AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
|
| 70 |
"AutoModel": "asr_modeling.ASRModel",
|
|
|
|
| 71 |
"AutoProcessor": "asr_processing.ASRProcessor"
|
| 72 |
},
|
| 73 |
"custom_pipelines": {
|
|
@@ -82,13 +80,13 @@
|
|
| 82 |
},
|
| 83 |
"dtype": "bfloat16",
|
| 84 |
"encoder": {
|
| 85 |
-
"_name_or_path": "ibm-granite/granite-speech-5.0-470m-turboctc",
|
| 86 |
"architectures": [
|
| 87 |
"GraniteSpeech5ForCTC"
|
| 88 |
],
|
| 89 |
"ctc_loss_reduction": "mean",
|
| 90 |
"ctc_zero_infinity": true,
|
| 91 |
-
"dtype": "
|
| 92 |
"encoder_config": {
|
| 93 |
"_name_or_path": "",
|
| 94 |
"activation_dropout": 0.0,
|
|
@@ -138,6 +136,7 @@
|
|
| 138 |
"vocab_size": 16384
|
| 139 |
},
|
| 140 |
"encoder_attention_mask": true,
|
|
|
|
| 141 |
"encoder_conv_layers": [
|
| 142 |
[
|
| 143 |
0,
|
|
@@ -154,179 +153,94 @@
|
|
| 154 |
"encoder_dtype": "bfloat16",
|
| 155 |
"encoder_trainable_post_projections": false,
|
| 156 |
"encoder_trainable_top_layers": 0,
|
| 157 |
-
"eos_token_id": 248046,
|
| 158 |
"freeze_audio_encoder": true,
|
| 159 |
-
"freeze_language_model":
|
| 160 |
"freeze_projector": false,
|
| 161 |
-
"freeze_text_embed_tokens":
|
|
|
|
| 162 |
"inference_lead_in_seconds": 0.25,
|
| 163 |
-
"
|
| 164 |
-
"llm_dim": 2048,
|
| 165 |
"lora_alpha": 64,
|
| 166 |
-
"lora_alpha_pattern": {},
|
| 167 |
"lora_dropout": 0.0,
|
| 168 |
"lora_rank": 64,
|
| 169 |
-
"lora_rank_pattern": {},
|
| 170 |
"lora_target_modules": "all-linear",
|
| 171 |
-
"mask_time_length": 10,
|
| 172 |
-
"mask_time_min_masks": 2,
|
| 173 |
-
"mask_time_prob": 0.05,
|
| 174 |
"max_new_tokens": 256,
|
| 175 |
"model_dtype": "bfloat16",
|
| 176 |
"model_type": "asr_model",
|
| 177 |
"no_repeat_ngram_size": 12,
|
| 178 |
-
"
|
| 179 |
"pipeline_tag": "automatic-speech-recognition",
|
| 180 |
"pretrained_model_path": "mazesmazes/tiny-audio",
|
| 181 |
"projector_dtype": "float32",
|
| 182 |
"projector_hidden_dim": 4096,
|
| 183 |
"projector_pool_stride": 1,
|
| 184 |
-
"projector_type": "mlp",
|
| 185 |
"text_config": {
|
| 186 |
-
"_name_or_path": "Qwen/Qwen3
|
| 187 |
"architectures": [
|
| 188 |
-
"
|
| 189 |
],
|
|
|
|
|
|
|
| 190 |
"bos_token_id": null,
|
| 191 |
-
"dtype": "
|
| 192 |
-
"eos_token_id":
|
| 193 |
-
"
|
| 194 |
-
"
|
| 195 |
-
"
|
| 196 |
-
"
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
"
|
| 200 |
-
"
|
| 201 |
-
"
|
| 202 |
-
"
|
| 203 |
-
"
|
| 204 |
-
"
|
| 205 |
-
"
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
"
|
| 210 |
-
"
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
"
|
| 215 |
-
"
|
| 216 |
-
"
|
| 217 |
-
"
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
"
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
"linear_attention",
|
| 240 |
-
"linear_attention",
|
| 241 |
-
"full_attention",
|
| 242 |
-
"linear_attention",
|
| 243 |
-
"linear_attention",
|
| 244 |
-
"linear_attention",
|
| 245 |
-
"full_attention"
|
| 246 |
-
],
|
| 247 |
-
"linear_conv_kernel_dim": 4,
|
| 248 |
-
"linear_key_head_dim": 128,
|
| 249 |
-
"linear_num_key_heads": 16,
|
| 250 |
-
"linear_num_value_heads": 16,
|
| 251 |
-
"linear_value_head_dim": 128,
|
| 252 |
-
"mamba_ssm_dtype": "float32",
|
| 253 |
-
"max_position_embeddings": 262144,
|
| 254 |
-
"mlp_only_layers": [],
|
| 255 |
-
"model_type": "qwen3_5_text",
|
| 256 |
-
"mtp_num_hidden_layers": 1,
|
| 257 |
-
"mtp_use_dedicated_embeddings": false,
|
| 258 |
-
"num_attention_heads": 8,
|
| 259 |
-
"num_hidden_layers": 24,
|
| 260 |
-
"num_key_value_heads": 2,
|
| 261 |
-
"output_attentions": false,
|
| 262 |
-
"output_hidden_states": false,
|
| 263 |
-
"pad_token_id": null,
|
| 264 |
-
"partial_rotary_factor": 0.25,
|
| 265 |
-
"problem_type": null,
|
| 266 |
-
"return_dict": true,
|
| 267 |
-
"rms_norm_eps": 1e-06,
|
| 268 |
-
"rope_parameters": {
|
| 269 |
-
"mrope_interleaved": true,
|
| 270 |
-
"mrope_section": [
|
| 271 |
-
11,
|
| 272 |
-
11,
|
| 273 |
-
10
|
| 274 |
-
],
|
| 275 |
-
"partial_rotary_factor": 0.25,
|
| 276 |
-
"rope_theta": 10000000,
|
| 277 |
-
"rope_type": "default"
|
| 278 |
-
},
|
| 279 |
-
"tie_word_embeddings": true,
|
| 280 |
-
"use_cache": true,
|
| 281 |
-
"vocab_size": 248320
|
| 282 |
},
|
|
|
|
| 283 |
"tie_word_embeddings": true,
|
| 284 |
-
"
|
| 285 |
-
"
|
| 286 |
-
|
| 287 |
-
"architectures": null,
|
| 288 |
-
"chunk_size_feed_forward": 0,
|
| 289 |
-
"deepstack_visual_indexes": [],
|
| 290 |
-
"depth": 24,
|
| 291 |
-
"dtype": null,
|
| 292 |
-
"hidden_act": "gelu_pytorch_tanh",
|
| 293 |
-
"hidden_size": 1024,
|
| 294 |
-
"id2label": {
|
| 295 |
-
"0": "LABEL_0",
|
| 296 |
-
"1": "LABEL_1"
|
| 297 |
-
},
|
| 298 |
-
"in_channels": 3,
|
| 299 |
-
"initializer_range": 0.02,
|
| 300 |
-
"intermediate_size": 4096,
|
| 301 |
-
"is_encoder_decoder": false,
|
| 302 |
-
"label2id": {
|
| 303 |
-
"LABEL_0": 0,
|
| 304 |
-
"LABEL_1": 1
|
| 305 |
-
},
|
| 306 |
-
"model_type": "qwen3_5_vision",
|
| 307 |
-
"num_heads": 16,
|
| 308 |
-
"num_position_embeddings": 2304,
|
| 309 |
-
"out_hidden_size": 2048,
|
| 310 |
-
"output_attentions": false,
|
| 311 |
-
"output_hidden_states": false,
|
| 312 |
-
"patch_size": 16,
|
| 313 |
-
"problem_type": null,
|
| 314 |
-
"return_dict": true,
|
| 315 |
-
"rope_parameters": {
|
| 316 |
-
"rope_theta": 10000.0,
|
| 317 |
-
"rope_type": "axial"
|
| 318 |
-
},
|
| 319 |
-
"spatial_merge_size": 2,
|
| 320 |
-
"temporal_patch_size": 2
|
| 321 |
-
},
|
| 322 |
-
"vision_end_token_id": 248054,
|
| 323 |
-
"vision_start_token_id": 248053,
|
| 324 |
-
"vocab_size": 248192
|
| 325 |
},
|
| 326 |
-
"text_model_id": "Qwen/Qwen3
|
| 327 |
"transcribe_prompt": "Transcribe the speech with proper punctuation and capitalization",
|
| 328 |
-
"transformers_version": "5.
|
| 329 |
"use_cache": true,
|
| 330 |
-
"use_lora":
|
| 331 |
-
"vocab_size":
|
| 332 |
}
|
|
|
|
| 1 |
{
|
|
|
|
| 2 |
"architectures": [
|
| 3 |
"ASRModel"
|
| 4 |
],
|
| 5 |
+
"attn_implementation": "flash_attention_2",
|
| 6 |
"audio_config": {
|
| 7 |
+
"_name_or_path": "ibm-granite/granite-speech-5.0-470m-turboctc-nc",
|
| 8 |
"architectures": [
|
| 9 |
"GraniteSpeech5ForCTC"
|
| 10 |
],
|
| 11 |
"ctc_loss_reduction": "mean",
|
| 12 |
"ctc_zero_infinity": true,
|
| 13 |
+
"dtype": "float32",
|
| 14 |
"encoder_config": {
|
| 15 |
"_name_or_path": "",
|
| 16 |
"activation_dropout": 0.0,
|
|
|
|
| 59 |
"tie_word_embeddings": true,
|
| 60 |
"vocab_size": 16384
|
| 61 |
},
|
| 62 |
+
"audio_model_id": "ibm-granite/granite-speech-5.0-470m-turboctc-nc",
|
|
|
|
| 63 |
"audio_sample_rate": 16000,
|
| 64 |
"audio_token": "<audio>",
|
| 65 |
"auto_map": {
|
| 66 |
"AutoConfig": "asr_config.ASRConfig",
|
|
|
|
| 67 |
"AutoModel": "asr_modeling.ASRModel",
|
| 68 |
+
"AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
|
| 69 |
"AutoProcessor": "asr_processing.ASRProcessor"
|
| 70 |
},
|
| 71 |
"custom_pipelines": {
|
|
|
|
| 80 |
},
|
| 81 |
"dtype": "bfloat16",
|
| 82 |
"encoder": {
|
| 83 |
+
"_name_or_path": "ibm-granite/granite-speech-5.0-470m-turboctc-nc",
|
| 84 |
"architectures": [
|
| 85 |
"GraniteSpeech5ForCTC"
|
| 86 |
],
|
| 87 |
"ctc_loss_reduction": "mean",
|
| 88 |
"ctc_zero_infinity": true,
|
| 89 |
+
"dtype": "float32",
|
| 90 |
"encoder_config": {
|
| 91 |
"_name_or_path": "",
|
| 92 |
"activation_dropout": 0.0,
|
|
|
|
| 136 |
"vocab_size": 16384
|
| 137 |
},
|
| 138 |
"encoder_attention_mask": true,
|
| 139 |
+
"encoder_chunk_frames": 0,
|
| 140 |
"encoder_conv_layers": [
|
| 141 |
[
|
| 142 |
0,
|
|
|
|
| 153 |
"encoder_dtype": "bfloat16",
|
| 154 |
"encoder_trainable_post_projections": false,
|
| 155 |
"encoder_trainable_top_layers": 0,
|
|
|
|
| 156 |
"freeze_audio_encoder": true,
|
| 157 |
+
"freeze_language_model": false,
|
| 158 |
"freeze_projector": false,
|
| 159 |
+
"freeze_text_embed_tokens": false,
|
| 160 |
+
"inference_chunk_seconds": 28.0,
|
| 161 |
"inference_lead_in_seconds": 0.25,
|
| 162 |
+
"llm_dim": 1024,
|
|
|
|
| 163 |
"lora_alpha": 64,
|
|
|
|
| 164 |
"lora_dropout": 0.0,
|
| 165 |
"lora_rank": 64,
|
|
|
|
| 166 |
"lora_target_modules": "all-linear",
|
|
|
|
|
|
|
|
|
|
| 167 |
"max_new_tokens": 256,
|
| 168 |
"model_dtype": "bfloat16",
|
| 169 |
"model_type": "asr_model",
|
| 170 |
"no_repeat_ngram_size": 12,
|
| 171 |
+
"pack_sequences": true,
|
| 172 |
"pipeline_tag": "automatic-speech-recognition",
|
| 173 |
"pretrained_model_path": "mazesmazes/tiny-audio",
|
| 174 |
"projector_dtype": "float32",
|
| 175 |
"projector_hidden_dim": 4096,
|
| 176 |
"projector_pool_stride": 1,
|
|
|
|
| 177 |
"text_config": {
|
| 178 |
+
"_name_or_path": "Qwen/Qwen3-0.6B",
|
| 179 |
"architectures": [
|
| 180 |
+
"Qwen3ForCausalLM"
|
| 181 |
],
|
| 182 |
+
"attention_bias": false,
|
| 183 |
+
"attention_dropout": 0.0,
|
| 184 |
"bos_token_id": null,
|
| 185 |
+
"dtype": "float32",
|
| 186 |
+
"eos_token_id": 151645,
|
| 187 |
+
"head_dim": 128,
|
| 188 |
+
"hidden_act": "silu",
|
| 189 |
+
"hidden_size": 1024,
|
| 190 |
+
"initializer_range": 0.02,
|
| 191 |
+
"intermediate_size": 3072,
|
| 192 |
+
"layer_types": [
|
| 193 |
+
"full_attention",
|
| 194 |
+
"full_attention",
|
| 195 |
+
"full_attention",
|
| 196 |
+
"full_attention",
|
| 197 |
+
"full_attention",
|
| 198 |
+
"full_attention",
|
| 199 |
+
"full_attention",
|
| 200 |
+
"full_attention",
|
| 201 |
+
"full_attention",
|
| 202 |
+
"full_attention",
|
| 203 |
+
"full_attention",
|
| 204 |
+
"full_attention",
|
| 205 |
+
"full_attention",
|
| 206 |
+
"full_attention",
|
| 207 |
+
"full_attention",
|
| 208 |
+
"full_attention",
|
| 209 |
+
"full_attention",
|
| 210 |
+
"full_attention",
|
| 211 |
+
"full_attention",
|
| 212 |
+
"full_attention",
|
| 213 |
+
"full_attention",
|
| 214 |
+
"full_attention",
|
| 215 |
+
"full_attention",
|
| 216 |
+
"full_attention",
|
| 217 |
+
"full_attention",
|
| 218 |
+
"full_attention",
|
| 219 |
+
"full_attention",
|
| 220 |
+
"full_attention"
|
| 221 |
+
],
|
| 222 |
+
"max_position_embeddings": 40960,
|
| 223 |
+
"max_window_layers": 28,
|
| 224 |
+
"model_type": "qwen3",
|
| 225 |
+
"num_attention_heads": 16,
|
| 226 |
+
"num_hidden_layers": 28,
|
| 227 |
+
"num_key_value_heads": 8,
|
| 228 |
+
"pad_token_id": 151643,
|
| 229 |
+
"rms_norm_eps": 1e-06,
|
| 230 |
+
"rope_parameters": {
|
| 231 |
+
"rope_theta": 1000000,
|
| 232 |
+
"rope_type": "default"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
},
|
| 234 |
+
"sliding_window": null,
|
| 235 |
"tie_word_embeddings": true,
|
| 236 |
+
"use_cache": true,
|
| 237 |
+
"use_sliding_window": false,
|
| 238 |
+
"vocab_size": 151680
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
},
|
| 240 |
+
"text_model_id": "Qwen/Qwen3-0.6B",
|
| 241 |
"transcribe_prompt": "Transcribe the speech with proper punctuation and capitalization",
|
| 242 |
+
"transformers_version": "5.19.0.dev0",
|
| 243 |
"use_cache": true,
|
| 244 |
+
"use_lora": false,
|
| 245 |
+
"vocab_size": 151680
|
| 246 |
}
|
diarization.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import importlib.util
|
| 4 |
import re
|
| 5 |
-
from typing import TYPE_CHECKING, Any, TypedDict
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
import numpy.typing as npt
|
|
@@ -161,7 +161,7 @@ class NemotronDiarizer:
|
|
| 161 |
AutoModelForAudioFrameClassification.from_pretrained(cls.MODEL_ID)
|
| 162 |
)
|
| 163 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 164 |
-
model.to(get_device())
|
| 165 |
model.eval()
|
| 166 |
cls._model = model
|
| 167 |
processor: Nemotron3DiarizationProcessor = AutoProcessor.from_pretrained(cls.MODEL_ID)
|
|
|
|
| 2 |
|
| 3 |
import importlib.util
|
| 4 |
import re
|
| 5 |
+
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
import numpy.typing as npt
|
|
|
|
| 161 |
AutoModelForAudioFrameClassification.from_pretrained(cls.MODEL_ID)
|
| 162 |
)
|
| 163 |
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 164 |
+
cast(torch.nn.Module, model).to(get_device())
|
| 165 |
model.eval()
|
| 166 |
cls._model = model
|
| 167 |
processor: Nemotron3DiarizationProcessor = AutoProcessor.from_pretrained(cls.MODEL_ID)
|
generation_config.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
|
|
|
| 3 |
"eos_token_id": [
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
248044
|
| 7 |
],
|
| 8 |
"max_new_tokens": 256,
|
| 9 |
"no_repeat_ngram_size": 12,
|
| 10 |
-
"pad_token_id":
|
| 11 |
-
"
|
|
|
|
|
|
|
|
|
|
| 12 |
"use_cache": true
|
| 13 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"bos_token_id": 151643,
|
| 3 |
+
"do_sample": true,
|
| 4 |
"eos_token_id": [
|
| 5 |
+
151645,
|
| 6 |
+
151643
|
|
|
|
| 7 |
],
|
| 8 |
"max_new_tokens": 256,
|
| 9 |
"no_repeat_ngram_size": 12,
|
| 10 |
+
"pad_token_id": 151643,
|
| 11 |
+
"temperature": 0.6,
|
| 12 |
+
"top_k": 20,
|
| 13 |
+
"top_p": 0.95,
|
| 14 |
+
"transformers_version": "5.19.0.dev0",
|
| 15 |
"use_cache": true
|
| 16 |
}
|
handler.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
"""Custom inference handler for HuggingFace Inference Endpoints."""
|
| 2 |
-
|
| 3 |
-
import base64
|
| 4 |
-
import binascii
|
| 5 |
-
import os
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from typing import TYPE_CHECKING, Any
|
| 8 |
-
|
| 9 |
-
if TYPE_CHECKING:
|
| 10 |
-
from .alignment import QwenForcedAligner
|
| 11 |
-
from .asr_modeling import ASRModel
|
| 12 |
-
from .asr_pipeline import ASRPipeline
|
| 13 |
-
from .diarization import NemotronDiarizer
|
| 14 |
-
from .diarization import get_device as _best_device
|
| 15 |
-
else:
|
| 16 |
-
try:
|
| 17 |
-
# For remote execution, imports are relative
|
| 18 |
-
from .alignment import QwenForcedAligner
|
| 19 |
-
from .asr_modeling import ASRModel
|
| 20 |
-
from .asr_pipeline import ASRPipeline
|
| 21 |
-
from .diarization import NemotronDiarizer
|
| 22 |
-
from .diarization import get_device as _best_device
|
| 23 |
-
except ImportError:
|
| 24 |
-
# For local execution, imports are not relative
|
| 25 |
-
from alignment import QwenForcedAligner
|
| 26 |
-
from asr_modeling import ASRModel
|
| 27 |
-
from asr_pipeline import ASRPipeline
|
| 28 |
-
from diarization import NemotronDiarizer
|
| 29 |
-
from diarization import get_device as _best_device
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def decode_inputs(inputs: Any) -> Any:
|
| 33 |
-
"""A JSON request's base64 audio as bytes; paths, raw bytes and arrays unchanged.
|
| 34 |
-
|
| 35 |
-
The toolkit hands `inputs` over as raw bytes for an `audio/*` body, but a
|
| 36 |
-
JSON body can only carry audio as a base64 string, which the pipeline
|
| 37 |
-
would otherwise read as a file path.
|
| 38 |
-
"""
|
| 39 |
-
if not isinstance(inputs, str) or Path(inputs).exists():
|
| 40 |
-
return inputs
|
| 41 |
-
try:
|
| 42 |
-
return base64.b64decode(inputs, validate=True)
|
| 43 |
-
except (binascii.Error, ValueError):
|
| 44 |
-
return inputs
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
class EndpointHandler:
|
| 48 |
-
"""HuggingFace Inference Endpoints handler for ASR model.
|
| 49 |
-
|
| 50 |
-
Handles model loading, warmup, and inference requests for deployment
|
| 51 |
-
on HuggingFace Inference Endpoints or similar services.
|
| 52 |
-
"""
|
| 53 |
-
|
| 54 |
-
def __init__(self, path: str = ""):
|
| 55 |
-
"""Initialize the endpoint handler.
|
| 56 |
-
|
| 57 |
-
Args:
|
| 58 |
-
path: Path to model directory or HuggingFace model ID
|
| 59 |
-
"""
|
| 60 |
-
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 61 |
-
|
| 62 |
-
# `ASRModel.from_pretrained` constructs its own submodules and forwards
|
| 63 |
-
# **kwargs to `ASRModel.__init__`, which discards them -- no loader
|
| 64 |
-
# reads `device_map`, `torch_dtype` or `low_cpu_mem_usage`, and it sets
|
| 65 |
-
# `_is_loading_from_pretrained` precisely to keep `device_map="auto"`
|
| 66 |
-
# out of the sub-model loaders. Passing them here did nothing, so the
|
| 67 |
-
# model stayed on CPU and a GPU Inference Endpoint silently decoded on
|
| 68 |
-
# CPU. Place it explicitly instead. dtype comes from
|
| 69 |
-
# `config.model_dtype` and the attention backend from
|
| 70 |
-
# `config.attn_implementation`, which already downgrades FA2 when
|
| 71 |
-
# flash_attn is missing.
|
| 72 |
-
self.model = ASRModel.from_pretrained(path)
|
| 73 |
-
self.device = _best_device()
|
| 74 |
-
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
|
| 75 |
-
self.model.to(self.device) # pyright: ignore[reportArgumentType]
|
| 76 |
-
self.model.eval()
|
| 77 |
-
|
| 78 |
-
self.pipe = ASRPipeline(
|
| 79 |
-
model=self.model,
|
| 80 |
-
feature_extractor=self.model.feature_extractor,
|
| 81 |
-
tokenizer=self.model.tokenizer,
|
| 82 |
-
device=self.device,
|
| 83 |
-
)
|
| 84 |
-
# Load the forced aligner (return_timestamps) and Nemotron diarizer
|
| 85 |
-
# (return_speakers) at boot, as the Spaces demo does, so the first
|
| 86 |
-
# request that asks for them doesn't pay the download and load.
|
| 87 |
-
QwenForcedAligner.get_instance()
|
| 88 |
-
NemotronDiarizer.get_instance()
|
| 89 |
-
|
| 90 |
-
def __call__(self, data: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
|
| 91 |
-
"""Process an inference request.
|
| 92 |
-
|
| 93 |
-
Args:
|
| 94 |
-
data: Request data containing 'inputs' (audio path, bytes or base64
|
| 95 |
-
string) and optional 'parameters' for the pipeline, e.g.
|
| 96 |
-
`return_timestamps`, `return_speakers`, `num_speakers`,
|
| 97 |
-
`max_speakers` -- the same options as the Spaces demo
|
| 98 |
-
|
| 99 |
-
Returns:
|
| 100 |
-
Transcription result with 'text' key; 'words' with timestamps, plus
|
| 101 |
-
speaker labels and 'speaker_segments' when diarizing
|
| 102 |
-
"""
|
| 103 |
-
inputs = data.get("inputs")
|
| 104 |
-
if inputs is None:
|
| 105 |
-
msg = "Missing 'inputs' in request data"
|
| 106 |
-
raise ValueError(msg)
|
| 107 |
-
|
| 108 |
-
# Pass through any parameters from request, let model config provide defaults
|
| 109 |
-
params = data.get("parameters", {})
|
| 110 |
-
|
| 111 |
-
result: dict[str, Any] | list[dict[str, Any]] = self.pipe(decode_inputs(inputs), **params)
|
| 112 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
model.safetensors
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4b17a2bb72d661cd79a5d7cb2772b1d817a667816f81c933dc73c6c126915559
|
| 3 |
+
size 2416770552
|
preprocessor_config.json
CHANGED
|
@@ -6,7 +6,6 @@
|
|
| 6 |
"logmel_floor_db": 8.0,
|
| 7 |
"n_fft": 512,
|
| 8 |
"num_mel_bins": 80,
|
| 9 |
-
"padding": false,
|
| 10 |
"padding_side": "right",
|
| 11 |
"padding_value": 0.0,
|
| 12 |
"return_attention_mask": true,
|
|
|
|
| 6 |
"logmel_floor_db": 8.0,
|
| 7 |
"n_fft": 512,
|
| 8 |
"num_mel_bins": 80,
|
|
|
|
| 9 |
"padding_side": "right",
|
| 10 |
"padding_value": 0.0,
|
| 11 |
"return_attention_mask": true,
|
projectors.py
CHANGED
|
@@ -7,7 +7,7 @@ This module contains all projector architectures:
|
|
| 7 |
from typing import overload
|
| 8 |
|
| 9 |
import torch
|
| 10 |
-
|
| 11 |
from transformers.models.llama.modeling_llama import LlamaRMSNorm
|
| 12 |
|
| 13 |
# =============================================================================
|
|
@@ -108,10 +108,10 @@ class MLPAudioProjector(nn.Module):
|
|
| 108 |
in_dim = encoder_dim * self.k
|
| 109 |
# Hidden dim defaults to llm_dim, can be overridden via config
|
| 110 |
hidden_dim: int = getattr(config, "projector_hidden_dim", None) or llm_dim
|
| 111 |
-
self.input_norm = LlamaRMSNorm(in_dim, eps=1e-6)
|
| 112 |
-
self.linear_1 = nn.Linear(in_dim, hidden_dim)
|
| 113 |
-
self.act = nn.GELU()
|
| 114 |
-
self.linear_2 = nn.Linear(hidden_dim, llm_dim)
|
| 115 |
|
| 116 |
@overload
|
| 117 |
def get_output_length(self, input_length: int) -> int: ...
|
|
@@ -160,12 +160,3 @@ def _frame_stack(x: torch.Tensor, k: int) -> torch.Tensor:
|
|
| 160 |
batch, seq, dim = x.shape
|
| 161 |
out_len = _frame_stack_length(seq, k)
|
| 162 |
return x[:, : out_len * k, :].reshape(batch, out_len, dim * k)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# =============================================================================
|
| 166 |
-
# Projector Registry
|
| 167 |
-
# =============================================================================
|
| 168 |
-
|
| 169 |
-
PROJECTOR_CLASSES = {
|
| 170 |
-
"mlp": MLPAudioProjector,
|
| 171 |
-
}
|
|
|
|
| 7 |
from typing import overload
|
| 8 |
|
| 9 |
import torch
|
| 10 |
+
from torch import nn
|
| 11 |
from transformers.models.llama.modeling_llama import LlamaRMSNorm
|
| 12 |
|
| 13 |
# =============================================================================
|
|
|
|
| 108 |
in_dim = encoder_dim * self.k
|
| 109 |
# Hidden dim defaults to llm_dim, can be overridden via config
|
| 110 |
hidden_dim: int = getattr(config, "projector_hidden_dim", None) or llm_dim
|
| 111 |
+
self.input_norm: LlamaRMSNorm = LlamaRMSNorm(in_dim, eps=1e-6)
|
| 112 |
+
self.linear_1: nn.Linear = nn.Linear(in_dim, hidden_dim)
|
| 113 |
+
self.act: nn.GELU = nn.GELU()
|
| 114 |
+
self.linear_2: nn.Linear = nn.Linear(hidden_dim, llm_dim)
|
| 115 |
|
| 116 |
@overload
|
| 117 |
def get_output_length(self, input_length: int) -> int: ...
|
|
|
|
| 160 |
batch, seq, dim = x.shape
|
| 161 |
out_len = _frame_stack_length(seq, k)
|
| 162 |
return x[:, : out_len * k, :].reshape(batch, out_len, dim * k)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
qwen3_asr_encoder.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qwen3-ASR's audio encoder behind ASRModel's encoder interface.
|
| 2 |
+
|
| 3 |
+
`Qwen/Qwen3-ASR-1.7B-hf` (Apache-2.0) is #9 on the Open ASR leaderboard (mean
|
| 4 |
+
of 9 4.936, 2026-10-20), the best system whose encoder loads natively in our
|
| 5 |
+
transformers, and its encoder was trained to feed a Qwen3 decoder.
|
| 6 |
+
|
| 7 |
+
transformers' `Qwen3ASREncoder` owns its preprocessing contract, which is why
|
| 8 |
+
it is used as-is rather than reimplemented:
|
| 9 |
+
|
| 10 |
+
- its convolutions run on independent 1 s chunks (100 mel frames, 13 tokens
|
| 11 |
+
each) and attention is limited to 8 s windows, both inside `forward`;
|
| 12 |
+
- it takes the feature extractor's mel mask and drops padding itself.
|
| 13 |
+
|
| 14 |
+
Two things differ from what ASRModel expects, and this module bridges them:
|
| 15 |
+
|
| 16 |
+
- the encoder returns the valid frames of the whole batch packed into one
|
| 17 |
+
`(sum(tokens), d_model)` tensor, which is split back into a padded
|
| 18 |
+
`(batch, tokens, d_model)` tensor here;
|
| 19 |
+
- it lives inside the full ASR checkpoint, so only its `model.audio_tower.*`
|
| 20 |
+
tensors are read.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import cast
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
from huggingface_hub import hf_hub_download
|
| 30 |
+
from huggingface_hub.errors import EntryNotFoundError
|
| 31 |
+
from safetensors import safe_open
|
| 32 |
+
from torch import nn
|
| 33 |
+
from transformers.models.qwen3_asr import Qwen3ASRConfig, Qwen3ASREncoder, Qwen3ASREncoderConfig
|
| 34 |
+
from transformers.utils.generic import ModelOutput
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
from .asr_config import (
|
| 38 |
+
QWEN3_ASR_CHUNK_FRAMES,
|
| 39 |
+
QWEN3_ASR_CONV_LAYERS,
|
| 40 |
+
compute_encoder_output_length,
|
| 41 |
+
)
|
| 42 |
+
except ImportError: # flat layout on the Hub: sibling modules, no package
|
| 43 |
+
from asr_config import (
|
| 44 |
+
QWEN3_ASR_CHUNK_FRAMES,
|
| 45 |
+
QWEN3_ASR_CONV_LAYERS,
|
| 46 |
+
compute_encoder_output_length,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# The encoder's tensors inside the ASR checkpoint.
|
| 50 |
+
_WEIGHT_PREFIX = "model.audio_tower."
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class Qwen3ASRAudioOutput(ModelOutput):
|
| 55 |
+
"""Padded encoder states and the mask of their valid frames."""
|
| 56 |
+
|
| 57 |
+
last_hidden_state: torch.Tensor | None = None
|
| 58 |
+
# (batch, tokens), True on valid frames. ASRModel checks the prompt's audio
|
| 59 |
+
# token counts against it, as it does for Granite's own returned mask.
|
| 60 |
+
attention_mask: torch.Tensor | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Qwen3ASRAudioEncoder(nn.Module):
|
| 64 |
+
"""`Qwen3ASREncoder` with its packed output split back into a padded batch."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, encoder: Qwen3ASREncoder) -> None:
|
| 67 |
+
"""Wrap a loaded encoder; `config` is exposed for encoder_dim detection."""
|
| 68 |
+
super().__init__()
|
| 69 |
+
self.encoder: Qwen3ASREncoder = encoder
|
| 70 |
+
self.config: Qwen3ASREncoderConfig = encoder.config
|
| 71 |
+
|
| 72 |
+
def forward(
|
| 73 |
+
self, input_features: torch.Tensor, attention_mask: torch.Tensor | None = None
|
| 74 |
+
) -> Qwen3ASRAudioOutput:
|
| 75 |
+
"""Encode `(batch, n_mels, frames)` mel features to `(batch, tokens, d_model)`.
|
| 76 |
+
|
| 77 |
+
`frames` must be a multiple of 100, which Qwen3-ASR's feature extractor
|
| 78 |
+
guarantees. Without `attention_mask` every frame counts as audio.
|
| 79 |
+
"""
|
| 80 |
+
batch = input_features.shape[0]
|
| 81 |
+
if attention_mask is None:
|
| 82 |
+
attention_mask = torch.ones(
|
| 83 |
+
batch, input_features.shape[-1], dtype=torch.long, device=input_features.device
|
| 84 |
+
)
|
| 85 |
+
mask = attention_mask.to(device=input_features.device, dtype=torch.long)
|
| 86 |
+
packed = self.encoder(input_features=input_features, input_features_mask=mask)
|
| 87 |
+
flat = packed.last_hidden_state
|
| 88 |
+
|
| 89 |
+
lengths = compute_encoder_output_length(
|
| 90 |
+
mask.sum(dim=-1), QWEN3_ASR_CONV_LAYERS, QWEN3_ASR_CHUNK_FRAMES
|
| 91 |
+
)
|
| 92 |
+
if int(lengths.sum()) != flat.shape[0]:
|
| 93 |
+
msg = (
|
| 94 |
+
f"Qwen3-ASR encoder packed {flat.shape[0]} frames but the chunked length "
|
| 95 |
+
f"formula expects {int(lengths.sum())} ({lengths.tolist()} per clip)."
|
| 96 |
+
)
|
| 97 |
+
raise ValueError(msg)
|
| 98 |
+
rows = list(flat.split(lengths.tolist()))
|
| 99 |
+
hidden = nn.utils.rnn.pad_sequence(rows, batch_first=True)
|
| 100 |
+
valid = torch.arange(hidden.shape[1], device=hidden.device)[None, :] < lengths[:, None]
|
| 101 |
+
return Qwen3ASRAudioOutput(last_hidden_state=hidden, attention_mask=valid)
|
| 102 |
+
|
| 103 |
+
@classmethod
|
| 104 |
+
def from_pretrained(
|
| 105 |
+
cls, repo_id: str, dtype: torch.dtype, attn_implementation: str | None
|
| 106 |
+
) -> "Qwen3ASRAudioEncoder":
|
| 107 |
+
"""Build the encoder from `repo_id`'s audio config and load only its tensors.
|
| 108 |
+
|
| 109 |
+
Built on CPU, not the meta device: the sinusoidal position table is a
|
| 110 |
+
non-persistent buffer that is absent from the checkpoint, so a meta
|
| 111 |
+
build would leave it without values.
|
| 112 |
+
"""
|
| 113 |
+
audio_config = Qwen3ASRConfig.from_pretrained(repo_id).audio_config
|
| 114 |
+
if not isinstance(audio_config, Qwen3ASREncoderConfig):
|
| 115 |
+
msg = f"{repo_id} has no Qwen3-ASR audio_config (got {type(audio_config).__name__})"
|
| 116 |
+
raise TypeError(msg)
|
| 117 |
+
encoder = Qwen3ASREncoder(audio_config)
|
| 118 |
+
|
| 119 |
+
state: dict[str, torch.Tensor] = {}
|
| 120 |
+
for path, names in _encoder_shards(repo_id).items():
|
| 121 |
+
with safe_open(path, framework="pt") as f:
|
| 122 |
+
for name in names:
|
| 123 |
+
state[name.removeprefix(_WEIGHT_PREFIX)] = f.get_tensor(name)
|
| 124 |
+
# strict: a renamed or missing tensor must fail here, not train on random weights.
|
| 125 |
+
encoder.load_state_dict(state, strict=True)
|
| 126 |
+
# PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a
|
| 127 |
+
# method; it casts in place.
|
| 128 |
+
cast(nn.Module, encoder).to(dtype=dtype)
|
| 129 |
+
# After the cast: transformers warns when FA2 is set on an fp32 model.
|
| 130 |
+
# Its other warning, "not initialized on GPU", is expected here; the
|
| 131 |
+
# Trainer moves the whole ASRModel to the GPU before the first step.
|
| 132 |
+
if attn_implementation:
|
| 133 |
+
encoder.set_attn_implementation(attn_implementation)
|
| 134 |
+
return cls(encoder)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _encoder_shards(repo_id: str) -> dict[str, list[str]]:
|
| 138 |
+
"""Local path of each checkpoint file holding encoder tensors -> those tensor names."""
|
| 139 |
+
try:
|
| 140 |
+
index_path = hf_hub_download(repo_id, "model.safetensors.index.json")
|
| 141 |
+
except EntryNotFoundError: # a single-file checkpoint (the 1.7B and 0.6B are) has no index
|
| 142 |
+
path = hf_hub_download(repo_id, "model.safetensors")
|
| 143 |
+
with safe_open(path, framework="pt") as f:
|
| 144 |
+
keys = cast("list[str]", f.keys())
|
| 145 |
+
names = [k for k in keys if k.startswith(_WEIGHT_PREFIX)]
|
| 146 |
+
return {path: names}
|
| 147 |
+
weight_map: dict[str, str] = json.loads(Path(index_path).read_text())["weight_map"]
|
| 148 |
+
shards: dict[str, list[str]] = {}
|
| 149 |
+
for name, shard in weight_map.items():
|
| 150 |
+
if name.startswith(_WEIGHT_PREFIX):
|
| 151 |
+
shards.setdefault(shard, []).append(name)
|
| 152 |
+
return {hf_hub_download(repo_id, shard): names for shard, names in shards.items()}
|
requirements.txt
CHANGED
|
@@ -8,3 +8,5 @@ transformers @ git+https://github.com/huggingface/transformers@91ea1192a00e0ebb6
|
|
| 8 |
torch
|
| 9 |
torchaudio
|
| 10 |
peft
|
|
|
|
|
|
|
|
|
| 8 |
torch
|
| 9 |
torchaudio
|
| 10 |
peft
|
| 11 |
+
# Nemotron's streaming feature extractor (return_speakers=True) imports it.
|
| 12 |
+
librosa
|
tokenizer.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:33b674fb8444e2553eae8f1b261093371920a28ef75b5c18f4deb3f9217ed0ba
|
| 3 |
+
size 11422834
|
tokenizer_config.json
CHANGED
|
@@ -1,8 +1,5 @@
|
|
| 1 |
{
|
| 2 |
"add_prefix_space": false,
|
| 3 |
-
"audio_bos_token": "<|audio_start|>",
|
| 4 |
-
"audio_eos_token": "<|audio_end|>",
|
| 5 |
-
"audio_token": "<|audio_pad|>",
|
| 6 |
"backend": "tokenizers",
|
| 7 |
"bos_token": null,
|
| 8 |
"clean_up_tokenization_spaces": false,
|
|
@@ -11,25 +8,11 @@
|
|
| 11 |
"extra_special_tokens": [
|
| 12 |
"<audio>"
|
| 13 |
],
|
| 14 |
-
"image_token": "<|image_pad|>",
|
| 15 |
"is_local": false,
|
| 16 |
"local_files_only": false,
|
| 17 |
-
"model_max_length":
|
| 18 |
-
"model_specific_special_tokens": {
|
| 19 |
-
"audio_bos_token": "<|audio_start|>",
|
| 20 |
-
"audio_eos_token": "<|audio_end|>",
|
| 21 |
-
"audio_token": "<|audio_pad|>",
|
| 22 |
-
"image_token": "<|image_pad|>",
|
| 23 |
-
"video_token": "<|video_pad|>",
|
| 24 |
-
"vision_bos_token": "<|vision_start|>",
|
| 25 |
-
"vision_eos_token": "<|vision_end|>"
|
| 26 |
-
},
|
| 27 |
"pad_token": "<|endoftext|>",
|
| 28 |
-
"pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
|
| 29 |
"split_special_tokens": false,
|
| 30 |
"tokenizer_class": "Qwen2Tokenizer",
|
| 31 |
-
"unk_token": null
|
| 32 |
-
"video_token": "<|video_pad|>",
|
| 33 |
-
"vision_bos_token": "<|vision_start|>",
|
| 34 |
-
"vision_eos_token": "<|vision_end|>"
|
| 35 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"add_prefix_space": false,
|
|
|
|
|
|
|
|
|
|
| 3 |
"backend": "tokenizers",
|
| 4 |
"bos_token": null,
|
| 5 |
"clean_up_tokenization_spaces": false,
|
|
|
|
| 8 |
"extra_special_tokens": [
|
| 9 |
"<audio>"
|
| 10 |
],
|
|
|
|
| 11 |
"is_local": false,
|
| 12 |
"local_files_only": false,
|
| 13 |
+
"model_max_length": 131072,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"pad_token": "<|endoftext|>",
|
|
|
|
| 15 |
"split_special_tokens": false,
|
| 16 |
"tokenizer_class": "Qwen2Tokenizer",
|
| 17 |
+
"unk_token": null
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
training_args.bin
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:96b511d201742d6e8622c5e93d5c85dd609a040ae7daa77c2d16b57834876ab0
|
| 3 |
-
size 5265
|
|
|
|
|
|
|
|
|
|
|
|