mazesmazes commited on
Commit
26ce2ed
·
verified ·
1 Parent(s): 37f59d3

Promote tiny-audio-granite-qwen-500-3 (Granite NC encoder + fine-tuned Qwen3-0.6B)

Browse files

Copy 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 CHANGED
@@ -1,3 +1,36 @@
1
- *.safetensors filter=lfs diff=lfs merge=lfs -text
 
2
  *.bin filter=lfs diff=lfs merge=lfs -text
3
- tokenizer_config.json -filter -diff -merge text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: mit
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.5-2B
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: 1.8%
186
- WER on LibriSpeech test-clean and 7.4% across 12 benchmarks (11,822 samples pooled). Built and
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
- Word error rate (%, lower is better) on 11,822 samples (up to 1,000 per dataset), scored with the
215
  [Tiny Audio eval harness](https://github.com/alexkroman/tiny-audio/tree/main/scripts/eval)
216
- (`ta eval`) after text normalization.
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 8-18 s chunks cut at
269
- pauses (the model trained on clips up to 19 s), aligns each chunk against its own transcript in
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. A smoke test runs on a
327
- laptop in about five minutes:
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 +experiments=mps_smoke
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 | ~19 s per plain call; any length with timestamps or speakers |
346
- | Vocabulary | Qwen3.5 tokenizer |
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), VoxPopuli, AMI (IHM + SDM), TED-LIUM: one pass, ~3.8M utterances |
355
- | **Hardware** | Single NVIDIA H100 80GB |
356
- | **Steps** | 58,861 |
357
- | **Batch Size** | 64 |
358
- | **Optimizer** | AdamW (fused), cosine schedule, 1,000 warmup steps, no weight decay |
359
- | **Learning Rate** | 1e-3 (projector), 1e-4 (LoRA) |
360
- | **Precision** | bf16 (projector held in fp32) |
361
 
362
- The training recipe is
363
- [`configs/experiments/granite_qwen_frozen.yaml`](https://github.com/alexkroman/tiny-audio/blob/main/configs/experiments/granite_qwen_frozen.yaml).
 
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**: A plain `pipe(audio)` call decodes the clip in one pass and works best up to
370
- about 19 seconds (the training length). For longer audio pass `return_timestamps=True` or
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 (~50MB) |
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
- Only the projector and LoRA weights are stored here. The encoder (Granite Speech) and decoder
392
- (Qwen3.5-2B) are downloaded from their own Hugging Face repos.
393
 
394
- The previous GLM-ASR + Qwen3-0.6B model is still available at revision `glm-asr-qwen3-0.6b`:
 
395
 
396
  ```python
397
  pipe = pipeline(
398
  "automatic-speech-recognition",
399
  model="mazesmazes/tiny-audio",
400
- revision="glm-asr-qwen3-0.6b",
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{tinyaudio2024,
411
  author = {Alex Kroman},
412
  title = {Tiny Audio: Minimal ASR Training},
413
- year = {2024},
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.5](https://huggingface.co/Qwen/Qwen3.5-2B) for the language model
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, VoxPopuli, AMI, and
428
- TED-LIUM teams for training data
429
 
430
  ## License
431
 
432
- MIT
 
 
 
 
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) # pyright: ignore[reportArgumentType]
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 Exception:
39
- # Raised by the heterogeneity guard; treated as "unknown".
 
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, conv_layers: Sequence[ConvLayerSpec] | None = None
 
 
57
  ) -> int: ...
58
  @overload
59
  def compute_encoder_output_length(
60
- mel_length: "torch.Tensor", conv_layers: Sequence[ConvLayerSpec] | None = None
 
 
61
  ) -> "torch.Tensor": ...
62
  def compute_encoder_output_length(
63
- mel_length: "int | torch.Tensor", conv_layers: Sequence[ConvLayerSpec] | None = None
 
 
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 the decoder's native audio token when it has one (Gemma 4),
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 -- 10.4 GiB on a frozen Gemma 4 E2B
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 = encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
307
- self.audio_features_time_major = (
308
- is_time_major_encoder(audio_model_id)
309
- if audio_features_time_major is None
310
- else audio_features_time_major
 
 
 
 
311
  )
312
- self.encoder_attention_mask = (
313
- is_time_major_encoder(audio_model_id)
 
314
  if encoder_attention_mask is None
315
  else encoder_attention_mask
316
  )
317
- self.audio_token = audio_token or native_audio_token(text_model_id) or "<audio>"
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
- # Plain dicts, never None: PEFT's LoraConfig defaults them to {} and
332
- # `get_pattern_key` iterates the keys unconditionally.
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 generation_defaults.items():
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: MPS-safe embeddings and encoder unfreezing.
2
 
3
- Two self-contained groups of `nn.Module` utilities that ASRModel applies to its
4
- loaded sub-models:
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 math
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 torch.nn as nn
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 resolve_attn_implementation, resolve_decoder_attn_implementation
48
- from .asr_config import ASRConfig, compute_encoder_output_length
49
- from .asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
 
 
 
 
 
 
 
 
 
 
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 PROJECTOR_CLASSES, MLPAudioProjector
 
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 resolve_attn_implementation, resolve_decoder_attn_implementation
73
- from .asr_config import ASRConfig, compute_encoder_output_length
74
- from .asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
 
 
 
 
 
 
 
 
 
 
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 PROJECTOR_CLASSES
 
83
  except ImportError: # flat layout on the Hub: sibling modules, no package
84
- from asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
85
- from asr_config import ASRConfig, compute_encoder_output_length
86
- from asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
 
 
 
 
 
 
 
 
 
 
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 PROJECTOR_CLASSES
 
95
 
96
- # Architectures newer than the transformers floor in pyproject.toml. Kept
97
- # optional so a checkpoint that needs neither still loads on an older
98
- # install; the branch that needs one raises at its use site instead.
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
- def _patch_gemma_decode_loop(model: "GenerativeDecoder") -> None:
224
- """Make a Gemma4ForCausalLM drop `per_layer_inputs` after the first step.
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
- Patched on the instance because subclassing Gemma4ForCausalLM trips
234
- transformers' experts-implementation check during __init__
235
- ("does not support setting experts implementation").
 
 
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
- model.prepare_inputs_for_generation = prepare_inputs_for_generation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
 
260
- def _assert_projector_loaded(incompatible_keys: LoadStateDictResult, projector_type: str) -> None:
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
- f"Projector weights do not match the configured projector "
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, getattr(config, "projector_type", "?"))
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 type(self)._is_loading_from_pretrained else logging.WARNING,
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. Omitting
493
- # this line left ASRConfig's 12-gram loop guard inert: the value was
494
  # set on the config, serialized into config.json, and never consulted.
495
- self.generation_config.no_repeat_ngram_size = config.no_repeat_ngram_size
 
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 type(self)._is_loading_from_pretrained:
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 "whisper" in config.audio_model_id.lower():
 
 
 
 
 
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 method.
637
- encoder = encoder.to(dtype=encoder_dtype) # pyright: ignore[reportCallIssue]
 
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) # pyright: ignore[reportCallIssue]
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 (Gemma 4) keep hidden_size on `text_config`;
821
- # get_text_config() returns self for plain decoders like Qwen3.
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
- # Select projector type based on config
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
- # (Gemma 4 ships "<|audio|>") so no embedding resize is needed at all.
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.8-19.0s leave a large
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
- # Forward label_smoothing to the LM's loss_function via **kwargs.
1631
- # transformers.loss.loss_utils.ForCausalLMLoss → fixed_cross_entropy
1632
- # forwards extra kwargs to F.cross_entropy, which accepts label_smoothing.
1633
- # When apply_liger_kernel_to_qwen3() has patched the LM, the smoothing
1634
- # is consumed by liger's fused linear CE (no (B,T,V) materialization).
1635
- # Zeroed on eval so eval/loss is raw CE and comparable to LS=0 runs.
1636
- if labels is not None and self.training and self.config.label_smoothing > 0:
1637
- kwargs.setdefault("label_smoothing", self.config.label_smoothing)
 
 
 
 
 
 
 
 
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
- # skip_logits = self.training and labels is not None
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 mps_smoke run on a mac.
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 _prepare_audio_inputs(
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
- if args:
1835
- msg = (
1836
- "ASRModel.generate takes input_ids, input_features, "
1837
- "audio_attention_mask and attention_mask as keyword arguments"
1838
- )
1839
- raise TypeError(msg)
1840
- if input_features is None:
1841
- msg = "input_features required for generation"
1842
- raise ValueError(msg)
1843
- if audio_attention_mask is None:
1844
- msg = "audio_attention_mask required for generation"
1845
- raise ValueError(msg)
 
 
 
1846
 
1847
- input_ids, attention_mask, inputs_embeds = self._prepare_audio_inputs(
1848
- input_features,
1849
- audio_attention_mask,
1850
- input_ids=input_ids,
1851
- attention_mask=attention_mask,
1852
- )
1853
- self._assert_sdpa_safe_on_mps(attention_mask)
1854
-
1855
- # Fold generation flags (max_new_tokens, num_beams, output_scores, ...)
1856
- # into a private copy of generation_config instead of passing them
1857
- # alongside it: transformers v5 still applies such kwargs but warns that
1858
- # combining them with `generation_config` is deprecated. update() returns
1859
- # the kwargs that aren't config fields (streamer, logits_processor, ...),
1860
- # which still go to generate() directly.
1861
- gen_cfg = deepcopy(self.generation_config)
1862
- generate_kwargs = gen_cfg.update(**generate_kwargs)
1863
- # output_scores requires return_dict_in_generate for HF generate to
1864
- # actually populate .scores on the output object.
1865
- if gen_cfg.output_scores:
1866
- gen_cfg.return_dict_in_generate = True
1867
-
1868
- lm_inputs = self._lm_generate_inputs(input_ids, inputs_embeds)
1869
- output = self.language_model.generate(
1870
- **lm_inputs,
1871
- attention_mask=attention_mask,
1872
- generation_config=gen_cfg,
1873
- **generate_kwargs,
1874
- )
 
 
 
 
 
 
 
 
 
1875
 
1876
- # With input_ids AND inputs_embeds, generate returns the full sequence
1877
- # (prompt + generated), so the prompt must be stripped. With
1878
- # inputs_embeds alone it already returns only the new tokens, so
1879
- # stripping would eat real output -- hence prompt_len = 0 there.
1880
- # When scores were requested, preserve the GenerateOutput so callers
1881
- # can read .scores; otherwise return the bare tensor for backward
1882
- # compatibility with existing callers.
1883
- prompt_len = input_ids.shape[1] if "input_ids" in lm_inputs else 0
1884
- if isinstance(output, torch.Tensor):
1885
- # Slicing a token-id tensor keeps its int64 dtype.
1886
- return cast("torch.LongTensor", output[:, prompt_len:])
1887
- output.sequences = cast("torch.LongTensor", output.sequences[:, prompt_len:])
1888
- return output
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
- Default path passes both input_ids and inputs_embeds so
1896
- repetition_penalty and no_repeat_ngram_size see the prompt (they need
1897
- input_ids to track which tokens have been used). Gemma 4 forbids that
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._prepare_audio_inputs(
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
- # (Gemma 4 is multimodal, so Gemma4Config holds text/vision/audio
2002
- # sub-configs) have no top-level `vocab_size` -- reading it raises
2003
- # AttributeError, which used to surface only at the first checkpoint
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
- # Call parent's push_to_hub
 
 
 
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
- tags=tags,
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
- assert torch.is_tensor(tokens) # no output_scores: plain token ids
 
 
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
- # File path or encoded bytes - decode with ffmpeg (same as HF pipeline)
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.feature_extractor.sampling_rate),
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.feature_extractor.sampling_rate),
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
- top1_logprobs: list[float] = []
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: list[list[float]] = (
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": top1_logprobs,
601
- "top2_logprob": top2_logprobs,
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
- assert self.tokenizer is not None # always set by __init__
652
- decoded = self.tokenizer.decode(tokens, skip_special_tokens=True)
653
- assert isinstance(decoded, str) # one sequence in, one string out
 
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 = _truncate_repetitions(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
- if "top1_logprob" in model_outputs:
664
- out["top1_logprob"] = model_outputs["top1_logprob"]
665
- if "top2_logprob" in model_outputs:
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`, which
262
- # resolves to the decoder's native placeholder where it has one (Gemma 4's
263
- # pretrained "<|audio|>") and to "<audio>" otherwise. Hardcoding the
264
- # fallback here fails silently on a native-token decoder: "<audio>" was
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
- self.audio_token_id = tokenizer.convert_tokens_to_ids(self.audio_token)
294
- self.projector = projector
295
- self.encoder_conv_layers = encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
296
- self.lead_in_seconds = float(lead_in_seconds)
 
 
 
 
 
 
 
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 `_prepare_audio_inputs`
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(mel_lengths, self.encoder_conv_layers)
 
 
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 = self._stack_prompt_rows(rows)
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
- {%- set image_count = namespace(value=0) %}
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
- {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
 
 
 
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
- {%- set content = render_content(messages[0].content, false, true)|trim %}
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 content = render_content(message.content, false)|trim %}
72
- {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
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
- {%- set content = render_content(message.content, true)|trim %}
83
- {%- if message.role == "system" %}
84
- {%- if not loop.first %}
85
- {{- raise_exception('System message must be at the beginning.') }}
86
- {%- endif %}
87
- {%- elif message.role == "user" %}
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
- {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
 
 
 
 
102
  {%- else %}
103
  {{- '<|im_start|>' + message.role + '\n' + content }}
104
  {%- endif %}
105
- {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
  {%- for tool_call in message.tool_calls %}
107
- {%- if tool_call.function is defined %}
 
 
 
108
  {%- set tool_call = tool_call.function %}
109
  {%- endif %}
110
- {%- if loop.first %}
111
- {%- if content|trim %}
112
- {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
- {%- else %}
114
- {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
- {%- endif %}
116
  {%- else %}
117
- {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
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
- {{- '</function>\n</tool_call>' }}
128
  {%- endfor %}
129
  {%- endif %}
130
  {{- '<|im_end|>\n' }}
131
  {%- elif message.role == "tool" %}
132
- {%- if loop.previtem and loop.previtem.role != "tool" %}
133
  {{- '<|im_start|>user' }}
134
  {%- endif %}
135
  {{- '\n<tool_response>\n' }}
136
  {{- content }}
137
  {{- '\n</tool_response>' }}
138
- {%- if not loop.last and loop.nextitem.role != "tool" %}
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 enable_thinking is defined and enable_thinking is true %}
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": "sdpa",
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": "bfloat16",
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
- "audio_features_time_major": true,
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": "bfloat16",
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": true,
160
  "freeze_projector": false,
161
- "freeze_text_embed_tokens": true,
 
162
  "inference_lead_in_seconds": 0.25,
163
- "label_smoothing": 0.0,
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
- "pad_token_id": 248044,
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.5-2B",
187
  "architectures": [
188
- "Qwen3_5ForConditionalGeneration"
189
  ],
 
 
190
  "bos_token_id": null,
191
- "dtype": "bfloat16",
192
- "eos_token_id": 248046,
193
- "image_token_id": 248056,
194
- "model_type": "qwen3_5",
195
- "pad_token_id": 248044,
196
- "text_config": {
197
- "_name_or_path": "",
198
- "architectures": null,
199
- "attention_bias": false,
200
- "attention_dropout": 0.0,
201
- "attn_output_gate": true,
202
- "bos_token_id": null,
203
- "chunk_size_feed_forward": 0,
204
- "dtype": "bfloat16",
205
- "eos_token_id": 248044,
206
- "full_attention_interval": 4,
207
- "head_dim": 256,
208
- "hidden_act": "silu",
209
- "hidden_size": 2048,
210
- "id2label": {
211
- "0": "LABEL_0",
212
- "1": "LABEL_1"
213
- },
214
- "initializer_range": 0.02,
215
- "intermediate_size": 6144,
216
- "is_encoder_decoder": false,
217
- "label2id": {
218
- "LABEL_0": 0,
219
- "LABEL_1": 1
220
- },
221
- "layer_types": [
222
- "linear_attention",
223
- "linear_attention",
224
- "linear_attention",
225
- "full_attention",
226
- "linear_attention",
227
- "linear_attention",
228
- "linear_attention",
229
- "full_attention",
230
- "linear_attention",
231
- "linear_attention",
232
- "linear_attention",
233
- "full_attention",
234
- "linear_attention",
235
- "linear_attention",
236
- "linear_attention",
237
- "full_attention",
238
- "linear_attention",
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
- "video_token_id": 248057,
285
- "vision_config": {
286
- "_name_or_path": "",
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.5-2B",
327
  "transcribe_prompt": "Transcribe the speech with proper punctuation and capitalization",
328
- "transformers_version": "5.17.0",
329
  "use_cache": true,
330
- "use_lora": true,
331
- "vocab_size": 248192
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()) # pyright: ignore[reportArgumentType]
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
- "_from_model_config": true,
 
3
  "eos_token_id": [
4
- 248046,
5
- 248046,
6
- 248044
7
  ],
8
  "max_new_tokens": 256,
9
  "no_repeat_ngram_size": 12,
10
- "pad_token_id": 248044,
11
- "transformers_version": "5.17.0",
 
 
 
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:2aae926199ca8125d15aa878e4c51cc69086ce150e9bb1c5dcb648f90be97cee
3
- size 50360824
 
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
- import torch.nn as nn
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:a454aca253aa94d056abd50384d2b15bc468030ad401f9d644b20416b1d46055
3
- size 19989509
 
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": 262144,
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