mazesmazes commited on
Commit
8c0bdcb
·
verified ·
1 Parent(s): 569f1aa

Update custom model files, README, and requirements

Browse files
Files changed (12) hide show
  1. README.md +128 -87
  2. alignment.py +76 -261
  3. asr_attention.py +171 -0
  4. asr_config.py +51 -25
  5. asr_layers.py +201 -0
  6. asr_modeling.py +433 -569
  7. asr_pipeline.py +188 -68
  8. asr_processing.py +108 -47
  9. asr_types.py +108 -0
  10. diarization.py +62 -712
  11. handler.py +28 -25
  12. projectors.py +17 -7
README.md CHANGED
@@ -1,42 +1,46 @@
1
  ---
2
  license: mit
3
  language:
4
- - en
5
  datasets:
6
- - mythicinfinity/libriheavy
7
- - MLCommons/peoples_speech
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
20
- - speech-recognition
21
- - audio
22
- - qwen
23
- - granite-speech
24
- - lora
25
  library_name: transformers
26
  ---
27
 
28
  # Tiny Audio
29
 
30
- An English speech recognition model that outputs punctuated, capitalized, formatted text. Built with [Tiny Audio](https://github.com/alexkroman/tiny-audio)—a minimal, hackable ASR framework.
 
31
 
32
- A frozen Granite Speech encoder is connected to a frozen Qwen3.5-2B decoder through a trained MLP projector, with LoRA adapters on the decoder. Only ~80M parameters are trained.
 
33
 
34
  ## Quick Start
35
 
36
  ```python
37
  from transformers import pipeline
38
 
39
- pipe = pipeline("automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True)
 
 
40
  result = pipe("audio.wav")
41
  print(result["text"])
42
  # The quarterly revenue grew by 12% according to Dr. Smith.
@@ -49,7 +53,9 @@ print(result["text"])
49
  ```python
50
  from transformers import pipeline
51
 
52
- pipe = pipeline("automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True)
 
 
53
 
54
  # From file
55
  result = pipe("audio.wav")
@@ -60,6 +66,7 @@ result = pipe("https://example.com/audio.mp3")
60
 
61
  # From numpy array (must be 16kHz)
62
  import numpy as np
 
63
  audio = np.random.randn(16000).astype(np.float32) # 1 second
64
  result = pipe(audio)
65
  ```
@@ -75,7 +82,11 @@ for r in results:
75
 
76
  ### Word-Level Timestamps
77
 
78
- `return_timestamps=True` times every word with [Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) and returns them under a `words` key. Audio of any length works: the pipeline transcribes it in 8-18 s chunks cut at pauses (the model trained on clips up to 19 s), aligns each chunk against its own transcript in batches, and places every word on the recording's timeline.
 
 
 
 
79
 
80
  ```python
81
  result = pipe("audio.wav", return_timestamps=True)
@@ -87,7 +98,11 @@ for word in result["words"]:
87
 
88
  ### Speaker Diarization
89
 
90
- `return_speakers=True` labels every word with a speaker (it implies `return_timestamps=True`). Speakers come from [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization), run once over the whole recording, so labels stay consistent across hour-long meetings; it handles overlapping speech and up to 8 speakers. Each word goes to the speaker most active while it was spoken.
 
 
 
 
91
 
92
  Nemotron-3-Diarization is in transformers `main`, not yet a release:
93
 
@@ -113,13 +128,17 @@ for w in result["words"]:
113
  # 0.22- 0.69 SPEAKER_0 Daniel.
114
  ```
115
 
116
- Speakers are numbered by when they first speak. The number of speakers is detected automatically. If you know it, pass `num_speakers` (exact) or `max_speakers` (an upper bound); any extra voices' words go to the closest kept speaker:
 
 
117
 
118
  ```python
119
  result = pipe("call.wav", return_speakers=True, num_speakers=2)
120
  ```
121
 
122
- If alignment or diarization fails (for example, transformers without Nemotron), the transcript is still returned and the error is reported under `result["timestamp_error"]` or `result["diarization_error"]`.
 
 
123
 
124
  ### GPU Inference
125
 
@@ -137,78 +156,90 @@ pipe = pipeline(
137
 
138
  ## Benchmarks
139
 
140
- Word error rate (%, lower is better) on 1,000 samples per dataset, scored with the [Tiny Audio eval harness](https://github.com/alexkroman/tiny-audio/tree/main/scripts/eval) (`ta eval`) after text normalization.
141
-
142
- | Dataset | WER |
143
- |---------|----:|
144
- | LibriSpeech test-clean | 1.80 |
145
- | SPGISpeech | 2.24 |
146
- | LibriSpeech test-other | 3.09 |
147
- | TED-LIUM | 3.74 |
148
- | LoquaciousSet † | 6.10 |
149
- | Common Voice | 6.62 |
150
- | VoxPopuli | 6.96 |
151
- | AMI (IHM) | 8.88 |
152
- | GigaSpeech | 9.07 |
153
- | Earnings22 † | 10.54 |
154
- | People's Speech | 17.69 |
155
- | AMI (SDM) | 23.59 |
156
- | **Mean (12 sets)** | **8.36** |
 
 
157
 
158
  † Held out: no data from this source was used in training.
159
 
160
  ## Architecture
161
 
162
- ```
163
- Audio (16kHz) → Granite Speech encoder (frozen) → MLP projector (trained) → Qwen3.5-2B + LoRA (trained adapters) → Text
 
164
  ```
165
 
166
- | Component | Model | Parameters | Status |
167
- |-----------|-------|------------|--------|
168
- | Audio Encoder | [granite-speech-5.0-470m-turboctc](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc) | ~470M | Frozen |
169
- | Projector | 2-layer MLP (hidden 4096) | 12.6M | Trained |
170
- | Language Model | [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B) | ~2B | Frozen |
171
- | LoRA adapters | r=64, alpha=64, all linear layers | 67.3M | Trained |
172
 
173
  ### How It Works
174
 
175
  1. **Audio encoder**: Granite Speech turns 16kHz audio into frame-level embeddings.
176
- 2. **Projector**: A 2-layer MLP maps those embeddings into the decoder's embedding space. Each projected frame replaces an `<audio>` placeholder token in the prompt.
177
- 3. **Language model**: Qwen3.5-2B, adapted with LoRA, generates the transcript conditioned on the projected audio and the prompt *"Transcribe the speech with proper punctuation and capitalization"*.
 
 
 
178
 
179
- At inference, 0.25s of silence is prepended to each clip (`inference_lead_in_seconds`). This keeps the first word from being dropped on clips that start mid-speech.
 
180
 
181
  ## Model Specifications
182
 
183
- | Specification | Value |
184
- |---------------|-------|
185
- | Input | Audio (16kHz mono) |
186
- | Output | Punctuated, capitalized text with formatted numbers |
187
- | Max Audio Length | ~30 seconds per call |
188
- | Vocabulary | Qwen3.5 tokenizer |
189
- | Languages | English only |
190
- | Generation | Greedy decoding (num_beams=1, do_sample=False), max 256 new tokens |
191
 
192
  ## Training Details
193
 
194
- | | |
195
- |---|---|
196
- | **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 |
197
- | **Hardware** | Single NVIDIA H100 80GB |
198
- | **Steps** | 58,861 |
199
- | **Batch Size** | 64 |
200
- | **Optimizer** | AdamW (fused), cosine schedule, 1,000 warmup steps, no weight decay |
201
- | **Learning Rate** | 1e-3 (projector), 1e-4 (LoRA) |
202
- | **Precision** | bf16 (projector held in fp32) |
203
 
204
- The training recipe is [`configs/experiments/granite_qwen_frozen.yaml`](https://github.com/alexkroman/tiny-audio/blob/main/configs/experiments/granite_qwen_frozen.yaml).
 
205
 
206
  ## Limitations
207
 
208
  - **English only**: Not trained on other languages.
209
  - **Sample rate**: Expects 16kHz audio (other rates are resampled automatically).
210
- - **Audio length**: A plain `pipe(audio)` call decodes the clip in one pass and works best up to about 19 seconds (the training length). For longer audio pass `return_timestamps=True` or `return_speakers=True`, which transcribe in 8-18 s chunks automatically.
211
- - **Speaker diarization**: At most 8 speakers per recording. The count is detected automatically; `num_speakers` and `max_speakers` can cap it, but `min_speakers` is not supported (passing it raises a `ValueError`). Needs transformers `main` until a release includes Nemotron-3-Diarization.
 
 
 
 
212
  - **Accuracy**: May degrade on:
213
  - Far-field and overlapping speech (see AMI SDM)
214
  - Noisy or low-quality audio
@@ -216,21 +247,27 @@ The training recipe is [`configs/experiments/granite_qwen_frozen.yaml`](https://
216
 
217
  ## Files
218
 
219
- | File | Description |
220
- |------|-------------|
221
- | `config.json` | Model configuration |
222
- | `model.safetensors` | Projector weights (~50MB) |
223
- | `adapter_config.json` / `adapter_model.safetensors` | LoRA adapters for the decoder (~270MB) |
224
- | `preprocessor_config.json` | Audio preprocessing config |
225
- | `tokenizer.json` / `tokenizer_config.json` / `chat_template.jinja` | Tokenizer |
226
- | `asr_*.py`, `projectors.py`, `alignment.py`, `diarization.py` | Custom model code (loaded with `trust_remote_code=True`) |
227
 
228
- Only the projector and LoRA weights are stored here. The encoder (Granite Speech) and decoder (Qwen3.5-2B) are downloaded from their own Hugging Face repos.
 
229
 
230
  The previous GLM-ASR + Qwen3-0.6B model is still available at revision `glm-asr-qwen3-0.6b`:
231
 
232
  ```python
233
- pipe = pipeline("automatic-speech-recognition", model="mazesmazes/tiny-audio", revision="glm-asr-qwen3-0.6b", trust_remote_code=True)
 
 
 
 
 
234
  ```
235
 
236
  ## Citation
@@ -250,16 +287,20 @@ If you use this model, please cite:
250
  ## Links
251
 
252
  - [GitHub Repository](https://github.com/alexkroman/tiny-audio) - Train your own model
253
- - [Free 3.5-hour Course](https://github.com/alexkroman/tiny-audio/blob/main/docs/course/0-course-overview.md) - Learn ASR from scratch
 
254
  - [Live Demo](https://huggingface.co/spaces/mazesmazes/tiny-audio) - Try it in your browser
255
 
256
  ## Acknowledgments
257
 
258
- - [Granite Speech](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc) for the audio encoder
 
259
  - [Qwen3.5](https://huggingface.co/Qwen/Qwen3.5-2B) for the language model
260
  - [Qwen3-ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) for word timestamps
261
- - [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization) for speaker diarization
262
- - The LibriHeavy, People's Speech, Common Voice, GigaSpeech, SPGISpeech, VoxPopuli, AMI, and TED-LIUM teams for training data
 
 
263
 
264
  ## License
265
 
 
1
  ---
2
  license: mit
3
  language:
4
+ - en
5
  datasets:
6
+ - mythicinfinity/libriheavy
7
+ - MLCommons/peoples_speech
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
20
+ - speech-recognition
21
+ - audio
22
+ - qwen
23
+ - granite-speech
24
+ - lora
25
  library_name: transformers
26
  ---
27
 
28
  # Tiny Audio
29
 
30
+ An English speech recognition model that outputs punctuated, capitalized, formatted text. Built with
31
+ [Tiny Audio](https://github.com/alexkroman/tiny-audio)—a minimal, hackable ASR framework.
32
 
33
+ A frozen Granite Speech encoder is connected to a frozen Qwen3.5-2B decoder through a trained MLP
34
+ projector, with LoRA adapters on the decoder. Only ~80M parameters are trained.
35
 
36
  ## Quick Start
37
 
38
  ```python
39
  from transformers import pipeline
40
 
41
+ pipe = pipeline(
42
+ "automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True
43
+ )
44
  result = pipe("audio.wav")
45
  print(result["text"])
46
  # The quarterly revenue grew by 12% according to Dr. Smith.
 
53
  ```python
54
  from transformers import pipeline
55
 
56
+ pipe = pipeline(
57
+ "automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True
58
+ )
59
 
60
  # From file
61
  result = pipe("audio.wav")
 
66
 
67
  # From numpy array (must be 16kHz)
68
  import numpy as np
69
+
70
  audio = np.random.randn(16000).astype(np.float32) # 1 second
71
  result = pipe(audio)
72
  ```
 
82
 
83
  ### Word-Level Timestamps
84
 
85
+ `return_timestamps=True` times every word with
86
+ [Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) and returns them
87
+ under a `words` key. Audio of any length works: the pipeline transcribes it in 8-18 s chunks cut at
88
+ pauses (the model trained on clips up to 19 s), aligns each chunk against its own transcript in
89
+ batches, and places every word on the recording's timeline.
90
 
91
  ```python
92
  result = pipe("audio.wav", return_timestamps=True)
 
98
 
99
  ### Speaker Diarization
100
 
101
+ `return_speakers=True` labels every word with a speaker (it implies `return_timestamps=True`).
102
+ Speakers come from [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization),
103
+ run once over the whole recording, so labels stay consistent across hour-long meetings; it handles
104
+ overlapping speech and up to 8 speakers. Each word goes to the speaker most active while it was
105
+ spoken.
106
 
107
  Nemotron-3-Diarization is in transformers `main`, not yet a release:
108
 
 
128
  # 0.22- 0.69 SPEAKER_0 Daniel.
129
  ```
130
 
131
+ Speakers are numbered by when they first speak. The number of speakers is detected automatically. If
132
+ you know it, pass `num_speakers` (exact) or `max_speakers` (an upper bound); any extra voices' words
133
+ go to the closest kept speaker:
134
 
135
  ```python
136
  result = pipe("call.wav", return_speakers=True, num_speakers=2)
137
  ```
138
 
139
+ If alignment or diarization fails (for example, transformers without Nemotron), the transcript is
140
+ still returned and the error is reported under `result["timestamp_error"]` or
141
+ `result["diarization_error"]`.
142
 
143
  ### GPU Inference
144
 
 
156
 
157
  ## Benchmarks
158
 
159
+ Word error rate (%, lower is better) on 1,000 samples per dataset, scored with the
160
+ [Tiny Audio eval harness](https://github.com/alexkroman/tiny-audio/tree/main/scripts/eval)
161
+ (`ta eval`) after text normalization.
162
+
163
+ | Dataset | WER |
164
+ | ---------------------- | -------: |
165
+ | LibriSpeech test-clean | 1.80 |
166
+ | SPGISpeech | 2.24 |
167
+ | LibriSpeech test-other | 3.09 |
168
+ | TED-LIUM | 3.74 |
169
+ | LoquaciousSet † | 6.10 |
170
+ | Common Voice | 6.62 |
171
+ | VoxPopuli | 6.96 |
172
+ | AMI (IHM) | 8.88 |
173
+ | GigaSpeech | 9.07 |
174
+ | Earnings22 † | 10.54 |
175
+ | People's Speech | 17.69 |
176
+ | AMI (SDM) | 23.59 |
177
+ | **Mean (12 sets)** | **8.36** |
178
 
179
  † Held out: no data from this source was used in training.
180
 
181
  ## Architecture
182
 
183
+ ```text
184
+ Audio (16kHz) → Granite Speech encoder (frozen) → MLP projector (trained)
185
+ → Qwen3.5-2B + LoRA (trained adapters) → Text
186
  ```
187
 
188
+ | Component | Model | Parameters | Status |
189
+ | -------------- | ------------------------------------------------------------------------------------------------------- | ---------- | ------- |
190
+ | Audio Encoder | [granite-speech-5.0-470m-turboctc](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc) | ~470M | Frozen |
191
+ | Projector | 2-layer MLP (hidden 4096) | 12.6M | Trained |
192
+ | Language Model | [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B) | ~2B | Frozen |
193
+ | LoRA adapters | r=64, alpha=64, all linear layers | 67.3M | Trained |
194
 
195
  ### How It Works
196
 
197
  1. **Audio encoder**: Granite Speech turns 16kHz audio into frame-level embeddings.
198
+ 1. **Projector**: A 2-layer MLP maps those embeddings into the decoder's embedding space. Each
199
+ projected frame replaces an `<audio>` placeholder token in the prompt.
200
+ 1. **Language model**: Qwen3.5-2B, adapted with LoRA, generates the transcript conditioned on the
201
+ projected audio and the prompt *"Transcribe the speech with proper punctuation and
202
+ capitalization"*.
203
 
204
+ At inference, 0.25s of silence is prepended to each clip (`inference_lead_in_seconds`). This keeps
205
+ the first word from being dropped on clips that start mid-speech.
206
 
207
  ## Model Specifications
208
 
209
+ | Specification | Value |
210
+ | ---------------- | ------------------------------------------------------------------ |
211
+ | Input | Audio (16kHz mono) |
212
+ | Output | Punctuated, capitalized text with formatted numbers |
213
+ | Max Audio Length | ~30 seconds per call |
214
+ | Vocabulary | Qwen3.5 tokenizer |
215
+ | Languages | English only |
216
+ | Generation | Greedy decoding (num_beams=1, do_sample=False), max 256 new tokens |
217
 
218
  ## Training Details
219
 
220
+ | | |
221
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | **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 |
223
+ | **Hardware** | Single NVIDIA H100 80GB |
224
+ | **Steps** | 58,861 |
225
+ | **Batch Size** | 64 |
226
+ | **Optimizer** | AdamW (fused), cosine schedule, 1,000 warmup steps, no weight decay |
227
+ | **Learning Rate** | 1e-3 (projector), 1e-4 (LoRA) |
228
+ | **Precision** | bf16 (projector held in fp32) |
229
 
230
+ The training recipe is
231
+ [`configs/experiments/granite_qwen_frozen.yaml`](https://github.com/alexkroman/tiny-audio/blob/main/configs/experiments/granite_qwen_frozen.yaml).
232
 
233
  ## Limitations
234
 
235
  - **English only**: Not trained on other languages.
236
  - **Sample rate**: Expects 16kHz audio (other rates are resampled automatically).
237
+ - **Audio length**: A plain `pipe(audio)` call decodes the clip in one pass and works best up to
238
+ about 19 seconds (the training length). For longer audio pass `return_timestamps=True` or
239
+ `return_speakers=True`, which transcribe in 8-18 s chunks automatically.
240
+ - **Speaker diarization**: At most 8 speakers per recording. The count is detected automatically;
241
+ `num_speakers` and `max_speakers` can cap it, but `min_speakers` is not supported (passing it
242
+ raises a `ValueError`). Needs transformers `main` until a release includes Nemotron-3-Diarization.
243
  - **Accuracy**: May degrade on:
244
  - Far-field and overlapping speech (see AMI SDM)
245
  - Noisy or low-quality audio
 
247
 
248
  ## Files
249
 
250
+ | File | Description |
251
+ | ------------------------------------------------------------------ | -------------------------------------------------------- |
252
+ | `config.json` | Model configuration |
253
+ | `model.safetensors` | Projector weights (~50MB) |
254
+ | `adapter_config.json` / `adapter_model.safetensors` | LoRA adapters for the decoder (~270MB) |
255
+ | `preprocessor_config.json` | Audio preprocessing config |
256
+ | `tokenizer.json` / `tokenizer_config.json` / `chat_template.jinja` | Tokenizer |
257
+ | `asr_*.py`, `projectors.py`, `alignment.py`, `diarization.py` | Custom model code (loaded with `trust_remote_code=True`) |
258
 
259
+ Only the projector and LoRA weights are stored here. The encoder (Granite Speech) and decoder
260
+ (Qwen3.5-2B) are downloaded from their own Hugging Face repos.
261
 
262
  The previous GLM-ASR + Qwen3-0.6B model is still available at revision `glm-asr-qwen3-0.6b`:
263
 
264
  ```python
265
+ pipe = pipeline(
266
+ "automatic-speech-recognition",
267
+ model="mazesmazes/tiny-audio",
268
+ revision="glm-asr-qwen3-0.6b",
269
+ trust_remote_code=True,
270
+ )
271
  ```
272
 
273
  ## Citation
 
287
  ## Links
288
 
289
  - [GitHub Repository](https://github.com/alexkroman/tiny-audio) - Train your own model
290
+ - [Free 3.5-hour Course](https://github.com/alexkroman/tiny-audio/blob/main/docs/course/0-course-overview.md)
291
+ \- Learn ASR from scratch
292
  - [Live Demo](https://huggingface.co/spaces/mazesmazes/tiny-audio) - Try it in your browser
293
 
294
  ## Acknowledgments
295
 
296
+ - [Granite Speech](https://huggingface.co/ibm-granite/granite-speech-5.0-470m-turboctc) for the
297
+ audio encoder
298
  - [Qwen3.5](https://huggingface.co/Qwen/Qwen3.5-2B) for the language model
299
  - [Qwen3-ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) for word timestamps
300
+ - [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization) for speaker
301
+ diarization
302
+ - The LibriHeavy, People's Speech, Common Voice, GigaSpeech, SPGISpeech, VoxPopuli, AMI, and
303
+ TED-LIUM teams for training data
304
 
305
  ## License
306
 
alignment.py CHANGED
@@ -1,11 +1,45 @@
1
- """Forced alignment for word-level timestamps: Qwen3-ForcedAligner, wav2vec2 as fallback."""
 
 
 
 
 
 
2
 
3
  import numpy as np
 
4
  import torch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  def _get_device() -> str:
8
- """Get best available device for non-transformers models."""
9
  if torch.cuda.is_available():
10
  return "cuda"
11
  if torch.backends.mps.is_available():
@@ -13,235 +47,6 @@ def _get_device() -> str:
13
  return "cpu"
14
 
15
 
16
- class ForcedAligner:
17
- """Lazy-loaded forced aligner for word-level timestamps using torchaudio wav2vec2.
18
-
19
- The CTC Viterbi search itself is `torchaudio.functional.forced_align`; this
20
- class owns the model singleton, the word-level tokenization and the
21
- frame-to-seconds conversion around it.
22
- """
23
-
24
- _bundle = None
25
- _model = None
26
- _labels = None
27
- _dictionary = None
28
-
29
- @classmethod
30
- def get_instance(cls, device: str = "cuda"):
31
- """Get or create the forced alignment model (singleton).
32
-
33
- Args:
34
- device: Device to run model on ("cuda" or "cpu")
35
-
36
- Returns:
37
- Tuple of (model, labels, dictionary)
38
- """
39
- if cls._model is None:
40
- import torchaudio
41
-
42
- cls._bundle = torchaudio.pipelines.WAV2VEC2_ASR_BASE_960H
43
- cls._model = cls._bundle.get_model().to(device)
44
- cls._model.eval()
45
- cls._labels = cls._bundle.get_labels()
46
- cls._dictionary = {c: i for i, c in enumerate(cls._labels)}
47
- return cls._model, cls._labels, cls._dictionary
48
-
49
- @staticmethod
50
- def _align_tokens(
51
- emission: torch.Tensor, tokens: list[int], blank_id: int = 0
52
- ) -> list[tuple[int, float, float]]:
53
- """Viterbi-align `tokens` to `emission` and return one span per token.
54
-
55
- Delegates to `torchaudio.functional.forced_align` (a C++/CUDA CTC
56
- Viterbi) and `merge_tokens`, which collapse the per-frame path into
57
- `(token_id, start_frame, end_frame)` spans with `end_frame` exclusive.
58
-
59
- Guarantees:
60
- - All tokens are emitted exactly once, in order (strictly monotonic).
61
- - When no valid path exists -- more tokens than frames, or an emission
62
- that assigns every path zero probability -- the tokens are spread
63
- uniformly over the frames instead of raising, so a timestamp request
64
- degrades to coarse timings rather than failing the transcription.
65
-
66
- Args:
67
- emission: Log-softmax emission matrix of shape (num_frames, num_classes)
68
- tokens: Target token indices
69
- blank_id: Index of the CTC blank (default 0)
70
- """
71
- import torchaudio.functional as F # noqa: N812
72
-
73
- num_frames = emission.size(0)
74
- num_tokens = len(tokens)
75
- if num_tokens == 0:
76
- return []
77
-
78
- def _uniform() -> list[tuple[int, float, float]]:
79
- frames_per_token = num_frames / num_tokens
80
- return [
81
- (tokens[i], i * frames_per_token, (i + 1) * frames_per_token)
82
- for i in range(num_tokens)
83
- ]
84
-
85
- log_probs = emission.detach().float().unsqueeze(0)
86
- targets = torch.tensor([tokens], dtype=torch.int32, device=log_probs.device)
87
- try:
88
- aligned, scores = F.forced_align(log_probs, targets, blank=blank_id)
89
- except RuntimeError:
90
- # "targets length is too long for CTC": no monotonic path fits.
91
- return _uniform()
92
- if not torch.isfinite(scores).all():
93
- # Every path has -inf log-probability; the returned path is junk.
94
- return _uniform()
95
-
96
- spans = F.merge_tokens(aligned[0], scores[0].exp(), blank=blank_id)
97
- if len(spans) != num_tokens:
98
- return _uniform()
99
- return [(int(span.token), float(span.start), float(span.end)) for span in spans]
100
-
101
- # Offset compensation for Wav2Vec2-BASE systematic bias (in seconds)
102
- # Calibrated on librispeech-alignments dataset
103
- START_OFFSET = 0.06 # Subtract from start times (shift earlier)
104
- END_OFFSET = -0.03 # Add to end times (shift later)
105
-
106
- @staticmethod
107
- def _tokenize_words(
108
- text: str, dictionary: dict, blank_id: int = 0
109
- ) -> tuple[list[str], list[int]]:
110
- """Split `text` into alignable words and the CTC token sequence for them.
111
-
112
- Returns `(words, tokens)` where `tokens` is each word's character ids
113
- joined by the separator id, and `words` lists only the words that
114
- contributed at least one token -- so the token groups the Viterbi path
115
- produces line up 1:1 with `words`.
116
-
117
- That pairing is the whole point. A word can produce no usable tokens in
118
- two ways, and both used to leave an empty group behind while still
119
- emitting the separators on either side of it:
120
-
121
- - every character is outside the label set (digits, "...");
122
- - every character maps to a control id. In wav2vec2's label set index 0
123
- is spelled "-", so a hyphen is the *blank* symbol, and "--" became
124
- two blank target tokens rather than a word.
125
-
126
- Pairing groups against a plain `text.split()` then shifted every later
127
- word onto the previous word's timing and dropped the last one:
128
- "hello -- world" returned `hello, --` with `--` carrying WORLD's frames.
129
-
130
- `blank_id` defaults to 0 to match the `blank_id=0` that `align` passes
131
- to `_align_tokens`.
132
- """
133
- separator_id = dictionary.get("|", dictionary.get(" ", 0))
134
- # Neither may appear as a target token: the separator would split the
135
- # word in two, and the blank is what the trellis emits *between*
136
- # tokens. Either one desyncs the group-to-word pairing.
137
- control_ids = {separator_id, blank_id}
138
- words: list[str] = []
139
- tokens: list[int] = []
140
- for word in text.split():
141
- word_tokens = [
142
- token_id
143
- for c in word.upper()
144
- if (token_id := dictionary.get(c)) is not None and token_id not in control_ids
145
- ]
146
- if not word_tokens:
147
- continue
148
- if tokens:
149
- tokens.append(separator_id)
150
- tokens.extend(word_tokens)
151
- words.append(word)
152
- return words, tokens
153
-
154
- @classmethod
155
- def align(
156
- cls,
157
- audio: np.ndarray | torch.Tensor,
158
- text: str,
159
- sample_rate: int = 16000,
160
- ) -> list[dict]:
161
- """Align transcript to audio and return word-level timestamps.
162
-
163
- Args:
164
- audio: Audio waveform as a numpy array or torch tensor
165
- text: Transcript text to align
166
- sample_rate: Audio sample rate (default 16000)
167
-
168
- Returns:
169
- List of dicts with 'word', 'start', 'end' keys
170
- """
171
- import torchaudio
172
-
173
- device = _get_device()
174
- model, _labels, dictionary = cls.get_instance(device)
175
- assert cls._bundle is not None
176
- assert dictionary is not None
177
-
178
- # Convert audio to tensor (copy to ensure array is writable)
179
- if isinstance(audio, np.ndarray):
180
- waveform = torch.from_numpy(audio.copy()).float()
181
- else:
182
- waveform = audio.clone().float()
183
-
184
- # Ensure 2D (channels, time)
185
- if waveform.dim() == 1:
186
- waveform = waveform.unsqueeze(0)
187
-
188
- # Resample if needed (wav2vec2 expects 16kHz)
189
- if sample_rate != cls._bundle.sample_rate:
190
- waveform = torchaudio.functional.resample(
191
- waveform, sample_rate, cls._bundle.sample_rate
192
- )
193
-
194
- waveform = waveform.to(device)
195
-
196
- # Get emissions from model
197
- with torch.inference_mode():
198
- emissions, _ = model(waveform)
199
- emissions = torch.log_softmax(emissions, dim=-1)
200
-
201
- emission = emissions[0].cpu()
202
-
203
- # Tokenize per word so each Viterbi token group maps back to the word
204
- # it came from; words with no representable characters are dropped.
205
- words, tokens = cls._tokenize_words(text, dictionary)
206
- if not tokens:
207
- return []
208
-
209
- alignment_path = cls._align_tokens(emission, tokens, blank_id=0)
210
-
211
- # Convert frame indices to time (model stride is 320 samples at 16kHz = 20ms)
212
- frame_duration = 320 / cls._bundle.sample_rate
213
-
214
- # Group token spans into words on the separator token. `_tokenize_words`
215
- # guarantees one non-empty group per entry in `words`, so the groups
216
- # zip 1:1 with the word list.
217
- separator_id = dictionary.get("|", dictionary.get(" ", 0))
218
- groups: list[list[tuple[float, float]]] = []
219
- current: list[tuple[float, float]] = []
220
- for token_id, start_frame, end_frame in alignment_path:
221
- if token_id == separator_id:
222
- if current:
223
- groups.append(current)
224
- current = []
225
- else:
226
- current.append((start_frame, end_frame))
227
- if current:
228
- groups.append(current)
229
-
230
- # Apply separate offset compensation for start/end (Wav2Vec2 systematic bias)
231
- word_timestamps = []
232
- for word, frames in zip(words, groups):
233
- start_frame, end_frame = frames[0][0], frames[-1][1]
234
- word_timestamps.append(
235
- {
236
- "word": word,
237
- "start": max(0.0, start_frame * frame_duration - cls.START_OFFSET),
238
- "end": max(0.0, end_frame * frame_duration - cls.END_OFFSET),
239
- }
240
- )
241
-
242
- return word_timestamps
243
-
244
-
245
  class QwenForcedAligner:
246
  """Word timestamps from Qwen3-ForcedAligner-0.6B (transformers >= 5.17).
247
 
@@ -259,67 +64,71 @@ class QwenForcedAligner:
259
  MODEL_ID = "Qwen/Qwen3-ForcedAligner-0.6B-hf"
260
  MAX_SECONDS = 300.0
261
  BATCH_SIZE = 8
262
- _model = None
263
- _processor = None
264
 
265
  @classmethod
266
- def get_instance(cls):
267
- if cls._model is None:
268
- from transformers import AutoProcessor, Qwen3ASRForTokenClassification
269
-
 
270
  device = _get_device()
271
  # Batches are padded, and Metal sdpa returns NaN for fully masked rows.
272
  attn = "eager" if device == "mps" else "sdpa"
273
  model = Qwen3ASRForTokenClassification.from_pretrained(
274
  cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
275
  )
276
- cls._model = model.to(device).eval()
277
- cls._processor = AutoProcessor.from_pretrained(cls.MODEL_ID)
 
 
 
278
  return cls._model, cls._processor
279
 
280
  @staticmethod
281
- def _to_16k(audio, sample_rate: int) -> np.ndarray:
 
282
  if isinstance(audio, torch.Tensor):
283
- audio = audio.cpu().numpy()
284
- audio = np.asarray(audio, dtype=np.float32).reshape(-1)
285
  if sample_rate != 16000:
286
- import torchaudio
287
-
288
- audio = torchaudio.functional.resample(
289
- torch.from_numpy(audio), sample_rate, 16000
290
- ).numpy()
291
- return audio
292
 
293
  @classmethod
294
  @torch.inference_mode()
295
  def align_chunks(
296
  cls,
297
- chunks: list[tuple[np.ndarray, str]],
298
  sample_rate: int = 16000,
299
  language: str = "English",
300
- ) -> list[list[dict]]:
301
  """Align each `(audio, text)` pair (each <= 5 min); one word list per pair."""
302
- from transformers.models.qwen3_asr.processing_qwen3_asr import _clean_tokens
303
-
304
- results: list[list[dict]] = [[] for _ in chunks]
305
- todo = []
306
  for i, (raw, text) in enumerate(chunks):
307
  kept = [w for w in text.split() if _clean_tokens([w])]
308
  if not kept:
309
  continue
310
  audio = cls._to_16k(raw, sample_rate)
311
  if len(audio) / 16000 > cls.MAX_SECONDS:
312
- raise ValueError(
313
  f"chunk {i} is {len(audio) / 16000:.0f} s; Qwen3-ForcedAligner takes at most "
314
  f"{cls.MAX_SECONDS:.0f} s -- cut the audio first"
315
  )
 
316
  todo.append((i, audio, kept))
317
  if not todo: # nothing to time: don't load the model
318
  return results
319
  model, processor = cls.get_instance()
320
- for b in range(0, len(todo), cls.BATCH_SIZE):
321
- batch = todo[b : b + cls.BATCH_SIZE]
322
- inputs, word_lists = processor.prepare_forced_aligner_inputs(
323
  [audio for _, audio, _ in batch],
324
  [" ".join(kept) for _, _, kept in batch],
325
  language=language,
@@ -332,7 +141,7 @@ class QwenForcedAligner:
332
  if v.is_floating_point()
333
  else v.to(model.device)
334
  )
335
- for k, v in inputs.items()
336
  }
337
  logits = model(**inputs).logits
338
  timed = processor.decode_forced_alignment(
@@ -346,6 +155,12 @@ class QwenForcedAligner:
346
  return results
347
 
348
  @classmethod
349
- def align(cls, audio, text: str, sample_rate: int = 16000, language: str = "English"):
350
- """ForcedAligner.align's interface for one clip of at most 5 minutes."""
 
 
 
 
 
 
351
  return cls.align_chunks([(audio, text)], sample_rate, language)[0]
 
1
+ """Forced alignment for word-level timestamps with Qwen3-ForcedAligner."""
2
+
3
+ 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
11
  import torch
12
+ import torchaudio
13
+
14
+ if TYPE_CHECKING:
15
+ from transformers import Qwen3ASRForTokenClassification, Qwen3ASRProcessor
16
+ from transformers.models.qwen3_asr.processing_qwen3_asr import (
17
+ _clean_tokens, # pyright: ignore[reportPrivateUsage] # no public word filter
18
+ )
19
+
20
+ _QWEN3_ASR_IMPORT_ERROR: ImportError | None = None
21
+ else:
22
+ # Qwen3-ForcedAligner needs transformers >= 5.17; keep this module importable
23
+ # on older versions and only fail when alignment is actually requested.
24
+ try:
25
+ from transformers import Qwen3ASRForTokenClassification, Qwen3ASRProcessor
26
+ from transformers.models.qwen3_asr.processing_qwen3_asr import _clean_tokens
27
+
28
+ _QWEN3_ASR_IMPORT_ERROR = None
29
+ except ImportError as e:
30
+ _QWEN3_ASR_IMPORT_ERROR = e
31
+
32
+
33
+ class AlignedWord(TypedDict):
34
+ """One aligned word: the original word and its span in seconds."""
35
+
36
+ word: str
37
+ start: float
38
+ end: float
39
 
40
 
41
  def _get_device() -> str:
42
+ """Get best available device for inference."""
43
  if torch.cuda.is_available():
44
  return "cuda"
45
  if torch.backends.mps.is_available():
 
47
  return "cpu"
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  class QwenForcedAligner:
51
  """Word timestamps from Qwen3-ForcedAligner-0.6B (transformers >= 5.17).
52
 
 
64
  MODEL_ID = "Qwen/Qwen3-ForcedAligner-0.6B-hf"
65
  MAX_SECONDS = 300.0
66
  BATCH_SIZE = 8
67
+ _model: Qwen3ASRForTokenClassification | None = None
68
+ _processor: Qwen3ASRProcessor | None = None
69
 
70
  @classmethod
71
+ def get_instance(cls) -> tuple[Qwen3ASRForTokenClassification, Qwen3ASRProcessor]:
72
+ """Load the aligner model and processor once, then return the cached pair."""
73
+ if cls._model is None or cls._processor is None:
74
+ if _QWEN3_ASR_IMPORT_ERROR is not None:
75
+ raise _QWEN3_ASR_IMPORT_ERROR
76
  device = _get_device()
77
  # Batches are padded, and Metal sdpa returns NaN for fully masked rows.
78
  attn = "eager" if device == "mps" else "sdpa"
79
  model = Qwen3ASRForTokenClassification.from_pretrained(
80
  cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
81
  )
82
+ # PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
83
+ model.to(device) # pyright: ignore[reportArgumentType]
84
+ model.eval()
85
+ cls._model = model
86
+ cls._processor = Qwen3ASRProcessor.from_pretrained(cls.MODEL_ID)
87
  return cls._model, cls._processor
88
 
89
  @staticmethod
90
+ def _to_16k(audio: npt.ArrayLike | torch.Tensor, sample_rate: int) -> npt.NDArray[np.float32]:
91
+ """Flatten `audio` to a float32 mono array resampled to 16 kHz."""
92
  if isinstance(audio, torch.Tensor):
93
+ audio = audio.cpu()
94
+ out: npt.NDArray[np.float32] = np.asarray(audio, dtype=np.float32).reshape(-1)
95
  if sample_rate != 16000:
96
+ out = np.asarray(
97
+ torchaudio.functional.resample(torch.as_tensor(out), sample_rate, 16000),
98
+ dtype=np.float32,
99
+ )
100
+ return out
 
101
 
102
  @classmethod
103
  @torch.inference_mode()
104
  def align_chunks(
105
  cls,
106
+ chunks: Sequence[tuple[npt.ArrayLike | torch.Tensor, str]],
107
  sample_rate: int = 16000,
108
  language: str = "English",
109
+ ) -> list[list[AlignedWord]]:
110
  """Align each `(audio, text)` pair (each <= 5 min); one word list per pair."""
111
+ if _QWEN3_ASR_IMPORT_ERROR is not None:
112
+ raise _QWEN3_ASR_IMPORT_ERROR
113
+ results: list[list[AlignedWord]] = [[] for _ in chunks]
114
+ todo: list[tuple[int, npt.NDArray[np.float32], list[str]]] = []
115
  for i, (raw, text) in enumerate(chunks):
116
  kept = [w for w in text.split() if _clean_tokens([w])]
117
  if not kept:
118
  continue
119
  audio = cls._to_16k(raw, sample_rate)
120
  if len(audio) / 16000 > cls.MAX_SECONDS:
121
+ msg = (
122
  f"chunk {i} is {len(audio) / 16000:.0f} s; Qwen3-ForcedAligner takes at most "
123
  f"{cls.MAX_SECONDS:.0f} s -- cut the audio first"
124
  )
125
+ raise ValueError(msg)
126
  todo.append((i, audio, kept))
127
  if not todo: # nothing to time: don't load the model
128
  return results
129
  model, processor = cls.get_instance()
130
+ for batch in itertools.batched(todo, cls.BATCH_SIZE):
131
+ features, word_lists = processor.prepare_forced_aligner_inputs(
 
132
  [audio for _, audio, _ in batch],
133
  [" ".join(kept) for _, _, kept in batch],
134
  language=language,
 
141
  if v.is_floating_point()
142
  else v.to(model.device)
143
  )
144
+ for k, v in features.items()
145
  }
146
  logits = model(**inputs).logits
147
  timed = processor.decode_forced_alignment(
 
155
  return results
156
 
157
  @classmethod
158
+ def align(
159
+ cls,
160
+ audio: npt.ArrayLike | torch.Tensor,
161
+ text: str,
162
+ sample_rate: int = 16000,
163
+ language: str = "English",
164
+ ) -> list[AlignedWord]:
165
+ """Align one clip of at most 5 minutes; `[{"word", "start", "end"}]`."""
166
  return cls.align_chunks([(audio, text)], sample_rate, language)[0]
asr_attention.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attention-implementation selection for ASRModel's encoder and decoder loaders.
2
+
3
+ Decides which `attn_implementation` each sub-model is loaded with: FA2 only
4
+ where CUDA and a working flash-attn are present and every head fits its
5
+ kernels, eager on MPS for models that build a sliding-window mask, and the
6
+ requested implementation otherwise.
7
+ """
8
+
9
+ import functools
10
+ import importlib
11
+ import logging
12
+
13
+ import torch
14
+ from transformers import AutoConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # FlashAttention's kernels are compiled for head dimensions up to 256.
19
+ FLASH_ATTENTION_MAX_HEAD_DIM = 256
20
+
21
+
22
+ def _max_attention_head_dim(text_config: object) -> int | None:
23
+ """Largest attention head dim across layers, or None if undeterminable.
24
+
25
+ Has to cope with heterogeneous configs: Gemma 4 varies head_dim per layer,
26
+ and reading `config.head_dim` on one of those raises
27
+ AmbiguousGlobalPerLayerAttributeError instead of returning a number, so the
28
+ per-layer list must be consulted explicitly.
29
+ """
30
+ per_layer = getattr(text_config, "per_layer_config", None)
31
+ dims: list[object] = []
32
+ if per_layer:
33
+ dims = [getattr(layer, "head_dim", None) for layer in per_layer]
34
+ else:
35
+ try:
36
+ dims = [getattr(text_config, "head_dim", None)]
37
+ except Exception:
38
+ # Raised by the heterogeneity guard; treated as "unknown".
39
+ dims = []
40
+ usable = [d for d in dims if isinstance(d, int) and d > 0]
41
+ return max(usable) if usable else None
42
+
43
+
44
+ @functools.lru_cache(maxsize=16)
45
+ def _has_sliding_window_attention(model_id: str) -> bool:
46
+ """Whether `model_id`'s text stack has any sliding-window attention layer.
47
+
48
+ Read from the config rather than the loaded module so the answer is
49
+ available before `from_pretrained` picks an attn implementation.
50
+
51
+ Two independent spellings, because transformers has both. Gemma 4 declares
52
+ `sliding_window: 512` AND `layer_types: [full_attention,
53
+ sliding_attention]` (verified against google/gemma-4-E2B-it); Qwen2-style
54
+ configs carry a non-null `sliding_window` that is inert unless
55
+ `use_sliding_window` is true, so that flag has to veto the window.
56
+
57
+ Returns True when the config can't be read, so an unknown architecture
58
+ keeps the conservative eager path on MPS.
59
+ """
60
+ try:
61
+ probe = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
62
+ text_config = probe.get_text_config() if hasattr(probe, "get_text_config") else probe
63
+ except Exception:
64
+ logger.warning(
65
+ "Could not read the config for %s to check for sliding-window "
66
+ "attention; assuming it has some and using eager attention on MPS.",
67
+ model_id,
68
+ )
69
+ return True
70
+
71
+ layer_types: list[object] = getattr(text_config, "layer_types", None) or []
72
+ if any("sliding" in str(layer_type) for layer_type in layer_types):
73
+ return True
74
+ if getattr(text_config, "use_sliding_window", None) is False:
75
+ return False
76
+ return bool(getattr(text_config, "sliding_window", None))
77
+
78
+
79
+ def resolve_attn_implementation(requested: str | None, model_id: str | None = None) -> str | None:
80
+ """Coerce flash_attention_2 to sdpa when CUDA isn't available, and avoid sdpa on MPS.
81
+
82
+ FA2 is CUDA-only. On MPS/CPU, requesting it either errors at load or
83
+ silently falls back to a slower path; either way the user pays the FA2
84
+ install + import cost for no win. Coerce here so a saved config that
85
+ pins flash_attention_2 still loads on Mac / CPU-only Linux boxes.
86
+
87
+ MPS needs eager for SOME models. PyTorch's Metal sdpa kernel returns wrong
88
+ results for cached single-token decode against a sliding-window mask, which
89
+ is exactly Gemma 4's layout (`sliding_window=512`, four sliding layers per
90
+ full-attention layer). A full-sequence forward is fine, so the damage shows
91
+ up only during generation: greedy decode with no cache transcribed
92
+ correctly while the identical cached decode produced "Mr. and a".
93
+
94
+ The mask is the trigger, so the coercion is scoped to models that actually
95
+ build one. `model_id` opts into that check; without it every MPS load falls
96
+ back to eager, which is what this function used to do unconditionally. That
97
+ blanket version taxed the models it was never protecting: Qwen3.5-2B
98
+ declares `sliding_window: None` and cannot hit the bug, and eager cost it
99
+ 41% of decode throughput on an M4 Max (19.9 -> 28.1 tok/s, bf16, 64 tokens
100
+ from a 200-token prompt). Audio encoders are likewise unaffected -- they run
101
+ full-sequence forwards with no cache at all, and the Granite branch below
102
+ has been pinning sdpa on MPS for every granite_qwen run to date.
103
+
104
+ SECOND, SEPARATE MPS sdpa HAZARD, and the reason `generate` carries a guard:
105
+ Metal's sdpa returns NaN for a row containing any fully-masked position, so
106
+ a LEFT-PADDED batch comes back as NaN logits, argmax 0, and a transcript of
107
+ "!!!!!!". One pad token is enough. Measured on bare transformers with no
108
+ tiny_audio code involved (Qwen3.5-2B, bf16): the identical forward is exact
109
+ to 0.000 on CPU/fp32, and eager is correct on MPS at any padding. Batch 1
110
+ pads nothing, which is why the eval harness is safe under sdpa -- but this
111
+ is what the blanket eager was accidentally also protecting, so anything
112
+ that batches ragged audio on a Mac must ask for eager explicitly. See
113
+ `_assert_sdpa_safe_on_mps`.
114
+ """
115
+ if (
116
+ torch.backends.mps.is_available()
117
+ and requested in (None, "sdpa", "flash_attention_2")
118
+ and (model_id is None or _has_sliding_window_attention(model_id))
119
+ ):
120
+ return "eager"
121
+ # Otherwise fall through: on MPS, sdpa and None (let transformers pick,
122
+ # which is sdpa where available) are both safe for a model with no
123
+ # sliding-window layer, and a config pinning flash_attention_2 is still
124
+ # downgraded to sdpa below.
125
+ if requested != "flash_attention_2":
126
+ return requested
127
+ if not torch.cuda.is_available():
128
+ return "sdpa"
129
+ # CUDA alone isn't enough -- the flash_attn package must actually be
130
+ # importable, and new enough for transformers. It is a source build that
131
+ # routinely fails on RunPod images (its metadata hook imports torch, so any
132
+ # broken torch install takes it down with it). Without this check a CUDA
133
+ # box with no flash-attn raises at from_pretrained instead of quietly
134
+ # using sdpa, which is the same numerics at lower throughput.
135
+ #
136
+ # Looked up on `transformers.utils` at call time rather than bound at
137
+ # import, so patching it there (as the tests do) reaches this probe.
138
+ if not importlib.import_module("transformers.utils").is_flash_attn_2_available():
139
+ logger.warning(
140
+ "flash_attention_2 requested but flash_attn is not installed or too old; "
141
+ "falling back to sdpa."
142
+ )
143
+ return "sdpa"
144
+ return requested
145
+
146
+
147
+ def resolve_decoder_attn_implementation(requested: str | None, model_id: str) -> str | None:
148
+ """`resolve_attn_implementation` for the decoder, plus FlashAttention's head-dim limit.
149
+
150
+ FlashAttention only has kernels for head_dim <= 256, and it fails at
151
+ the first forward rather than at load. Gemma 4 E2B trips this: 7 of
152
+ its 35 layers use head_dim=512 (the other 28 use 256), so FA2 can
153
+ never run this architecture regardless of flash-attn version. Detect
154
+ it from the config and fall back instead of dying a step into
155
+ training.
156
+ """
157
+ attn_implementation = resolve_attn_implementation(requested, model_id)
158
+ if attn_implementation == "flash_attention_2":
159
+ probe = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
160
+ text_probe = probe.get_text_config() if hasattr(probe, "get_text_config") else probe
161
+ head_dim = _max_attention_head_dim(text_probe)
162
+ if head_dim is not None and head_dim > FLASH_ATTENTION_MAX_HEAD_DIM:
163
+ logger.warning(
164
+ "%s has max head_dim=%d, above FlashAttention's limit of %d; "
165
+ "using sdpa for the decoder.",
166
+ model_id,
167
+ head_dim,
168
+ FLASH_ATTENTION_MAX_HEAD_DIM,
169
+ )
170
+ attn_implementation = "sdpa"
171
+ return attn_implementation
asr_config.py CHANGED
@@ -1,9 +1,17 @@
1
  """Configuration for the ASR model: encoder, decoder, projector and training options."""
2
 
 
 
 
3
  import transformers
4
 
 
 
 
 
 
5
  # Default conv layers for Whisper/GLM-ASR audio encoders: [(pad, kernel, stride), ...]
6
- DEFAULT_ENCODER_CONV_LAYERS = [(1, 3, 1), (1, 3, 2)]
7
 
8
  # Granite Speech 5.0 TurboCTC. Its feature extractor already stacks mel frame
9
  # pairs (100 Hz -> 50 Hz, 320-dim output), so the `mel_length` handed to
@@ -14,7 +22,7 @@ DEFAULT_ENCODER_CONV_LAYERS = [(1, 3, 1), (1, 3, 2)]
14
  # generic formula below. Verified against the real encoder at 1/2/5/10/20s
15
  # (T_fe 50/100/250/500/1000 -> 12/25/62/125/250).
16
  # Full path: 100 Hz mel -> 2x FE stacking -> 4x encoder -> 12.5 Hz.
17
- GRANITE_ENCODER_CONV_LAYERS = [(0, 2, 2), (0, 2, 2)]
18
 
19
  # Encoders whose `input_features` are (batch, time, feature_dim) rather than
20
  # Whisper/GLM-ASR's (batch, n_mels, mel_len). Conformer-family checkpoints
@@ -43,7 +51,17 @@ def is_time_major_encoder(audio_model_id: str | None) -> bool:
43
  return any(m in (audio_model_id or "").lower() for m in _TIME_MAJOR_ENCODER_MARKERS)
44
 
45
 
46
- def compute_encoder_output_length(mel_length, conv_layers=None):
 
 
 
 
 
 
 
 
 
 
47
  """Apply encoder conv layer formulas to compute output length.
48
 
49
  Works with both Python ints and torch tensors of mel lengths; the formula
@@ -56,6 +74,13 @@ def compute_encoder_output_length(mel_length, conv_layers=None):
56
  return length
57
 
58
 
 
 
 
 
 
 
 
59
  class ASRConfig(transformers.PretrainedConfig):
60
  """Configuration class for the ASR model.
61
 
@@ -87,7 +112,7 @@ class ASRConfig(transformers.PretrainedConfig):
87
  llm_dim: int | None = None,
88
  # Encoder conv layers: list of (padding, kernel_size, stride) tuples
89
  # Default is Whisper/GLM-ASR structure: conv1(k=3,s=1,p=1) + conv2(k=3,s=2,p=1)
90
- encoder_conv_layers: list | None = None,
91
  audio_sample_rate: int = 16000,
92
  # Whether the encoder takes `input_features` as (batch, time, feature)
93
  # instead of Whisper/GLM-ASR's (batch, n_mels, mel_len). Only
@@ -151,7 +176,7 @@ class ASRConfig(transformers.PretrainedConfig):
151
  lora_rank: int = 8, # SALMONN default
152
  lora_alpha: int = 32, # SALMONN default (scaling factor 4.0)
153
  lora_dropout: float = 0.0,
154
- lora_target_modules: list | None = None, # Default: all linear layers
155
  # Per-module rank/alpha overrides, keyed by the module's leaf name and
156
  # matched by PEFT as `(.*\.)?<key>$` against the full module path.
157
  #
@@ -171,8 +196,8 @@ class ASRConfig(transformers.PretrainedConfig):
171
  # changes the scale: alpha/r goes 128/64 = 2.0 to 128/16 = 8.0. Always
172
  # set both, and keep the ratio equal to the global one unless the
173
  # scale change is the point.
174
- lora_rank_pattern: dict | None = None,
175
- lora_alpha_pattern: dict | None = None,
176
  # Seconds of silence prepended to every clip at INFERENCE. Measured on
177
  # Peoples (n=500, paired bootstrap): 20.51% -> 19.28% WER, delta -1.22
178
  # CI [-1.83, -0.64], with utterances losing a leading reference word
@@ -235,8 +260,8 @@ class ASRConfig(transformers.PretrainedConfig):
235
  max_new_tokens: int | None = None,
236
  use_cache: bool | None = None,
237
  no_repeat_ngram_size: int | None = None,
238
- **kwargs,
239
- ):
240
  """Initialize ASR model configuration.
241
 
242
  Args:
@@ -336,33 +361,34 @@ class ASRConfig(transformers.PretrainedConfig):
336
  value = explicit_generation_args[key]
337
  setattr(self, key, value if value is not None else default)
338
 
 
339
  if "audio_config" not in kwargs:
340
- self.audio_config = transformers.AutoConfig.from_pretrained(audio_model_id)
341
  # Override dtype to match model_dtype
342
- self.audio_config.dtype = model_dtype
343
  else:
344
- self.audio_config = kwargs.pop("audio_config")
 
345
 
 
346
  if "text_config" not in kwargs:
347
- self.text_config = transformers.AutoConfig.from_pretrained(
348
  text_model_id, trust_remote_code=True
349
  )
350
  # Override dtype to match model_dtype
351
- self.text_config.dtype = model_dtype
352
  else:
353
- self.text_config = kwargs.pop("text_config")
354
 
355
- if isinstance(self.text_config, dict):
356
  # Reconstruct config from dict using the model_type stored in the dict
357
- model_type = self.text_config["model_type"]
358
- config_class = transformers.AutoConfig.for_model(model_type).__class__
359
- self.text_config = config_class(**self.text_config)
360
-
361
- if isinstance(self.audio_config, dict):
362
- model_type = self.audio_config.get("model_type")
363
- if model_type:
364
- config_class = transformers.AutoConfig.for_model(model_type).__class__
365
- self.audio_config = config_class(**self.audio_config)
366
 
367
  super().__init__(**kwargs)
368
 
 
1
  """Configuration for the ASR model: encoder, decoder, projector and training options."""
2
 
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING, Any, overload
5
+
6
  import transformers
7
 
8
+ if TYPE_CHECKING:
9
+ import torch
10
+
11
+ ConvLayerSpec = tuple[int, int, int]
12
+
13
  # Default conv layers for Whisper/GLM-ASR audio encoders: [(pad, kernel, stride), ...]
14
+ DEFAULT_ENCODER_CONV_LAYERS: list[ConvLayerSpec] = [(1, 3, 1), (1, 3, 2)]
15
 
16
  # Granite Speech 5.0 TurboCTC. Its feature extractor already stacks mel frame
17
  # pairs (100 Hz -> 50 Hz, 320-dim output), so the `mel_length` handed to
 
22
  # generic formula below. Verified against the real encoder at 1/2/5/10/20s
23
  # (T_fe 50/100/250/500/1000 -> 12/25/62/125/250).
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
 
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
 
74
  return length
75
 
76
 
77
+ def _config_from_dict(model_type: str, values: dict[str, Any]) -> transformers.PretrainedConfig:
78
+ """Rebuild a serialized sub-config as the config class registered for `model_type`."""
79
+ auto_config = transformers.AutoConfig
80
+ default = auto_config.for_model(model_type)
81
+ return default.__class__(**values)
82
+
83
+
84
  class ASRConfig(transformers.PretrainedConfig):
85
  """Configuration class for the ASR model.
86
 
 
112
  llm_dim: int | None = None,
113
  # Encoder conv layers: list of (padding, kernel_size, stride) tuples
114
  # Default is Whisper/GLM-ASR structure: conv1(k=3,s=1,p=1) + conv2(k=3,s=2,p=1)
115
+ encoder_conv_layers: list[ConvLayerSpec] | None = None,
116
  audio_sample_rate: int = 16000,
117
  # Whether the encoder takes `input_features` as (batch, time, feature)
118
  # instead of Whisper/GLM-ASR's (batch, n_mels, mel_len). Only
 
176
  lora_rank: int = 8, # SALMONN default
177
  lora_alpha: int = 32, # SALMONN default (scaling factor 4.0)
178
  lora_dropout: float = 0.0,
179
+ lora_target_modules: list[str] | None = None, # Default: all linear layers
180
  # Per-module rank/alpha overrides, keyed by the module's leaf name and
181
  # matched by PEFT as `(.*\.)?<key>$` against the full module path.
182
  #
 
196
  # changes the scale: alpha/r goes 128/64 = 2.0 to 128/16 = 8.0. Always
197
  # set both, and keep the ratio equal to the global one unless the
198
  # scale change is the point.
199
+ lora_rank_pattern: dict[str, int] | None = None,
200
+ lora_alpha_pattern: dict[str, int] | None = None,
201
  # Seconds of silence prepended to every clip at INFERENCE. Measured on
202
  # Peoples (n=500, paired bootstrap): 20.51% -> 19.28% WER, delta -1.22
203
  # CI [-1.83, -0.64], with utterances losing a leading reference word
 
260
  max_new_tokens: int | None = None,
261
  use_cache: bool | None = None,
262
  no_repeat_ngram_size: int | None = None,
263
+ **kwargs: Any,
264
+ ) -> None:
265
  """Initialize ASR model configuration.
266
 
267
  Args:
 
361
  value = explicit_generation_args[key]
362
  setattr(self, key, value if value is not None else default)
363
 
364
+ audio_config: transformers.PretrainedConfig | dict[str, Any]
365
  if "audio_config" not in kwargs:
366
+ audio_config = transformers.AutoConfig.from_pretrained(audio_model_id)
367
  # Override dtype to match model_dtype
368
+ audio_config.dtype = model_dtype
369
  else:
370
+ audio_config = kwargs.pop("audio_config")
371
+ self.audio_config: transformers.PretrainedConfig | dict[str, Any] = audio_config
372
 
373
+ text_config: transformers.PretrainedConfig | dict[str, Any]
374
  if "text_config" not in kwargs:
375
+ text_config = transformers.AutoConfig.from_pretrained(
376
  text_model_id, trust_remote_code=True
377
  )
378
  # Override dtype to match model_dtype
379
+ text_config.dtype = model_dtype
380
  else:
381
+ text_config = kwargs.pop("text_config")
382
 
383
+ if isinstance(text_config, dict):
384
  # Reconstruct config from dict using the model_type stored in the dict
385
+ text_config = _config_from_dict(text_config["model_type"], text_config)
386
+ self.text_config: transformers.PretrainedConfig = text_config
387
+
388
+ if isinstance(audio_config, dict):
389
+ audio_model_type = audio_config.get("model_type")
390
+ if audio_model_type:
391
+ self.audio_config = _config_from_dict(audio_model_type, audio_config)
 
 
392
 
393
  super().__init__(**kwargs)
394
 
asr_layers.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
109
+ """Locate the encoder's ordered transformer-block list.
110
+
111
+ Returns `(attribute_path, ModuleList)` or None. Encoders disagree on the
112
+ attribute name -- Granite Speech uses `layers`, Whisper `encoder.layers`,
113
+ others `blocks` or `encoder_layers` -- so probe the known names rather
114
+ than hardcoding one and silently unfreezing nothing on a new encoder.
115
+ """
116
+ candidates = ("layers", "blocks", "encoder_layers", "encoder.layers", "encoder.blocks")
117
+ for path in candidates:
118
+ obj: nn.Module | None = encoder
119
+ for part in path.split("."):
120
+ obj = getattr(obj, part, None)
121
+ if obj is None:
122
+ break
123
+ if isinstance(obj, nn.ModuleList) and len(obj) > 0:
124
+ return path, obj
125
+ return None
126
+
127
+
128
+ def unfreeze_encoder_top_layers(
129
+ encoder: nn.Module, top_n: int, include_post_projections: bool = True
130
+ ) -> list[str]:
131
+ """Unfreeze the top `top_n` encoder blocks, optionally with the output projections.
132
+
133
+ Assumes the caller has already frozen the whole encoder. Returns the sorted
134
+ names of every parameter switched back on, so callers (and tests) can
135
+ assert on exactly what moved rather than trusting a count.
136
+
137
+ The post-stack projections (Granite's `out` / `out_mid`) are included by
138
+ default: they sit between the top block and the projector, so leaving them
139
+ frozen forces the newly-trainable blocks to adapt through a fixed output
140
+ map. That is a reasonable prior, but on this stack it is not what the
141
+ gradient says. Probing the step-2000 granite_qwen_lora checkpoint on real
142
+ audio, `out`/`out_mid` carry 33.57M params -- 23% of a top-4 budget -- for
143
+ an RMS-per-param gradient of 1.14e-05 against 1.4-1.7e-04 in the top
144
+ blocks, i.e. **0.27% of the selection's squared gradient**. Dropping them
145
+ moved the aggregate norm from 1.5840 to 1.5827. Pass False to reclaim the
146
+ parameters; the default stays True so existing recipes are unchanged.
147
+
148
+ The pre-stack `input_linear` stays frozen either way -- it is the feature
149
+ front-end, the part most expensive to damage and least specialised to the
150
+ encoder's own CTC head.
151
+
152
+ Raises if the layer stack cannot be found or `top_n` exceeds its depth:
153
+ silently unfreezing nothing would produce a run that looks like a
154
+ partial-unfreeze experiment and is actually the frozen baseline.
155
+ """
156
+ if top_n <= 0:
157
+ return []
158
+ found = find_encoder_layer_stack(encoder)
159
+ if found is None:
160
+ msg = (
161
+ f"Could not locate a transformer-block ModuleList on "
162
+ f"{type(encoder).__name__}; cannot unfreeze its top {top_n} layers. "
163
+ f"Children: {[n for n, _ in encoder.named_children()]}"
164
+ )
165
+ raise ValueError(msg)
166
+ path, stack = found
167
+ depth = len(stack)
168
+ if top_n > depth:
169
+ msg = f"encoder_trainable_top_layers={top_n} exceeds the encoder's {depth} blocks"
170
+ raise ValueError(msg)
171
+
172
+ unfrozen: list[str] = []
173
+ for idx in range(depth - top_n, depth):
174
+ for name, param in stack[idx].named_parameters():
175
+ param.requires_grad_(True)
176
+ unfrozen.append(f"{path}.{idx}.{name}")
177
+ # Post-stack projections, identified positionally: direct children that are
178
+ # not the stack itself and not the pre-stack input projection.
179
+ for name, child in encoder.named_children() if include_post_projections else ():
180
+ if name == path.split(".")[0] or name.startswith("input"):
181
+ continue
182
+ for pname, param in child.named_parameters():
183
+ param.requires_grad_(True)
184
+ unfrozen.append(f"{name}.{pname}")
185
+ return sorted(unfrozen)
186
+
187
+
188
+ def chunk_oversized_embeddings(root: nn.Module) -> list[str]:
189
+ """Replace every embedding MPS cannot index with a `ChunkedEmbedding`.
190
+
191
+ Returns the qualified names that were replaced, empty when nothing needed
192
+ it. Idempotent: `ChunkedEmbedding` is not an `nn.Embedding`, so a second
193
+ pass finds nothing.
194
+ """
195
+ replaced: list[str] = []
196
+ for module_name, module in list(root.named_modules()):
197
+ for child_name, child in list(module.named_children()):
198
+ if isinstance(child, nn.Embedding) and child.weight.numel() > MPS_MAX_TENSOR_ELEMENTS:
199
+ setattr(module, child_name, ChunkedEmbedding(child))
200
+ replaced.append(f"{module_name}.{child_name}" if module_name else child_name)
201
+ return replaced
asr_modeling.py CHANGED
@@ -1,156 +1,107 @@
1
  import functools
 
2
  import inspect
3
  import json
4
  import logging
5
- import math
6
- from collections.abc import Iterator
 
 
7
  from pathlib import Path
8
  from threading import Thread
 
9
 
10
  import torch
11
  import torch.nn as nn
12
- import torch.nn.functional as F # noqa: N812
13
- from torch.nn.utils.rnn import pad_sequence
 
14
  from transformers import (
15
- AutoConfig,
16
  AutoModel,
17
  AutoModelForCausalLM,
 
18
  AutoTokenizer,
 
19
  PreTrainedModel,
 
 
20
  TextIteratorStreamer,
 
21
  )
22
- from transformers.generation import GenerationMixin
 
23
  from transformers.modeling_outputs import CausalLMOutputWithPast
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- try:
26
  from .asr_config import ASRConfig, compute_encoder_output_length
27
- from .projectors import PROJECTOR_CLASSES
28
- except ImportError:
29
- from asr_config import ASRConfig, compute_encoder_output_length # type: ignore[no-redef]
30
- from projectors import PROJECTOR_CLASSES # type: ignore[no-redef]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  logger = logging.getLogger(__name__)
34
 
35
- # FlashAttention's kernels are compiled for head dimensions up to 256.
36
- FLASH_ATTENTION_MAX_HEAD_DIM = 256
37
-
38
  # Vocab dimension the embedding table is padded to after adding the audio
39
  # token. 128 is what transformers' own `pad_to_multiple_of` docs recommend for
40
  # tensor cores on sm_75+ and is what Qwen already pads its published tables to.
41
  VOCAB_PAD_MULTIPLE = 128
42
 
43
- # PyTorch's Metal kernels index tensor storage with 32-bit offsets, so a gather
44
- # into a tensor holding more than INT32_MAX elements wraps around and silently
45
- # returns the wrong rows -- no error, no warning, just corrupt values.
46
- MPS_MAX_TENSOR_ELEMENTS = 2**31 - 1
47
-
48
-
49
- def mps_unsafe_parameters(model: nn.Module) -> list[tuple[str, int]]:
50
- """Parameters too large for MPS to index correctly, as (name, numel) pairs.
51
-
52
- Gemma 4 E2B trips this on `embed_tokens_per_layer`: its per-layer embedding
53
- table is 262144 x (35*256) = 2,348,810,240 elements, 201M past INT32_MAX.
54
- On MPS the lookup returns garbage, which Gemma then adds into all 35 layers
55
- at every position. The failure is entirely silent -- the model loads, runs,
56
- and emits fluent, confident, wrong transcripts. Measured on this stack:
57
- teacher-forced loss 0.14 on CPU versus 3.80 on MPS from identical weights.
58
-
59
- Returns an empty list for models that are safe, so callers can treat a
60
- non-empty result as "this model needs `chunk_oversized_embeddings`".
61
- """
62
- return [
63
- (name, param.numel())
64
- for name, param in model.named_parameters()
65
- if param.numel() > MPS_MAX_TENSOR_ELEMENTS
66
- ]
67
-
68
-
69
- class ChunkedEmbedding(nn.Module):
70
- """Drop-in `nn.Embedding` that splits its table to stay within 32-bit indexing.
71
-
72
- The overflow lives in the kernel's arithmetic over the weight's *storage*,
73
- so slicing is not enough -- views share that storage and stay broken. Each
74
- chunk therefore has to be its own contiguous allocation. Gathering per
75
- chunk and concatenating on the feature axis reproduces the full gather
76
- exactly: verified bit-identical to CPU on MPS, where the single-tensor
77
- lookup was off by up to 3.16.
78
-
79
- Wraps rather than replaces the semantics of `Gemma4TextScaledWordEmbedding`,
80
- whose forward multiplies the lookup by `embed_scale` -- dropping that would
81
- shrink every per-layer embedding by sqrt(hidden_size_per_layer_input).
82
- """
83
-
84
- def __init__(self, embedding: nn.Embedding) -> None:
85
- """Split `embedding`'s table into MPS-safe column chunks, keeping its metadata."""
86
- super().__init__()
87
- self.num_embeddings = embedding.num_embeddings
88
- self.embedding_dim = embedding.embedding_dim
89
- self.padding_idx = embedding.padding_idx
90
- # Gemma 4's scaled embedding carries this; a plain nn.Embedding does not.
91
- self.embed_scale = getattr(embedding, "embed_scale", None)
92
-
93
- num_chunks = math.ceil(embedding.weight.numel() / MPS_MAX_TENSOR_ELEMENTS)
94
- device = embedding.weight.device
95
- # Chunk from a CPU copy: carving contiguous pieces out of an
96
- # already-oversized MPS tensor would run the same overflowing kernel
97
- # this class exists to avoid.
98
- source = embedding.weight.detach().cpu()
99
- self.chunks = nn.ParameterList(
100
- [
101
- nn.Parameter(
102
- chunk.contiguous().to(device), requires_grad=embedding.weight.requires_grad
103
- )
104
- for chunk in torch.chunk(source, num_chunks, dim=1)
105
- ]
106
- )
107
- del source
108
-
109
- def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
110
- """Look up `input_ids` in every chunk and concatenate along the feature dim."""
111
- out = torch.cat([F.embedding(input_ids, chunk) for chunk in self.chunks], dim=-1)
112
- if self.embed_scale is not None:
113
- out = out * self.embed_scale.to(out.dtype)
114
- return out
115
-
116
- @property
117
- def weight(self) -> torch.Tensor:
118
- """Reassembled table, for callers that introspect `.weight`.
119
-
120
- Materializes the full tensor, so this is a debugging/serialization
121
- convenience rather than something to call on a hot path.
122
- """
123
- return torch.cat(list(self.chunks), dim=1)
124
 
125
- def extra_repr(self) -> str:
126
- """Describe the table like `nn.Embedding` does, plus the chunk count."""
127
- return (
128
- f"{self.num_embeddings}, {self.embedding_dim}, "
129
- f"chunks={len(self.chunks)} (MPS int32-indexing workaround)"
130
- )
131
-
132
-
133
- def find_encoder_layer_stack(encoder: nn.Module) -> tuple[str, nn.Module] | None:
134
- """Locate the encoder's ordered transformer-block list.
135
-
136
- Returns `(attribute_path, ModuleList)` or None. Encoders disagree on the
137
- attribute name -- Granite Speech uses `layers`, Whisper `encoder.layers`,
138
- others `blocks` or `encoder_layers` -- so probe the known names rather
139
- than hardcoding one and silently unfreezing nothing on a new encoder.
140
- """
141
- candidates = ("layers", "blocks", "encoder_layers", "encoder.layers", "encoder.blocks")
142
- for path in candidates:
143
- obj: nn.Module | None = encoder
144
- for part in path.split("."):
145
- obj = getattr(obj, part, None)
146
- if obj is None:
147
- break
148
- if isinstance(obj, nn.ModuleList) and len(obj) > 0:
149
- return path, obj
150
- return None
151
-
152
-
153
- def _resolve_dtype(name, fallback: torch.dtype) -> torch.dtype:
154
  """Resolve a config dtype name to a `torch.dtype`, falling back when unusable.
155
 
156
  Only a genuine `str` naming a real floating-point dtype is honoured. Stand-in
@@ -168,206 +119,6 @@ def _resolve_dtype(name, fallback: torch.dtype) -> torch.dtype:
168
  )
169
 
170
 
171
- def unfreeze_encoder_top_layers(
172
- encoder: nn.Module, top_n: int, include_post_projections: bool = True
173
- ) -> list[str]:
174
- """Unfreeze the top `top_n` encoder blocks, optionally with the output projections.
175
-
176
- Assumes the caller has already frozen the whole encoder. Returns the sorted
177
- names of every parameter switched back on, so callers (and tests) can
178
- assert on exactly what moved rather than trusting a count.
179
-
180
- The post-stack projections (Granite's `out` / `out_mid`) are included by
181
- default: they sit between the top block and the projector, so leaving them
182
- frozen forces the newly-trainable blocks to adapt through a fixed output
183
- map. That is a reasonable prior, but on this stack it is not what the
184
- gradient says. Probing the step-2000 granite_qwen_lora checkpoint on real
185
- audio, `out`/`out_mid` carry 33.57M params -- 23% of a top-4 budget -- for
186
- an RMS-per-param gradient of 1.14e-05 against 1.4-1.7e-04 in the top
187
- blocks, i.e. **0.27% of the selection's squared gradient**. Dropping them
188
- moved the aggregate norm from 1.5840 to 1.5827. Pass False to reclaim the
189
- parameters; the default stays True so existing recipes are unchanged.
190
-
191
- The pre-stack `input_linear` stays frozen either way -- it is the feature
192
- front-end, the part most expensive to damage and least specialised to the
193
- encoder's own CTC head.
194
-
195
- Raises if the layer stack cannot be found or `top_n` exceeds its depth:
196
- silently unfreezing nothing would produce a run that looks like a
197
- partial-unfreeze experiment and is actually the frozen baseline.
198
- """
199
- if top_n <= 0:
200
- return []
201
- found = find_encoder_layer_stack(encoder)
202
- if found is None:
203
- raise ValueError(
204
- f"Could not locate a transformer-block ModuleList on "
205
- f"{type(encoder).__name__}; cannot unfreeze its top {top_n} layers. "
206
- f"Children: {[n for n, _ in encoder.named_children()]}"
207
- )
208
- path, stack = found
209
- depth = len(stack)
210
- if top_n > depth:
211
- raise ValueError(
212
- f"encoder_trainable_top_layers={top_n} exceeds the encoder's {depth} blocks"
213
- )
214
-
215
- unfrozen: list[str] = []
216
- for idx in range(depth - top_n, depth):
217
- for name, param in stack[idx].named_parameters():
218
- param.requires_grad_(True)
219
- unfrozen.append(f"{path}.{idx}.{name}")
220
- # Post-stack projections, identified positionally: direct children that are
221
- # not the stack itself and not the pre-stack input projection.
222
- for name, child in encoder.named_children() if include_post_projections else ():
223
- if name == path.split(".")[0] or name.startswith("input"):
224
- continue
225
- for pname, param in child.named_parameters():
226
- param.requires_grad_(True)
227
- unfrozen.append(f"{name}.{pname}")
228
- return sorted(unfrozen)
229
-
230
-
231
- def chunk_oversized_embeddings(root: nn.Module) -> list[str]:
232
- """Replace every embedding MPS cannot index with a `ChunkedEmbedding`.
233
-
234
- Returns the qualified names that were replaced, empty when nothing needed
235
- it. Idempotent: `ChunkedEmbedding` is not an `nn.Embedding`, so a second
236
- pass finds nothing.
237
- """
238
- replaced: list[str] = []
239
- for module_name, module in list(root.named_modules()):
240
- for child_name, child in list(module.named_children()):
241
- if isinstance(child, nn.Embedding) and child.weight.numel() > MPS_MAX_TENSOR_ELEMENTS:
242
- setattr(module, child_name, ChunkedEmbedding(child))
243
- replaced.append(f"{module_name}.{child_name}" if module_name else child_name)
244
- return replaced
245
-
246
-
247
- def _max_attention_head_dim(text_config) -> int | None:
248
- """Largest attention head dim across layers, or None if undeterminable.
249
-
250
- Has to cope with heterogeneous configs: Gemma 4 varies head_dim per layer,
251
- and reading `config.head_dim` on one of those raises
252
- AmbiguousGlobalPerLayerAttributeError instead of returning a number, so the
253
- per-layer list must be consulted explicitly.
254
- """
255
- per_layer = getattr(text_config, "per_layer_config", None)
256
- dims: list = []
257
- if per_layer:
258
- dims = [getattr(layer, "head_dim", None) for layer in per_layer]
259
- else:
260
- try:
261
- dims = [getattr(text_config, "head_dim", None)]
262
- except Exception:
263
- # Raised by the heterogeneity guard; treated as "unknown".
264
- dims = []
265
- usable = [d for d in dims if isinstance(d, int) and d > 0]
266
- return max(usable) if usable else None
267
-
268
-
269
- @functools.lru_cache(maxsize=16)
270
- def _has_sliding_window_attention(model_id: str) -> bool:
271
- """Whether `model_id`'s text stack has any sliding-window attention layer.
272
-
273
- Read from the config rather than the loaded module so the answer is
274
- available before `from_pretrained` picks an attn implementation.
275
-
276
- Two independent spellings, because transformers has both. Gemma 4 declares
277
- `sliding_window: 512` AND `layer_types: [full_attention,
278
- sliding_attention]` (verified against google/gemma-4-E2B-it); Qwen2-style
279
- configs carry a non-null `sliding_window` that is inert unless
280
- `use_sliding_window` is true, so that flag has to veto the window.
281
-
282
- Returns True when the config can't be read, so an unknown architecture
283
- keeps the conservative eager path on MPS.
284
- """
285
- try:
286
- probe = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
287
- text_config = probe.get_text_config() if hasattr(probe, "get_text_config") else probe
288
- except Exception:
289
- logger.warning(
290
- "Could not read the config for %s to check for sliding-window "
291
- "attention; assuming it has some and using eager attention on MPS.",
292
- model_id,
293
- )
294
- return True
295
-
296
- layer_types = getattr(text_config, "layer_types", None) or []
297
- if any("sliding" in str(layer_type) for layer_type in layer_types):
298
- return True
299
- if getattr(text_config, "use_sliding_window", None) is False:
300
- return False
301
- return bool(getattr(text_config, "sliding_window", None))
302
-
303
-
304
- def _resolve_attn_implementation(requested: str | None, model_id: str | None = None) -> str | None:
305
- """Coerce flash_attention_2 to sdpa when CUDA isn't available, and avoid sdpa on MPS.
306
-
307
- FA2 is CUDA-only. On MPS/CPU, requesting it either errors at load or
308
- silently falls back to a slower path; either way the user pays the FA2
309
- install + import cost for no win. Coerce here so a saved config that
310
- pins flash_attention_2 still loads on Mac / CPU-only Linux boxes.
311
-
312
- MPS needs eager for SOME models. PyTorch's Metal sdpa kernel returns wrong
313
- results for cached single-token decode against a sliding-window mask, which
314
- is exactly Gemma 4's layout (`sliding_window=512`, four sliding layers per
315
- full-attention layer). A full-sequence forward is fine, so the damage shows
316
- up only during generation: greedy decode with no cache transcribed
317
- correctly while the identical cached decode produced "Mr. and a".
318
-
319
- The mask is the trigger, so the coercion is scoped to models that actually
320
- build one. `model_id` opts into that check; without it every MPS load falls
321
- back to eager, which is what this function used to do unconditionally. That
322
- blanket version taxed the models it was never protecting: Qwen3.5-2B
323
- declares `sliding_window: None` and cannot hit the bug, and eager cost it
324
- 41% of decode throughput on an M4 Max (19.9 -> 28.1 tok/s, bf16, 64 tokens
325
- from a 200-token prompt). Audio encoders are likewise unaffected -- they run
326
- full-sequence forwards with no cache at all, and the Granite branch below
327
- has been pinning sdpa on MPS for every granite_qwen run to date.
328
-
329
- SECOND, SEPARATE MPS sdpa HAZARD, and the reason `generate` carries a guard:
330
- Metal's sdpa returns NaN for a row containing any fully-masked position, so
331
- a LEFT-PADDED batch comes back as NaN logits, argmax 0, and a transcript of
332
- "!!!!!!". One pad token is enough. Measured on bare transformers with no
333
- tiny_audio code involved (Qwen3.5-2B, bf16): the identical forward is exact
334
- to 0.000 on CPU/fp32, and eager is correct on MPS at any padding. Batch 1
335
- pads nothing, which is why the eval harness is safe under sdpa -- but this
336
- is what the blanket eager was accidentally also protecting, so anything
337
- that batches ragged audio on a Mac must ask for eager explicitly. See
338
- `_assert_sdpa_safe_on_mps`.
339
- """
340
- if (
341
- torch.backends.mps.is_available()
342
- and requested in (None, "sdpa", "flash_attention_2")
343
- and (model_id is None or _has_sliding_window_attention(model_id))
344
- ):
345
- return "eager"
346
- # Otherwise fall through: on MPS, sdpa and None (let transformers pick,
347
- # which is sdpa where available) are both safe for a model with no
348
- # sliding-window layer, and a config pinning flash_attention_2 is still
349
- # downgraded to sdpa below.
350
- if requested != "flash_attention_2":
351
- return requested
352
- if not torch.cuda.is_available():
353
- return "sdpa"
354
- # CUDA alone isn't enough -- the flash_attn package must actually be
355
- # importable, and new enough for transformers. It is a source build that
356
- # routinely fails on RunPod images (its metadata hook imports torch, so any
357
- # broken torch install takes it down with it). Without this check a CUDA
358
- # box with no flash-attn raises at from_pretrained instead of quietly
359
- # using sdpa, which is the same numerics at lower throughput.
360
- from transformers.utils import is_flash_attn_2_available
361
-
362
- if not is_flash_attn_2_available():
363
- logger.warning(
364
- "flash_attention_2 requested but flash_attn is not installed or too old; "
365
- "falling back to sdpa."
366
- )
367
- return "sdpa"
368
- return requested
369
-
370
-
371
  def _gather_audio_embeds(
372
  audio_embeds: torch.Tensor,
373
  token_counts: torch.Tensor,
@@ -387,7 +138,7 @@ def _gather_audio_embeds(
387
  _, max_len, _ = audio_embeds.shape
388
  needed = int(token_counts.max().item()) if max_tokens is None else max_tokens
389
  if needed > max_len:
390
- audio_embeds = F.pad(audio_embeds, (0, 0, 0, needed - max_len))
391
  max_len = needed
392
  indices = torch.arange(max_len, device=audio_embeds.device).unsqueeze(0)
393
  mask = indices < token_counts.unsqueeze(1)
@@ -397,7 +148,7 @@ def _gather_audio_embeds(
397
  def _assert_audio_token_counts(
398
  audio_embeds: torch.Tensor,
399
  token_counts: torch.Tensor,
400
- projector,
401
  encoder_valid_lengths: torch.Tensor | None = None,
402
  max_tokens: int | None = None,
403
  ) -> None:
@@ -424,12 +175,13 @@ def _assert_audio_token_counts(
424
  actual = torch.as_tensor(actual, device=token_counts.device).to(torch.long).reshape(-1)
425
  if actual.shape == token_counts.shape and not torch.equal(actual, token_counts):
426
  rows = (actual != token_counts).nonzero().flatten()[:8].tolist()
427
- raise ValueError(
428
  "Audio token count mismatch between prompt and projector. Rows "
429
  f"{rows}: prompt expects {token_counts[rows].tolist()}, encoder+projector "
430
  f"produced {actual[rows].tolist()}. A wrong `encoder_conv_layers` for this "
431
  "encoder is the usual cause."
432
  )
 
433
 
434
  available = audio_embeds.shape[1]
435
  if max_tokens is not None:
@@ -437,14 +189,15 @@ def _assert_audio_token_counts(
437
  else:
438
  needed = int(token_counts.max().item()) if token_counts.numel() else 0
439
  if needed > available:
440
- raise ValueError(
441
  f"Projector produced {available} audio frames but the prompt expects up to "
442
  f"{needed}. Without this check `_gather_audio_embeds` would zero-pad the "
443
  "deficit, silently feeding the decoder zero vectors in place of audio."
444
  )
 
445
 
446
 
447
- def _patch_gemma_decode_loop(model) -> None:
448
  """Make a Gemma4ForCausalLM drop `per_layer_inputs` after the first step.
449
 
450
  `Gemma4ForConditionalGeneration` does this itself; the causal LM does not.
@@ -469,7 +222,9 @@ def _patch_gemma_decode_loop(model) -> None:
469
  # here, since audio only ever reaches the decoder as inputs_embeds.
470
  # `wraps` sets __wrapped__, which inspect.signature follows.
471
  @functools.wraps(inner_prepare)
472
- def prepare_inputs_for_generation(*args, is_first_iteration: bool = False, **kwargs):
 
 
473
  """Drop stale `per_layer_inputs` on every decode step after the first."""
474
  model_inputs = inner_prepare(*args, is_first_iteration=is_first_iteration, **kwargs)
475
  if not is_first_iteration:
@@ -479,7 +234,7 @@ def _patch_gemma_decode_loop(model) -> None:
479
  model.prepare_inputs_for_generation = prepare_inputs_for_generation
480
 
481
 
482
- def _assert_projector_loaded(incompatible_keys, projector_type: str) -> None:
483
  """Fail loudly when a checkpoint's projector doesn't match the built one.
484
 
485
  `save_pretrained` serializes `projector.*` (plus the language model when
@@ -499,7 +254,7 @@ def _assert_projector_loaded(incompatible_keys, projector_type: str) -> None:
499
  if not missing and not unexpected:
500
  return
501
 
502
- detail = []
503
  if missing:
504
  detail.append(f"missing from the checkpoint: {missing}")
505
  if unexpected:
@@ -539,8 +294,6 @@ def _log_linear_attention_backends(level: int = logging.WARNING) -> None:
539
  touch these functions at all. `level` is INFO for an inference load
540
  (`from_pretrained`), where the slower path is not something to act on.
541
  """
542
- import importlib.util
543
-
544
  found = {
545
  pkg: importlib.util.find_spec(mod) is not None
546
  for pkg, mod in (("causal-conv1d", "causal_conv1d"), ("flash-linear-attention", "fla"))
@@ -571,10 +324,16 @@ class ASRModel(PreTrainedModel, GenerationMixin):
571
  TRANSCRIBE_PROMPT = "Transcribe the speech to text"
572
 
573
  @classmethod
574
- def from_pretrained(cls, pretrained_model_name_or_path: str, *args, **kwargs) -> "ASRModel":
 
 
 
 
 
575
  """Load model from pretrained, handling device placement correctly."""
576
- from safetensors.torch import load_file
577
- from transformers.utils.hub import cached_file
 
578
 
579
  config = kwargs.pop("config", None)
580
  if config is None:
@@ -589,7 +348,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
589
  # Load projector weights from safetensors
590
  subfolder = kwargs.get("subfolder")
591
  revision = kwargs.get("revision")
592
- cache_kwargs = {}
593
  if subfolder:
594
  cache_kwargs["subfolder"] = subfolder
595
  if revision:
@@ -618,21 +377,23 @@ class ASRModel(PreTrainedModel, GenerationMixin):
618
  )
619
  if adapter_config_file is not None:
620
  # Load saved adapter weights using the original repo_id/path
621
- # PEFT handles Hub downloads and caching internally
622
- from peft import PeftModel
623
-
624
  # Adapter weights load onto the base model's device. Left to
625
  # PEFT they go straight to "cuda" whenever CUDA reports
626
  # available, which ZeroGPU Spaces do at startup without a
627
  # GPU attached ("No CUDA GPUs are available").
628
  base_device = next(model.language_model.parameters()).device
629
- model.language_model = PeftModel.from_pretrained(
630
  model.language_model,
631
  pretrained_model_name_or_path,
632
  is_trainable=True,
633
  torch_device=str(base_device),
634
  **cache_kwargs,
635
  )
 
 
 
636
  else:
637
  # No saved adapters - initialize fresh LLM LoRA for training.
638
  # __init__ skips _setup_lora while loading, so call it here.
@@ -642,7 +403,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
642
  finally:
643
  cls._is_loading_from_pretrained = False
644
 
645
- def __init__(self, config: ASRConfig, **kwargs) -> None:
646
  """Build encoder, projector, decoder and tokenizer from `config`.
647
 
648
  `**kwargs` are the loader arguments `from_pretrained` forwards (e.g.
@@ -652,15 +413,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
652
 
653
  # Shadows the class attribute when the config names one, so a run that
654
  # trained under a specific instruction decodes under the same one.
655
- if getattr(config, "transcribe_prompt", None) is not None:
656
- self.TRANSCRIBE_PROMPT = config.transcribe_prompt
 
657
  target_dtype = getattr(torch, config.model_dtype)
658
 
659
  # Audio encoder (frozen)
660
  self.audio_tower = self._load_audio_encoder(config, target_dtype)
661
 
662
- # Language model (frozen)
663
- self.language_model = self._load_language_model(config, target_dtype)
 
 
664
 
665
  # Does the decoder's forward take `skip_logits`? Only liger's patched
666
  # `lce_forward` declares it; the stock transformers forward does not.
@@ -706,51 +470,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
706
  # this line left ASRConfig's 12-gram loop guard inert: the value was
707
  # set on the config, serialized into config.json, and never consulted.
708
  self.generation_config.no_repeat_ngram_size = config.no_repeat_ngram_size
709
- # Set EOS tokens, filtering out any that don't exist in the tokenizer.
710
- # `convert_tokens_to_ids` reports "not in vocab" inconsistently: Qwen-
711
- # style tokenizers return None, while Gemma's returns unk_token_id.
712
- # Filtering on None alone left Gemma with eos_token_id=[unk, unk], so
713
- # generation never stopped and every sample ran to max_new_tokens.
714
- #
715
- # The membership test is a name round-trip, NOT `id == unk_token_id`:
716
- # in GPT-2-lineage tokenizers (SmolLM2, Qwen) unk_token *is*
717
- # "<|endoftext|>", so comparing ids would discard a legitimate stop
718
- # token here while fixing Gemma. A token that fell back to unk comes
719
- # back under unk's name; one that is really in the vocab comes back
720
- # as itself.
721
- #
722
- # The name list alone is not enough, and silently produced garbage on
723
- # Gemma 4: that template dropped Gemma 2/3's "<end_of_turn>" for a new
724
- # "<|turn>role\n ... <turn|>\n" scheme, so all three probes missed and
725
- # eos collapsed to the bare "<eos>" -- a token the template never
726
- # emits. Training taught the model to close its turn with "<turn|>"
727
- # (id 106) and generation then ignored it, running every sample to
728
- # max_new_tokens and burying the transcript under 250 copies of
729
- # "<turn|>" that `skip_special_tokens` silently swallowed.
730
- #
731
- # So derive the real terminator from the template instead of guessing
732
- # its name: render an assistant turn holding a sentinel and take the
733
- # first token that follows it. That token is the turn closer by
734
- # construction, for any decoder, including ones released after this
735
- # code was written. The name probes stay as a fallback for tokenizers
736
- # whose template renders no trailing special token.
737
- eos_ids: list[int] = []
738
- derived = self._derive_turn_end_token_id()
739
- if derived is not None:
740
- eos_ids.append(derived)
741
- for token in ("<|im_end|>", "<|endoftext|>", "<end_of_turn>"):
742
- token_id = self.tokenizer.convert_tokens_to_ids(token)
743
- if token_id is None or self.tokenizer.convert_ids_to_tokens(token_id) != token:
744
- continue
745
- if token_id not in eos_ids:
746
- eos_ids.append(token_id)
747
- # The chat-template stop tokens above are additions, not replacements:
748
- # the tokenizer's own EOS is always a valid stop and is the only one
749
- # left for a decoder using none of those three templates.
750
- tokenizer_eos = self.tokenizer.eos_token_id
751
- if tokenizer_eos is not None and tokenizer_eos not in eos_ids:
752
- eos_ids.append(tokenizer_eos)
753
- self.generation_config.eos_token_id = eos_ids
754
  self.generation_config.pad_token_id = self.tokenizer.pad_token_id
755
 
756
  # Feature extractor for audio preprocessing
@@ -784,17 +504,17 @@ class ASRModel(PreTrainedModel, GenerationMixin):
784
  # For model parallelism
785
  self._no_split_modules = getattr(self.language_model, "_no_split_modules", [])
786
 
787
- def _create_feature_extractor(self, config: ASRConfig):
788
  """Create the appropriate feature extractor for the audio encoder."""
789
- from transformers import AutoFeatureExtractor
790
-
791
- feature_extractor = AutoFeatureExtractor.from_pretrained(config.audio_model_id)
792
  # Whisper's encoder requires a fixed 3000 mel frames (30s) and the
793
  # feature extractor pads to that by default — leave it alone. Other
794
  # encoders (e.g. GLM-ASR) accept variable-length input, so we disable
795
  # padding to avoid wasting compute on silent frames.
796
  if "whisper" not in config.audio_model_id.lower():
797
- feature_extractor.padding = False
798
  return feature_extractor
799
 
800
  @classmethod
@@ -807,17 +527,16 @@ class ASRModel(PreTrainedModel, GenerationMixin):
807
  from its pretrained features). See `ASRTrainer.create_optimizer` for the
808
  LR routing.
809
  """
810
- encoder_kwargs = {
811
- "attn_implementation": _resolve_attn_implementation(
812
  config.attn_implementation, config.audio_model_id
813
  ),
814
  "low_cpu_mem_usage": True,
815
  "dtype": dtype,
816
  }
817
 
 
818
  if "whisper" in config.audio_model_id.lower():
819
- from transformers import WhisperModel
820
-
821
  full_model = WhisperModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
822
  encoder = full_model.encoder
823
  del full_model
@@ -832,15 +551,21 @@ class ASRModel(PreTrainedModel, GenerationMixin):
832
  # checkpoint ships modeling .py files but its config.json has no
833
  # `auto_map`, so Auto* classes can't resolve them on older
834
  # versions -- they fail with KeyError('granite_speech5_ctc').
835
- from transformers import GraniteSpeech5Encoder
 
 
 
 
 
836
 
837
  # Granite's block-attention implementation has no FA2 kernel and
838
  # raises ValueError on attn_implementation="flash_attention_2".
839
- # _resolve_attn_implementation only downgrades FA2 when CUDA is
840
  # absent, so on a CUDA box the inherited default would hard-fail
841
  # at load. Pin sdpa regardless of what the config asks for.
842
- granite_kwargs = {**encoder_kwargs, "attn_implementation": "sdpa"}
843
- encoder = GraniteSpeech5Encoder.from_pretrained(config.audio_model_id, **granite_kwargs)
 
844
  elif "glm" in config.audio_model_id.lower():
845
  # GLM-ASR stores its encoder at audio_tower (GlmAsrEncoder), but
846
  # which object owns that attribute depends on the transformers
@@ -851,21 +576,21 @@ class ASRModel(PreTrainedModel, GenerationMixin):
851
  # flat checkpoints hung them off the top-level model. Resolve the
852
  # owner instead of assuming, so neither layout AttributeErrors
853
  # at load.
854
- from transformers import AutoModelForSeq2SeqLM
855
- from transformers import __version__ as transformers_version
856
-
857
- full_model = AutoModelForSeq2SeqLM.from_pretrained(
858
  config.audio_model_id, trust_remote_code=True, **encoder_kwargs
859
  )
860
- inner = getattr(full_model, "model", None)
861
- holder = inner if hasattr(inner, "audio_tower") else full_model
 
 
862
  if not hasattr(holder, "audio_tower"):
863
- raise AttributeError(
864
- f"{type(full_model).__name__} exposes no audio_tower at "
865
  "`.audio_tower` or `.model.audio_tower`; GLM-ASR encoder "
866
  f"extraction needs updating for transformers "
867
  f"{transformers_version}."
868
  )
 
869
  encoder = holder.audio_tower
870
  # Drop the LLM decoder and projector to free their VRAM. These must
871
  # go through the same owner as audio_tower: assigning None to a name
@@ -873,7 +598,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
873
  # a plain attribute and frees nothing.
874
  holder.language_model = None
875
  holder.multi_modal_projector = None
876
- del full_model
877
  else:
878
  encoder = AutoModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
879
 
@@ -886,7 +611,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
886
  # `encoder_dtype` overrides the stack dtype when the encoder has
887
  # trainable blocks; it must be fp32 for Adam's step to survive
888
  # rounding. See ASRConfig.encoder_dtype for the arithmetic.
889
- encoder = encoder.to(dtype=_resolve_dtype(getattr(config, "encoder_dtype", None), dtype))
 
 
890
  if getattr(config, "freeze_audio_encoder", True):
891
  encoder.requires_grad_(False)
892
  encoder.train(False) # equivalent to .eval(); avoids a security hook false-positive
@@ -928,34 +655,12 @@ class ASRModel(PreTrainedModel, GenerationMixin):
928
  return encoder
929
 
930
  @classmethod
931
- def _load_language_model(cls, config: ASRConfig, dtype: torch.dtype) -> PreTrainedModel:
932
  """Load and freeze the language model."""
933
- attn_implementation = _resolve_attn_implementation(
934
- config.attn_implementation, config.text_model_id
935
- )
936
-
937
- # FlashAttention only has kernels for head_dim <= 256, and it fails at
938
- # the first forward rather than at load. Gemma 4 E2B trips this: 7 of
939
- # its 35 layers use head_dim=512 (the other 28 use 256), so FA2 can
940
- # never run this architecture regardless of flash-attn version. Detect
941
- # it from the config and fall back instead of dying a step into
942
- # training.
943
- if attn_implementation == "flash_attention_2":
944
- probe = AutoConfig.from_pretrained(config.text_model_id, trust_remote_code=True)
945
- text_probe = probe.get_text_config() if hasattr(probe, "get_text_config") else probe
946
- head_dim = _max_attention_head_dim(text_probe)
947
- if head_dim is not None and head_dim > FLASH_ATTENTION_MAX_HEAD_DIM:
948
- logger.warning(
949
- "%s has max head_dim=%d, above FlashAttention's limit of %d; "
950
- "using sdpa for the decoder.",
951
- config.text_model_id,
952
- head_dim,
953
- FLASH_ATTENTION_MAX_HEAD_DIM,
954
- )
955
- attn_implementation = "sdpa"
956
-
957
- decoder_kwargs = {
958
- "attn_implementation": attn_implementation,
959
  "trust_remote_code": True,
960
  "low_cpu_mem_usage": True,
961
  "dtype": dtype,
@@ -983,7 +688,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
983
  logging.INFO if cls._is_loading_from_pretrained else logging.WARNING
984
  )
985
 
986
- decoder = AutoModelForCausalLM.from_pretrained(config.text_model_id, **decoder_kwargs)
 
 
 
987
 
988
  # Gemma 4 checkpoints are natively multimodal: AutoModelForCausalLM maps
989
  # `gemma4` to Gemma4ForConditionalGeneration, which is both wasteful
@@ -1005,7 +713,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1005
  # See _load_audio_encoder note: idempotent post-load cast to dodge the
1006
  # FA2 "current dype is fp32" warning when from_pretrained's dtype kwarg
1007
  # isn't fully propagated to every submodule.
1008
- decoder = decoder.to(dtype=dtype)
 
 
1009
  decoder.config.use_cache = getattr(config, "use_cache", True)
1010
  if getattr(config, "freeze_language_model", True):
1011
  decoder.requires_grad_(False)
@@ -1013,7 +723,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1013
  return decoder
1014
 
1015
  @staticmethod
1016
- def _gemma_text_only(loaded: PreTrainedModel) -> PreTrainedModel:
1017
  """Turn a loaded Gemma4ForConditionalGeneration into a text-only CausalLM.
1018
 
1019
  Two independent problems force this transplant rather than just loading
@@ -1042,14 +752,17 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1042
  returned text-only model holds 4,628,569,344 parameters against the
1043
  checkpoint's 5,123,178,979, so ~494M are released.
1044
  """
1045
- from transformers.models.gemma4.modeling_gemma4 import Gemma4ForCausalLM
 
 
1046
 
1047
- text_config = loaded.config.get_text_config()
 
1048
  with torch.device("meta"):
1049
  shell = Gemma4ForCausalLM(text_config)
1050
 
1051
- shell.model = loaded.model.language_model
1052
- shell.lm_head = loaded.lm_head
1053
  shell.generation_config = loaded.generation_config
1054
  shell.tie_weights()
1055
 
@@ -1064,18 +777,22 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1064
  # Patched on the instance rather than via a subclass: subclassing
1065
  # Gemma4ForCausalLM trips transformers' experts-implementation check
1066
  # ("does not support setting experts implementation") during __init__.
1067
- _patch_gemma_decode_loop(shell)
 
 
 
1068
 
1069
  stranded = [n for n, t in shell.named_parameters() if t.device.type == "meta"]
1070
  stranded += [n for n, t in shell.named_buffers() if t.device.type == "meta"]
1071
  if stranded:
1072
- raise RuntimeError(
1073
  "Gemma text-only conversion left parameters on the meta device "
1074
  f"(would silently produce garbage): {stranded[:5]}"
1075
  )
1076
- return shell
 
1077
 
1078
- def _create_projector(self, config: ASRConfig, dtype: torch.dtype) -> nn.Module:
1079
  """Create the trainable audio projector."""
1080
  # Auto-detect dimensions if not specified
1081
  if config.encoder_dim is None:
@@ -1084,7 +801,8 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1084
  enc_cfg, "d_model", None
1085
  )
1086
  if config.encoder_dim is None:
1087
- raise ValueError("Could not auto-detect encoder_dim. Please specify in config.")
 
1088
 
1089
  if config.llm_dim is None:
1090
  dec_cfg = self.language_model.config
@@ -1096,16 +814,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1096
  dec_cfg, "d_model", None
1097
  )
1098
  if config.llm_dim is None:
1099
- raise ValueError("Could not auto-detect llm_dim. Please specify in config.")
 
1100
 
1101
  # Select projector type based on config
1102
  projector_type = getattr(config, "projector_type", "mlp")
1103
  projector_class = PROJECTOR_CLASSES.get(projector_type)
1104
  if projector_class is None:
1105
- raise ValueError(
1106
  f"Unknown projector_type: {projector_type}. "
1107
  f"Valid options: {list(PROJECTOR_CLASSES.keys())}"
1108
  )
 
1109
  projector = projector_class(config)
1110
 
1111
  # Move projector to same device as language model (important when using quantization)
@@ -1117,10 +837,8 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1117
  self._projector_dtype = proj_dtype
1118
  return projector.to(device=device, dtype=proj_dtype)
1119
 
1120
- def _setup_lora(self, config: ASRConfig):
1121
  """Apply LoRA adapters to the language model for Stage 2 fine-tuning."""
1122
- from peft import LoraConfig, get_peft_model
1123
-
1124
  lora_config = LoraConfig(
1125
  r=config.lora_rank,
1126
  lora_alpha=config.lora_alpha,
@@ -1136,11 +854,17 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1136
  bias="none",
1137
  task_type="CAUSAL_LM",
1138
  )
1139
- self.language_model = get_peft_model(self.language_model, lora_config)
 
 
 
 
1140
 
1141
- def _init_tokenizer(self, config: ASRConfig):
1142
  """Initialize tokenizer with audio token."""
1143
- self.tokenizer = AutoTokenizer.from_pretrained(config.text_model_id, trust_remote_code=True)
 
 
1144
 
1145
  # Set pad token. Prefer a dedicated pad token if the tokenizer has one
1146
  # (e.g. Qwen's <|finetune_right_pad_id|>); otherwise fall back to
@@ -1164,7 +888,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1164
  already_in_vocab = existing_id is not None and existing_id != self.tokenizer.unk_token_id
1165
 
1166
  if not already_in_vocab:
1167
- existing_special = getattr(self.tokenizer, "additional_special_tokens", None) or []
 
 
1168
  self.tokenizer.add_special_tokens(
1169
  {"additional_special_tokens": [*existing_special, self.audio_token]}
1170
  )
@@ -1203,7 +929,12 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1203
  mean_resizing=True,
1204
  )
1205
 
1206
- self.audio_token_id = self.tokenizer.convert_tokens_to_ids(self.audio_token)
 
 
 
 
 
1207
  self.tokenizer.padding_side = "right"
1208
 
1209
  # Sync token IDs to configs
@@ -1213,6 +944,58 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1213
  cfg.eos_token_id = self.tokenizer.eos_token_id
1214
  cfg.bos_token_id = self.tokenizer.bos_token_id
1215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1216
  def _derive_turn_end_token_id(self) -> int | None:
1217
  """Return the token id the chat template uses to close an assistant turn.
1218
 
@@ -1243,9 +1026,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1243
  return None
1244
  if not isinstance(rendered, str) or sentinel not in rendered:
1245
  return None
1246
- tail_ids = self.tokenizer(rendered.split(sentinel)[-1], add_special_tokens=False)[
1247
- "input_ids"
1248
- ]
1249
  if not tail_ids:
1250
  return None
1251
  # Require a special token: a template that ends the turn with plain
@@ -1254,7 +1037,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1254
  first = tail_ids[0]
1255
  return first if first in set(self.tokenizer.all_special_ids) else None
1256
 
1257
- def _apply(self, *args, **kwargs):
1258
  """Repair MPS-unsafe embeddings whenever the model lands on MPS.
1259
 
1260
  Device placement happens after construction -- `pipeline` builds the
@@ -1268,7 +1051,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1268
  full speed. Chunking keeps the model on the GPU -- falling back to CPU
1269
  would be correct but costs roughly an order of magnitude in latency.
1270
  """
1271
- module = super()._apply(*args, **kwargs)
1272
  try:
1273
  on_mps = any(p.device.type == "mps" for p in module.parameters())
1274
  except StopIteration: # pragma: no cover - parameterless model
@@ -1281,7 +1064,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1281
  )
1282
  return module
1283
 
1284
- def train(self, mode: bool = True):
1285
  """Set train/eval mode, but keep frozen submodules out of train mode.
1286
 
1287
  HF Trainer calls `model.train()` at the top of every training step, which
@@ -1325,7 +1108,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1325
  # "Norm"). This mirrors what the partial-unfreeze path already gets
1326
  # for free via the `freeze_audio_encoder: true` branch above.
1327
  for module in self.audio_tower.modules():
1328
- if isinstance(module, torch.nn.modules.batchnorm._BatchNorm):
 
 
 
1329
  module.eval()
1330
  if getattr(self.config, "freeze_language_model", True):
1331
  self.language_model.train(False)
@@ -1333,10 +1119,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1333
  def _set_gradient_checkpointing(
1334
  self,
1335
  enable: bool = True,
1336
- gradient_checkpointing_func=None,
1337
  every_n_layers: int = 1,
1338
- **kwargs,
1339
- ):
1340
  """Enable/disable gradient checkpointing on the trainable submodules.
1341
 
1342
  Routes the request to whichever components are actually trainable in
@@ -1359,9 +1145,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1359
  Upstream sniffs for it to detect the pre-4.35 checkpointing format and
1360
  would silently take the legacy `self.apply(...)` path instead.
1361
  """
1362
- import inspect
1363
-
1364
- forwardable = dict(kwargs)
1365
  forwardable["every_n_layers"] = every_n_layers
1366
  if gradient_checkpointing_func is not None:
1367
  forwardable["gradient_checkpointing_func"] = gradient_checkpointing_func
@@ -1370,16 +1154,16 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1370
  # Gradient checkpointing trades compute for memory by recomputing activations.
1371
  for submodule in self._gradient_checkpointing_targets():
1372
  setter = getattr(submodule, "_set_gradient_checkpointing", None)
 
 
1373
  if setter is not None:
1374
  accepted = inspect.signature(setter).parameters
1375
  passthrough = {k: v for k, v in forwardable.items() if k in accepted}
1376
  setter(enable=enable, **passthrough)
1377
- elif hasattr(submodule, "gradient_checkpointing_enable") and enable:
1378
- submodule.gradient_checkpointing_enable(
1379
- gradient_checkpointing_kwargs={"use_reentrant": False}
1380
- )
1381
- elif hasattr(submodule, "gradient_checkpointing_disable") and not enable:
1382
- submodule.gradient_checkpointing_disable()
1383
 
1384
  def _gradient_checkpointing_targets(self) -> list[nn.Module]:
1385
  """Return the submodules that should respond to gradient_checkpointing
@@ -1401,21 +1185,16 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1401
  """Replace the decoder's token embedding module."""
1402
  self.language_model.set_input_embeddings(value)
1403
 
1404
- def get_output_embeddings(self) -> nn.Module:
1405
- """Return the decoder's LM head."""
1406
  return self.language_model.get_output_embeddings()
1407
 
1408
- def set_output_embeddings(self, value: nn.Module) -> None:
1409
  """Replace the decoder's LM head."""
1410
- self.language_model.set_output_embeddings(value)
1411
 
1412
- def get_processor(self):
1413
  """Get the processor for this model."""
1414
- try:
1415
- from .asr_processing import ASRProcessor
1416
- except ImportError:
1417
- from asr_processing import ASRProcessor # type: ignore[no-redef]
1418
-
1419
  return ASRProcessor(
1420
  feature_extractor=self.feature_extractor,
1421
  tokenizer=self.tokenizer,
@@ -1424,7 +1203,21 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1424
  audio_token=self.audio_token,
1425
  )
1426
 
1427
- def state_dict(self, *args, **kwargs) -> dict[str, torch.Tensor]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1428
  """Save trainable weights: projector, plus the encoder/LM when unfrozen.
1429
 
1430
  Every module this returns is gated on the same freeze flag that decides
@@ -1445,7 +1238,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1445
  `freeze_text_embed_tokens` freezes an embedding that must still
1446
  round-trip with the rest of the LM.
1447
  """
1448
- sd = {f"projector.{k}": v for k, v in self.projector.state_dict().items()}
 
 
1449
  # Keyed on autograd state, not `freeze_audio_encoder`. Under the
1450
  # documented partial-unfreeze recipe that flag stays True while the top
1451
  # N blocks train, so gating on it meant a trained encoder was never
@@ -1458,14 +1253,14 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1458
  sd.update(
1459
  {
1460
  f"audio_tower.{k}": v
1461
- for k, v in self.audio_tower.state_dict().items()
1462
  if fully or k in enc_trainable
1463
  }
1464
  )
1465
  if not getattr(self.config, "freeze_language_model", True):
1466
  lm = self.language_model
1467
  if hasattr(lm, "peft_config"):
1468
- for name, v in lm.state_dict().items():
1469
  if "lora_" in name:
1470
  continue
1471
  k = name.removeprefix("base_model.model.")
@@ -1473,10 +1268,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1473
  k = k.replace(".base_layer.", ".")
1474
  sd[f"language_model.{k}"] = v
1475
  else:
1476
- sd.update({f"language_model.{k}": v for k, v in lm.state_dict().items()})
1477
- return sd
 
 
 
 
 
 
 
 
1478
 
1479
- def _ple_text_model(self):
1480
  """Locate the submodule owning `get_per_layer_inputs`, or None.
1481
 
1482
  Only Gemma 4 style decoders have one. The attribute sits on
@@ -1490,10 +1293,11 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1490
  )
1491
  for candidate in candidates:
1492
  if candidate is not None and hasattr(candidate, "get_per_layer_inputs"):
1493
- return candidate
 
1494
  return None
1495
 
1496
- def _per_layer_kwargs(self, input_ids: torch.Tensor | None) -> dict:
1497
  """Precompute Gemma 4 per-layer embeddings (PLE) from clean `input_ids`.
1498
 
1499
  Gemma 4 builds a token-identity PLE component by looking `input_ids` up
@@ -1519,6 +1323,11 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1519
  return {}
1520
  return {"per_layer_inputs": text_model.get_per_layer_inputs(input_ids, None)}
1521
 
 
 
 
 
 
1522
  def _compute_encoder_output_lengths(
1523
  self,
1524
  audio_attention_mask: torch.Tensor,
@@ -1626,7 +1435,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1626
  mask `mask_length` frames forward from each), but reimplemented in
1627
  pure torch so it stays inside the autograd graph without crossing
1628
  the numpy boundary. This avoids inductor codegen failures
1629
- (e.g. the `‘zuf0’ was not declared` error from the prior numpy ->
1630
  torch.tensor round-trip) AND avoids the per-forward host-to-GPU
1631
  sync that the numpy path required.
1632
 
@@ -1673,12 +1482,13 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1673
  valid_lengths = None
1674
  if attention_mask is not None:
1675
  if attention_mask.shape[-1] != sequence_length:
1676
- raise ValueError(
1677
  "SpecAugment attention_mask time axis "
1678
  f"({attention_mask.shape[-1]}) does not match input_features "
1679
  f"({sequence_length}). Masking against the wrong axis is silent, "
1680
  "so this is raised rather than ignored."
1681
  )
 
1682
  valid_lengths = attention_mask.to(device).sum(dim=-1)
1683
 
1684
  if getattr(config, "mask_time_prob", 0.0) > 0:
@@ -1747,7 +1557,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1747
  (mask_prob * lengths.float() / mask_length + 0.5).long(), min=min_masks
1748
  ) # (B,)
1749
 
1750
- # Sample start positions independently per sample × span, inside that
1751
  # sample's own valid range. Clamp so a span of length mask_length never
1752
  # starts past the end of short content. torch.rand is [0, 1), so the
1753
  # floor below never reaches max_start.
@@ -1780,11 +1590,11 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1780
  use_cache: bool | None = None,
1781
  cache_position: torch.Tensor | None = None,
1782
  audio_token_counts: torch.Tensor | None = None,
1783
- **kwargs,
1784
  ) -> CausalLMOutputWithPast:
1785
  """Forward pass for training and inference."""
1786
  if inputs_embeds is None:
1787
- inputs_embeds = self.language_model.get_input_embeddings()(input_ids)
1788
 
1789
  # Gemma 4 PLE: derived from input_ids, so it must be captured before the
1790
  # masked_scatter below replaces the audio rows. No-op ({}) elsewhere.
@@ -1869,7 +1679,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1869
  if labels is not None and self._lm_accepts_skip_logits:
1870
  kwargs.setdefault("skip_logits", True)
1871
 
1872
- return self.language_model(
1873
  attention_mask=attention_mask,
1874
  position_ids=position_ids,
1875
  past_key_values=past_key_values,
@@ -1880,8 +1690,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1880
  **ple_kwargs,
1881
  **kwargs,
1882
  )
 
1883
 
1884
- def prepare_inputs_for_generation(self, *args, **kwargs):
1885
  """Prepare inputs for generation, handling audio features for cached decoding."""
1886
  input_features = kwargs.pop("input_features", None)
1887
  cache_position = kwargs.get("cache_position")
@@ -1926,7 +1737,10 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1926
  return_tensors="pt",
1927
  enable_thinking=False, # Disable Qwen3 thinking mode for ASR
1928
  )
1929
- ids = chat_result.input_ids
 
 
 
1930
  return (ids[0] if ids.dim() > 1 else ids).to(torch.long)
1931
 
1932
  def _left_pad_prompt_rows(
@@ -1934,23 +1748,12 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1934
  rows: list[torch.Tensor],
1935
  device: torch.device,
1936
  ) -> tuple[torch.Tensor, torch.Tensor]:
1937
- """Stack per-sample prompt rows into a left-padded batch.
1938
 
1939
- Left, not right: these feed `generate`, and right padding would sit
1940
- between the prompt and the first generated token. Pad positions never
1941
- collide with `audio_token_id`, so the masked_scatter below is unaffected.
1942
  """
1943
- pad_id = self.tokenizer.pad_token_id
1944
- if pad_id is None:
1945
- pad_id = self.tokenizer.eos_token_id or 0
1946
- input_ids = pad_sequence(
1947
- rows, batch_first=True, padding_value=int(pad_id), padding_side="left"
1948
- )
1949
- # The mask is padded from ones rather than derived from `input_ids !=
1950
- # pad_id`: a real token may equal `pad_id` when pad falls back to eos.
1951
- attention_mask = pad_sequence(
1952
- [torch.ones_like(row) for row in rows], batch_first=True, padding_side="left"
1953
- )
1954
  return input_ids.to(device), attention_mask.to(device)
1955
 
1956
  def _prepare_audio_inputs(
@@ -1991,7 +1794,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1991
  input_ids, attention_mask = self._left_pad_prompt_rows(rows, device)
1992
 
1993
  # Get text embeddings and replace audio tokens with audio embeddings
1994
- inputs_embeds = self.language_model.get_input_embeddings()(input_ids)
1995
  audio_token_mask = (input_ids == self.audio_token_id).unsqueeze(-1)
1996
  inputs_embeds = inputs_embeds.masked_scatter(
1997
  audio_token_mask.to(inputs_embeds.device),
@@ -2012,16 +1815,19 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2012
 
2013
  Batch 1 and uniform-length batches pad nothing and pass. `eager` is
2014
  correct on MPS at any padding, and the bug is Metal-specific, so a CUDA
2015
- box is unaffected either way. `_resolve_attn_implementation` documents
2016
  the measurements.
2017
  """
2018
  if attention_mask is None or attention_mask.device.type != "mps":
2019
  return
2020
- if self.language_model.config._attn_implementation != "sdpa":
 
 
 
2021
  return
2022
  if not bool((attention_mask == 0).any()):
2023
  return
2024
- raise ValueError(
2025
  "Refusing to generate: this is a left-padded batch of "
2026
  f"{attention_mask.shape[0]} on MPS with sdpa attention, where "
2027
  "Metal's kernel returns NaN for the padded rows and every one of "
@@ -2030,26 +1836,40 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2030
  "to decode), batch only equal-length clips, or generate one clip "
2031
  "at a time."
2032
  )
 
2033
 
2034
- @torch.no_grad()
2035
  def generate(
2036
  self,
 
2037
  input_ids: torch.Tensor | None = None,
2038
  input_features: torch.Tensor | None = None,
2039
  audio_attention_mask: torch.Tensor | None = None,
2040
  attention_mask: torch.Tensor | None = None,
2041
- **generate_kwargs,
2042
- ):
2043
  """Generate transcription from audio input.
2044
 
2045
  Can be called in two ways:
2046
  1. With input_ids containing <audio> tokens (from processor)
2047
  2. With just audio, and we build the prompt internally
 
 
 
 
2048
  """
 
 
 
 
 
 
2049
  if input_features is None:
2050
- raise ValueError("input_features required for generation")
 
2051
  if audio_attention_mask is None:
2052
- raise ValueError("audio_attention_mask required for generation")
 
2053
 
2054
  input_ids, attention_mask, inputs_embeds = self._prepare_audio_inputs(
2055
  input_features,
@@ -2064,36 +1884,22 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2064
  # Pull any score-related flags out of generate_kwargs and apply them to
2065
  # a derived generation_config so they actually take effect.
2066
  gen_cfg = self.generation_config
2067
- score_flags = {}
2068
  for flag in ("output_scores", "output_logits", "return_dict_in_generate"):
2069
  if flag in generate_kwargs:
2070
  score_flags[flag] = generate_kwargs.pop(flag)
2071
  if score_flags:
2072
- from copy import copy as _copy
2073
-
2074
- gen_cfg = _copy(self.generation_config)
2075
  for flag, value in score_flags.items():
2076
  setattr(gen_cfg, flag, value)
2077
  # output_scores requires return_dict_in_generate for HF generate to
2078
  # actually populate .scores on the output object.
2079
- if gen_cfg.output_scores and not gen_cfg.return_dict_in_generate:
 
 
2080
  gen_cfg.return_dict_in_generate = True
2081
 
2082
- # Generate using language model.
2083
- #
2084
- # Default path passes both input_ids and inputs_embeds so
2085
- # repetition_penalty works correctly (it needs input_ids to track which
2086
- # tokens have been used). Gemma 4 forbids that combination outright --
2087
- # "You must specify exactly one of input_ids or inputs_embeds" -- so
2088
- # there we pass embeds plus the PLE precomputed from the clean prompt
2089
- # ids. Gemma's prepare_inputs_for_generation drops per_layer_inputs
2090
- # after the first step, so it is correct to hand it to generate().
2091
- ple_kwargs = self._per_layer_kwargs(input_ids)
2092
- lm_inputs = (
2093
- {"inputs_embeds": inputs_embeds, **ple_kwargs}
2094
- if ple_kwargs
2095
- else {"input_ids": input_ids, "inputs_embeds": inputs_embeds}
2096
- )
2097
  output = self.language_model.generate(
2098
  **lm_inputs,
2099
  attention_mask=attention_mask,
@@ -2110,15 +1916,35 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2110
  # compatibility with existing callers.
2111
  prompt_len = input_ids.shape[1] if "input_ids" in lm_inputs else 0
2112
  if isinstance(output, torch.Tensor):
2113
- return output[:, prompt_len:]
2114
- output.sequences = output.sequences[:, prompt_len:]
 
2115
  return output
2116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2117
  def generate_streaming(
2118
  self,
2119
  input_features: torch.Tensor,
2120
  audio_attention_mask: torch.Tensor,
2121
- **generate_kwargs,
2122
  ) -> Iterator[str]:
2123
  """Generate transcription with streaming token output.
2124
 
@@ -2145,13 +1971,13 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2145
  )
2146
 
2147
  # Prepare generation kwargs
2148
- gen_kwargs = {
2149
- "inputs_embeds": inputs_embeds,
 
 
2150
  "attention_mask": attention_mask,
2151
  "generation_config": self.generation_config,
2152
  "streamer": streamer,
2153
- # Gemma 4 needs PLE precomputed from the prompt ids; {} elsewhere.
2154
- **self._per_layer_kwargs(input_ids),
2155
  **generate_kwargs,
2156
  }
2157
 
@@ -2192,10 +2018,13 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2192
 
2193
  thread.join()
2194
 
2195
- def save_pretrained(self, save_directory: str | Path, **kwargs) -> None:
2196
- """Save model, tokenizer, and processor."""
2197
- import shutil
 
2198
 
 
 
2199
  save_dir = Path(save_directory)
2200
  save_dir.mkdir(parents=True, exist_ok=True)
2201
 
@@ -2212,15 +2041,23 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2212
  self.config.vocab_size = vocab_size
2213
  self.config.text_config.vocab_size = vocab_size
2214
 
2215
- if hasattr(self.audio_tower.config, "num_mel_bins"):
2216
- self.config.audio_config.num_mel_bins = self.audio_tower.config.num_mel_bins
 
 
 
 
2217
 
2218
  # Save model (temporarily remove non-serializable attributes)
2219
  tokenizer = self.tokenizer
2220
  del self.tokenizer
2221
 
2222
  try:
2223
- super().save_pretrained(save_dir, **kwargs)
 
 
 
 
2224
  finally:
2225
  self.tokenizer = tokenizer
2226
 
@@ -2259,6 +2096,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2259
 
2260
  # Add processor auto_map to preprocessor_config.json
2261
  config_path = save_dir / "preprocessor_config.json"
 
2262
  if config_path.exists():
2263
  with config_path.open() as f:
2264
  processor_config = json.load(f)
@@ -2308,7 +2146,22 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2308
  if card_fn is not None:
2309
  card_fn(str(output_dir))
2310
 
2311
- def push_to_hub(self, repo_id: str, **kwargs) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2312
  """Push model to HuggingFace Hub, ensuring adapter_config points to repo.
2313
 
2314
  IMPORTANT: Sets base_model_name_or_path in adapter_config.json to repo_id
@@ -2318,7 +2171,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2318
  # Store repo_id in config so save_pretrained can access it
2319
  self.config.pretrained_model_path = repo_id
2320
  # Call parent's push_to_hub
2321
- return super().push_to_hub(repo_id, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
2322
 
2323
 
2324
  # Register with transformers Auto classes
 
1
  import functools
2
+ import importlib.util
3
  import inspect
4
  import json
5
  import logging
6
+ import shutil
7
+ from collections.abc import Callable, Iterator
8
+ from copy import copy
9
+ from os import PathLike
10
  from pathlib import Path
11
  from threading import Thread
12
+ from typing import TYPE_CHECKING, Any, Self, cast, overload
13
 
14
  import torch
15
  import torch.nn as nn
16
+ from peft import LoraConfig, PeftModel, get_peft_model
17
+ from safetensors.torch import load_file
18
+ from torch.nn import functional
19
  from transformers import (
20
+ AutoFeatureExtractor,
21
  AutoModel,
22
  AutoModelForCausalLM,
23
+ AutoModelForSeq2SeqLM,
24
  AutoTokenizer,
25
+ BatchEncoding,
26
  PreTrainedModel,
27
+ PreTrainedTokenizerBase,
28
+ SequenceFeatureExtractor,
29
  TextIteratorStreamer,
30
+ WhisperModel,
31
  )
32
+ from transformers import __version__ as transformers_version
33
+ from transformers.generation.utils import GenerateOutput, GenerationMixin
34
  from transformers.modeling_outputs import CausalLMOutputWithPast
35
+ from transformers.utils.hub import (
36
+ cached_file,
37
+ )
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 ASRProcessor, left_pad_prompt_rows
51
+ from .asr_types import (
52
+ DecoderLoadKwargs,
53
+ GenerativeDecoder,
54
+ HubFileKwargs,
55
+ LoadKwargs,
56
+ LoadStateDictResult,
57
+ OutputLengthProjector,
58
+ StateDictT,
59
+ )
60
+ from .projectors import PROJECTOR_CLASSES, MLPAudioProjector
61
+
62
+ # None at runtime on a transformers without the architecture; see below.
63
+ GraniteSpeech5Encoder: type[_GraniteSpeech5Encoder] | None
64
+ Gemma4ForCausalLM: type[_Gemma4ForCausalLM] | None
65
+ else:
66
+ try:
67
+ from .asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
68
+ from .asr_config import ASRConfig, compute_encoder_output_length
69
+ from .asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
70
+ from .asr_processing import ASRProcessor, left_pad_prompt_rows
71
+ from .asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
72
+ from .projectors import PROJECTOR_CLASSES
73
+ except ImportError: # flat layout on the Hub: sibling modules, no package
74
+ from asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
75
+ from asr_config import ASRConfig, compute_encoder_output_length
76
+ from asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
77
+ from asr_processing import ASRProcessor, left_pad_prompt_rows
78
+ from asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
79
+ from projectors import PROJECTOR_CLASSES
80
+
81
+ # Architectures newer than the transformers floor in pyproject.toml. Kept
82
+ # optional so a checkpoint that needs neither still loads on an older
83
+ # install; the branch that needs one raises at its use site instead.
84
+ try:
85
+ from transformers import GraniteSpeech5Encoder
86
+ except ImportError: # transformers < 5.16: no native granite_speech5
87
+ GraniteSpeech5Encoder = None
88
+ try:
89
+ from transformers.models.gemma4.configuration_gemma4 import Gemma4TextConfig
90
+ from transformers.models.gemma4.modeling_gemma4 import Gemma4ForCausalLM
91
+ except ImportError: # transformers without the gemma4 architecture
92
+ Gemma4TextConfig = None
93
+ Gemma4ForCausalLM = None
94
 
95
 
96
  logger = logging.getLogger(__name__)
97
 
 
 
 
98
  # Vocab dimension the embedding table is padded to after adding the audio
99
  # token. 128 is what transformers' own `pad_to_multiple_of` docs recommend for
100
  # tensor cores on sm_75+ and is what Qwen already pads its published tables to.
101
  VOCAB_PAD_MULTIPLE = 128
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ def _resolve_dtype(name: object, fallback: torch.dtype) -> torch.dtype:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  """Resolve a config dtype name to a `torch.dtype`, falling back when unusable.
106
 
107
  Only a genuine `str` naming a real floating-point dtype is honoured. Stand-in
 
119
  )
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def _gather_audio_embeds(
123
  audio_embeds: torch.Tensor,
124
  token_counts: torch.Tensor,
 
138
  _, max_len, _ = audio_embeds.shape
139
  needed = int(token_counts.max().item()) if max_tokens is None else max_tokens
140
  if needed > max_len:
141
+ audio_embeds = functional.pad(audio_embeds, (0, 0, 0, needed - max_len))
142
  max_len = needed
143
  indices = torch.arange(max_len, device=audio_embeds.device).unsqueeze(0)
144
  mask = indices < token_counts.unsqueeze(1)
 
148
  def _assert_audio_token_counts(
149
  audio_embeds: torch.Tensor,
150
  token_counts: torch.Tensor,
151
+ projector: OutputLengthProjector,
152
  encoder_valid_lengths: torch.Tensor | None = None,
153
  max_tokens: int | None = None,
154
  ) -> None:
 
175
  actual = torch.as_tensor(actual, device=token_counts.device).to(torch.long).reshape(-1)
176
  if actual.shape == token_counts.shape and not torch.equal(actual, token_counts):
177
  rows = (actual != token_counts).nonzero().flatten()[:8].tolist()
178
+ msg = (
179
  "Audio token count mismatch between prompt and projector. Rows "
180
  f"{rows}: prompt expects {token_counts[rows].tolist()}, encoder+projector "
181
  f"produced {actual[rows].tolist()}. A wrong `encoder_conv_layers` for this "
182
  "encoder is the usual cause."
183
  )
184
+ raise ValueError(msg)
185
 
186
  available = audio_embeds.shape[1]
187
  if max_tokens is not None:
 
189
  else:
190
  needed = int(token_counts.max().item()) if token_counts.numel() else 0
191
  if needed > available:
192
+ msg = (
193
  f"Projector produced {available} audio frames but the prompt expects up to "
194
  f"{needed}. Without this check `_gather_audio_embeds` would zero-pad the "
195
  "deficit, silently feeding the decoder zero vectors in place of audio."
196
  )
197
+ raise ValueError(msg)
198
 
199
 
200
+ def _patch_gemma_decode_loop(model: "GenerativeDecoder") -> None:
201
  """Make a Gemma4ForCausalLM drop `per_layer_inputs` after the first step.
202
 
203
  `Gemma4ForConditionalGeneration` does this itself; the causal LM does not.
 
222
  # here, since audio only ever reaches the decoder as inputs_embeds.
223
  # `wraps` sets __wrapped__, which inspect.signature follows.
224
  @functools.wraps(inner_prepare)
225
+ def prepare_inputs_for_generation(
226
+ *args: Any, is_first_iteration: bool = False, **kwargs: Any
227
+ ) -> dict[str, Any]:
228
  """Drop stale `per_layer_inputs` on every decode step after the first."""
229
  model_inputs = inner_prepare(*args, is_first_iteration=is_first_iteration, **kwargs)
230
  if not is_first_iteration:
 
234
  model.prepare_inputs_for_generation = prepare_inputs_for_generation
235
 
236
 
237
+ def _assert_projector_loaded(incompatible_keys: LoadStateDictResult, projector_type: str) -> None:
238
  """Fail loudly when a checkpoint's projector doesn't match the built one.
239
 
240
  `save_pretrained` serializes `projector.*` (plus the language model when
 
254
  if not missing and not unexpected:
255
  return
256
 
257
+ detail: list[str] = []
258
  if missing:
259
  detail.append(f"missing from the checkpoint: {missing}")
260
  if unexpected:
 
294
  touch these functions at all. `level` is INFO for an inference load
295
  (`from_pretrained`), where the slower path is not something to act on.
296
  """
 
 
297
  found = {
298
  pkg: importlib.util.find_spec(mod) is not None
299
  for pkg, mod in (("causal-conv1d", "causal_conv1d"), ("flash-linear-attention", "fla"))
 
324
  TRANSCRIBE_PROMPT = "Transcribe the speech to text"
325
 
326
  @classmethod
327
+ def from_pretrained(
328
+ cls,
329
+ pretrained_model_name_or_path: str | PathLike[str] | None,
330
+ *args: Any,
331
+ **kwargs: Any,
332
+ ) -> "ASRModel":
333
  """Load model from pretrained, handling device placement correctly."""
334
+ if pretrained_model_name_or_path is None:
335
+ msg = "ASRModel.from_pretrained needs a checkpoint directory or Hub repo id"
336
+ raise ValueError(msg)
337
 
338
  config = kwargs.pop("config", None)
339
  if config is None:
 
348
  # Load projector weights from safetensors
349
  subfolder = kwargs.get("subfolder")
350
  revision = kwargs.get("revision")
351
+ cache_kwargs: HubFileKwargs = {}
352
  if subfolder:
353
  cache_kwargs["subfolder"] = subfolder
354
  if revision:
 
377
  )
378
  if adapter_config_file is not None:
379
  # Load saved adapter weights using the original repo_id/path
380
+ # PEFT handles Hub downloads and caching internally.
381
+ #
 
382
  # Adapter weights load onto the base model's device. Left to
383
  # PEFT they go straight to "cuda" whenever CUDA reports
384
  # available, which ZeroGPU Spaces do at startup without a
385
  # GPU attached ("No CUDA GPUs are available").
386
  base_device = next(model.language_model.parameters()).device
387
+ peft = PeftModel.from_pretrained(
388
  model.language_model,
389
  pretrained_model_name_or_path,
390
  is_trainable=True,
391
  torch_device=str(base_device),
392
  **cache_kwargs,
393
  )
394
+ # See `language_model` in __init__: PEFT forwards the
395
+ # decoder interface, so callers keep using it unchanged.
396
+ model.language_model = cast("GenerativeDecoder", peft)
397
  else:
398
  # No saved adapters - initialize fresh LLM LoRA for training.
399
  # __init__ skips _setup_lora while loading, so call it here.
 
403
  finally:
404
  cls._is_loading_from_pretrained = False
405
 
406
+ def __init__(self, config: ASRConfig, **kwargs: Any) -> None:
407
  """Build encoder, projector, decoder and tokenizer from `config`.
408
 
409
  `**kwargs` are the loader arguments `from_pretrained` forwards (e.g.
 
413
 
414
  # Shadows the class attribute when the config names one, so a run that
415
  # trained under a specific instruction decodes under the same one.
416
+ prompt = getattr(config, "transcribe_prompt", None)
417
+ if prompt is not None:
418
+ self.TRANSCRIBE_PROMPT = prompt # pyright: ignore[reportConstantRedefinition]
419
  target_dtype = getattr(torch, config.model_dtype)
420
 
421
  # Audio encoder (frozen)
422
  self.audio_tower = self._load_audio_encoder(config, target_dtype)
423
 
424
+ # Language model (frozen). Typed as the decoder even with LoRA attached:
425
+ # a PeftModel forwards every attribute it does not define to the
426
+ # decoder it wraps, so the decoder's interface is the right static view.
427
+ self.language_model: GenerativeDecoder = self._load_language_model(config, target_dtype)
428
 
429
  # Does the decoder's forward take `skip_logits`? Only liger's patched
430
  # `lce_forward` declares it; the stock transformers forward does not.
 
470
  # this line left ASRConfig's 12-gram loop guard inert: the value was
471
  # set on the config, serialized into config.json, and never consulted.
472
  self.generation_config.no_repeat_ngram_size = config.no_repeat_ngram_size
473
+ self.generation_config.eos_token_id = self._resolve_eos_token_ids()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  self.generation_config.pad_token_id = self.tokenizer.pad_token_id
475
 
476
  # Feature extractor for audio preprocessing
 
504
  # For model parallelism
505
  self._no_split_modules = getattr(self.language_model, "_no_split_modules", [])
506
 
507
+ def _create_feature_extractor(self, config: ASRConfig) -> SequenceFeatureExtractor:
508
  """Create the appropriate feature extractor for the audio encoder."""
509
+ feature_extractor: SequenceFeatureExtractor = AutoFeatureExtractor.from_pretrained(
510
+ config.audio_model_id
511
+ )
512
  # Whisper's encoder requires a fixed 3000 mel frames (30s) and the
513
  # feature extractor pads to that by default — leave it alone. Other
514
  # encoders (e.g. GLM-ASR) accept variable-length input, so we disable
515
  # padding to avoid wasting compute on silent frames.
516
  if "whisper" not in config.audio_model_id.lower():
517
+ feature_extractor.padding = False # pyright: ignore[reportAttributeAccessIssue]
518
  return feature_extractor
519
 
520
  @classmethod
 
527
  from its pretrained features). See `ASRTrainer.create_optimizer` for the
528
  LR routing.
529
  """
530
+ encoder_kwargs: LoadKwargs = {
531
+ "attn_implementation": resolve_attn_implementation(
532
  config.attn_implementation, config.audio_model_id
533
  ),
534
  "low_cpu_mem_usage": True,
535
  "dtype": dtype,
536
  }
537
 
538
+ encoder: nn.Module
539
  if "whisper" in config.audio_model_id.lower():
 
 
540
  full_model = WhisperModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
541
  encoder = full_model.encoder
542
  del full_model
 
551
  # checkpoint ships modeling .py files but its config.json has no
552
  # `auto_map`, so Auto* classes can't resolve them on older
553
  # versions -- they fail with KeyError('granite_speech5_ctc').
554
+ if GraniteSpeech5Encoder is None:
555
+ msg = (
556
+ "Granite Speech 5.0 encoders need transformers >= 5.16 for the "
557
+ f"native granite_speech5 architecture; found {transformers_version}."
558
+ )
559
+ raise ImportError(msg)
560
 
561
  # Granite's block-attention implementation has no FA2 kernel and
562
  # raises ValueError on attn_implementation="flash_attention_2".
563
+ # resolve_attn_implementation only downgrades FA2 when CUDA is
564
  # absent, so on a CUDA box the inherited default would hard-fail
565
  # at load. Pin sdpa regardless of what the config asks for.
566
+ granite_kwargs: LoadKwargs = {**encoder_kwargs, "attn_implementation": "sdpa"}
567
+ granite = GraniteSpeech5Encoder
568
+ encoder = granite.from_pretrained(config.audio_model_id, **granite_kwargs)
569
  elif "glm" in config.audio_model_id.lower():
570
  # GLM-ASR stores its encoder at audio_tower (GlmAsrEncoder), but
571
  # which object owns that attribute depends on the transformers
 
576
  # flat checkpoints hung them off the top-level model. Resolve the
577
  # owner instead of assuming, so neither layout AttributeErrors
578
  # at load.
579
+ glm_model = AutoModelForSeq2SeqLM.from_pretrained(
 
 
 
580
  config.audio_model_id, trust_remote_code=True, **encoder_kwargs
581
  )
582
+ inner = getattr(glm_model, "model", None)
583
+ holder: Any = (
584
+ inner if inner is not None and hasattr(inner, "audio_tower") else glm_model
585
+ )
586
  if not hasattr(holder, "audio_tower"):
587
+ msg = (
588
+ f"{type(glm_model).__name__} exposes no audio_tower at "
589
  "`.audio_tower` or `.model.audio_tower`; GLM-ASR encoder "
590
  f"extraction needs updating for transformers "
591
  f"{transformers_version}."
592
  )
593
+ raise AttributeError(msg)
594
  encoder = holder.audio_tower
595
  # Drop the LLM decoder and projector to free their VRAM. These must
596
  # go through the same owner as audio_tower: assigning None to a name
 
598
  # a plain attribute and frees nothing.
599
  holder.language_model = None
600
  holder.multi_modal_projector = None
601
+ del glm_model
602
  else:
603
  encoder = AutoModel.from_pretrained(config.audio_model_id, **encoder_kwargs)
604
 
 
611
  # `encoder_dtype` overrides the stack dtype when the encoder has
612
  # trainable blocks; it must be fp32 for Adam's step to survive
613
  # rounding. See ASRConfig.encoder_dtype for the arithmetic.
614
+ encoder_dtype = _resolve_dtype(getattr(config, "encoder_dtype", None), dtype)
615
+ # PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
616
+ encoder = encoder.to(dtype=encoder_dtype) # pyright: ignore[reportCallIssue]
617
  if getattr(config, "freeze_audio_encoder", True):
618
  encoder.requires_grad_(False)
619
  encoder.train(False) # equivalent to .eval(); avoids a security hook false-positive
 
655
  return encoder
656
 
657
  @classmethod
658
+ def _load_language_model(cls, config: ASRConfig, dtype: torch.dtype) -> "GenerativeDecoder":
659
  """Load and freeze the language model."""
660
+ decoder_kwargs: DecoderLoadKwargs = {
661
+ "attn_implementation": resolve_decoder_attn_implementation(
662
+ config.attn_implementation, config.text_model_id
663
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  "trust_remote_code": True,
665
  "low_cpu_mem_usage": True,
666
  "dtype": dtype,
 
688
  logging.INFO if cls._is_loading_from_pretrained else logging.WARNING
689
  )
690
 
691
+ decoder = cast(
692
+ "GenerativeDecoder",
693
+ AutoModelForCausalLM.from_pretrained(config.text_model_id, **decoder_kwargs),
694
+ )
695
 
696
  # Gemma 4 checkpoints are natively multimodal: AutoModelForCausalLM maps
697
  # `gemma4` to Gemma4ForConditionalGeneration, which is both wasteful
 
713
  # See _load_audio_encoder note: idempotent post-load cast to dodge the
714
  # FA2 "current dype is fp32" warning when from_pretrained's dtype kwarg
715
  # isn't fully propagated to every submodule.
716
+ # `to` returns the module itself, so the cast happens in place.
717
+ # PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
718
+ decoder.to(dtype=dtype) # pyright: ignore[reportCallIssue]
719
  decoder.config.use_cache = getattr(config, "use_cache", True)
720
  if getattr(config, "freeze_language_model", True):
721
  decoder.requires_grad_(False)
 
723
  return decoder
724
 
725
  @staticmethod
726
+ def _gemma_text_only(loaded: "GenerativeDecoder") -> "GenerativeDecoder":
727
  """Turn a loaded Gemma4ForConditionalGeneration into a text-only CausalLM.
728
 
729
  Two independent problems force this transplant rather than just loading
 
752
  returned text-only model holds 4,628,569,344 parameters against the
753
  checkpoint's 5,123,178,979, so ~494M are released.
754
  """
755
+ if Gemma4ForCausalLM is None:
756
+ msg = f"Gemma 4 decoders need the gemma4 architecture; found {transformers_version}."
757
+ raise ImportError(msg)
758
 
759
+ # Only reached for gemma-4 checkpoints, whose text config is Gemma4TextConfig.
760
+ text_config = cast(Gemma4TextConfig, loaded.config.get_text_config())
761
  with torch.device("meta"):
762
  shell = Gemma4ForCausalLM(text_config)
763
 
764
+ shell.model = cast("Gemma4TextModel", loaded.get_submodule("model.language_model"))
765
+ shell.lm_head = cast(nn.Linear, loaded.get_submodule("lm_head"))
766
  shell.generation_config = loaded.generation_config
767
  shell.tie_weights()
768
 
 
777
  # Patched on the instance rather than via a subclass: subclassing
778
  # Gemma4ForCausalLM trips transformers' experts-implementation check
779
  # ("does not support setting experts implementation") during __init__.
780
+ # Gemma4ForCausalLM is a PreTrainedModel with GenerationMixin, which is
781
+ # all `GenerativeDecoder` describes.
782
+ decoder = cast("GenerativeDecoder", shell)
783
+ _patch_gemma_decode_loop(decoder)
784
 
785
  stranded = [n for n, t in shell.named_parameters() if t.device.type == "meta"]
786
  stranded += [n for n, t in shell.named_buffers() if t.device.type == "meta"]
787
  if stranded:
788
+ msg = (
789
  "Gemma text-only conversion left parameters on the meta device "
790
  f"(would silently produce garbage): {stranded[:5]}"
791
  )
792
+ raise RuntimeError(msg)
793
+ return decoder
794
 
795
+ def _create_projector(self, config: ASRConfig, dtype: torch.dtype) -> "MLPAudioProjector":
796
  """Create the trainable audio projector."""
797
  # Auto-detect dimensions if not specified
798
  if config.encoder_dim is None:
 
801
  enc_cfg, "d_model", None
802
  )
803
  if config.encoder_dim is None:
804
+ msg = "Could not auto-detect encoder_dim. Please specify in config."
805
+ raise ValueError(msg)
806
 
807
  if config.llm_dim is None:
808
  dec_cfg = self.language_model.config
 
814
  dec_cfg, "d_model", None
815
  )
816
  if config.llm_dim is None:
817
+ msg = "Could not auto-detect llm_dim. Please specify in config."
818
+ raise ValueError(msg)
819
 
820
  # Select projector type based on config
821
  projector_type = getattr(config, "projector_type", "mlp")
822
  projector_class = PROJECTOR_CLASSES.get(projector_type)
823
  if projector_class is None:
824
+ msg = (
825
  f"Unknown projector_type: {projector_type}. "
826
  f"Valid options: {list(PROJECTOR_CLASSES.keys())}"
827
  )
828
+ raise ValueError(msg)
829
  projector = projector_class(config)
830
 
831
  # Move projector to same device as language model (important when using quantization)
 
837
  self._projector_dtype = proj_dtype
838
  return projector.to(device=device, dtype=proj_dtype)
839
 
840
+ def _setup_lora(self, config: ASRConfig) -> None:
841
  """Apply LoRA adapters to the language model for Stage 2 fine-tuning."""
 
 
842
  lora_config = LoraConfig(
843
  r=config.lora_rank,
844
  lora_alpha=config.lora_alpha,
 
854
  bias="none",
855
  task_type="CAUSAL_LM",
856
  )
857
+ # See `language_model` in __init__ for why the PEFT wrapper is
858
+ # typed as the decoder it wraps.
859
+ self.language_model = cast(
860
+ "GenerativeDecoder", get_peft_model(self.language_model, lora_config)
861
+ )
862
 
863
+ def _init_tokenizer(self, config: ASRConfig) -> None:
864
  """Initialize tokenizer with audio token."""
865
+ self.tokenizer: PreTrainedTokenizerBase = AutoTokenizer.from_pretrained(
866
+ config.text_model_id, trust_remote_code=True
867
+ )
868
 
869
  # Set pad token. Prefer a dedicated pad token if the tokenizer has one
870
  # (e.g. Qwen's <|finetune_right_pad_id|>); otherwise fall back to
 
888
  already_in_vocab = existing_id is not None and existing_id != self.tokenizer.unk_token_id
889
 
890
  if not already_in_vocab:
891
+ existing_special: list[str] = (
892
+ getattr(self.tokenizer, "additional_special_tokens", None) or []
893
+ )
894
  self.tokenizer.add_special_tokens(
895
  {"additional_special_tokens": [*existing_special, self.audio_token]}
896
  )
 
929
  mean_resizing=True,
930
  )
931
 
932
+ audio_token_id = self.tokenizer.convert_tokens_to_ids(self.audio_token)
933
+ # The token is in the vocab by now, so a single name maps to one id.
934
+ if not isinstance(audio_token_id, int):
935
+ msg = f"Tokenizer has no id for the audio token {self.audio_token!r}"
936
+ raise TypeError(msg)
937
+ self.audio_token_id = audio_token_id
938
  self.tokenizer.padding_side = "right"
939
 
940
  # Sync token IDs to configs
 
944
  cfg.eos_token_id = self.tokenizer.eos_token_id
945
  cfg.bos_token_id = self.tokenizer.bos_token_id
946
 
947
+ def _resolve_eos_token_ids(self) -> list[int]:
948
+ """Collect the ids generation stops on: turn closer, known end tokens, tokenizer EOS."""
949
+ # Candidates that don't exist in the tokenizer are filtered out.
950
+ # `convert_tokens_to_ids` reports "not in vocab" inconsistently: Qwen-
951
+ # style tokenizers return None, while Gemma's returns unk_token_id.
952
+ # Filtering on None alone left Gemma with eos_token_id=[unk, unk], so
953
+ # generation never stopped and every sample ran to max_new_tokens.
954
+ #
955
+ # The membership test is a name round-trip, NOT `id == unk_token_id`:
956
+ # in GPT-2-lineage tokenizers (SmolLM2, Qwen) unk_token *is*
957
+ # "<|endoftext|>", so comparing ids would discard a legitimate stop
958
+ # token here while fixing Gemma. A token that fell back to unk comes
959
+ # back under unk's name; one that is really in the vocab comes back
960
+ # as itself.
961
+ #
962
+ # The name list alone is not enough, and silently produced garbage on
963
+ # Gemma 4: that template dropped Gemma 2/3's "<end_of_turn>" for a new
964
+ # "<|turn>role\n ... <turn|>\n" scheme, so all three probes missed and
965
+ # eos collapsed to the bare "<eos>" -- a token the template never
966
+ # emits. Training taught the model to close its turn with "<turn|>"
967
+ # (id 106) and generation then ignored it, running every sample to
968
+ # max_new_tokens and burying the transcript under 250 copies of
969
+ # "<turn|>" that `skip_special_tokens` silently swallowed.
970
+ #
971
+ # So derive the real terminator from the template instead of guessing
972
+ # its name: render an assistant turn holding a sentinel and take the
973
+ # first token that follows it. That token is the turn closer by
974
+ # construction, for any decoder, including ones released after this
975
+ # code was written. The name probes stay as a fallback for tokenizers
976
+ # whose template renders no trailing special token.
977
+ eos_ids: list[int] = []
978
+ derived = self._derive_turn_end_token_id()
979
+ if derived is not None:
980
+ eos_ids.append(derived)
981
+ for token in ("<|im_end|>", "<|endoftext|>", "<end_of_turn>"):
982
+ token_id = self.tokenizer.convert_tokens_to_ids(token)
983
+ # A single token name maps to a single id (None when unknown).
984
+ if (
985
+ not isinstance(token_id, int)
986
+ or self.tokenizer.convert_ids_to_tokens(token_id) != token
987
+ ):
988
+ continue
989
+ if token_id not in eos_ids:
990
+ eos_ids.append(token_id)
991
+ # The chat-template stop tokens above are additions, not replacements:
992
+ # the tokenizer's own EOS is always a valid stop and is the only one
993
+ # left for a decoder using none of those three templates.
994
+ tokenizer_eos = self.tokenizer.eos_token_id
995
+ if isinstance(tokenizer_eos, int) and tokenizer_eos not in eos_ids:
996
+ eos_ids.append(tokenizer_eos)
997
+ return eos_ids
998
+
999
  def _derive_turn_end_token_id(self) -> int | None:
1000
  """Return the token id the chat template uses to close an assistant turn.
1001
 
 
1026
  return None
1027
  if not isinstance(rendered, str) or sentinel not in rendered:
1028
  return None
1029
+ tail_ids: list[int] = self.tokenizer(
1030
+ rendered.split(sentinel)[-1], add_special_tokens=False
1031
+ )["input_ids"]
1032
  if not tail_ids:
1033
  return None
1034
  # Require a special token: a template that ends the turn with plain
 
1037
  first = tail_ids[0]
1038
  return first if first in set(self.tokenizer.all_special_ids) else None
1039
 
1040
+ def _apply(self, *args: Any, **kwargs: Any) -> Self:
1041
  """Repair MPS-unsafe embeddings whenever the model lands on MPS.
1042
 
1043
  Device placement happens after construction -- `pipeline` builds the
 
1051
  full speed. Chunking keeps the model on the GPU -- falling back to CPU
1052
  would be correct but costs roughly an order of magnitude in latency.
1053
  """
1054
+ module: Self = super()._apply(*args, **kwargs)
1055
  try:
1056
  on_mps = any(p.device.type == "mps" for p in module.parameters())
1057
  except StopIteration: # pragma: no cover - parameterless model
 
1064
  )
1065
  return module
1066
 
1067
+ def train(self, mode: bool = True) -> Self:
1068
  """Set train/eval mode, but keep frozen submodules out of train mode.
1069
 
1070
  HF Trainer calls `model.train()` at the top of every training step, which
 
1108
  # "Norm"). This mirrors what the partial-unfreeze path already gets
1109
  # for free via the `freeze_audio_encoder: true` branch above.
1110
  for module in self.audio_tower.modules():
1111
+ if isinstance(
1112
+ module,
1113
+ (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d, nn.SyncBatchNorm),
1114
+ ):
1115
  module.eval()
1116
  if getattr(self.config, "freeze_language_model", True):
1117
  self.language_model.train(False)
 
1119
  def _set_gradient_checkpointing(
1120
  self,
1121
  enable: bool = True,
1122
+ gradient_checkpointing_func: Callable[..., Any] | None = None,
1123
  every_n_layers: int = 1,
1124
+ **kwargs: Any,
1125
+ ) -> None:
1126
  """Enable/disable gradient checkpointing on the trainable submodules.
1127
 
1128
  Routes the request to whichever components are actually trainable in
 
1145
  Upstream sniffs for it to detect the pre-4.35 checkpointing format and
1146
  would silently take the legacy `self.apply(...)` path instead.
1147
  """
1148
+ forwardable: dict[str, Any] = dict(kwargs)
 
 
1149
  forwardable["every_n_layers"] = every_n_layers
1150
  if gradient_checkpointing_func is not None:
1151
  forwardable["gradient_checkpointing_func"] = gradient_checkpointing_func
 
1154
  # Gradient checkpointing trades compute for memory by recomputing activations.
1155
  for submodule in self._gradient_checkpointing_targets():
1156
  setter = getattr(submodule, "_set_gradient_checkpointing", None)
1157
+ enable_fn = getattr(submodule, "gradient_checkpointing_enable", None)
1158
+ disable_fn = getattr(submodule, "gradient_checkpointing_disable", None)
1159
  if setter is not None:
1160
  accepted = inspect.signature(setter).parameters
1161
  passthrough = {k: v for k, v in forwardable.items() if k in accepted}
1162
  setter(enable=enable, **passthrough)
1163
+ elif enable_fn is not None and enable:
1164
+ enable_fn(gradient_checkpointing_kwargs={"use_reentrant": False})
1165
+ elif disable_fn is not None and not enable:
1166
+ disable_fn()
 
 
1167
 
1168
  def _gradient_checkpointing_targets(self) -> list[nn.Module]:
1169
  """Return the submodules that should respond to gradient_checkpointing
 
1185
  """Replace the decoder's token embedding module."""
1186
  self.language_model.set_input_embeddings(value)
1187
 
1188
+ def get_output_embeddings(self) -> nn.Module | None:
1189
+ """Return the decoder's LM head (None for a decoder without one)."""
1190
  return self.language_model.get_output_embeddings()
1191
 
1192
+ def set_output_embeddings(self, new_embeddings: nn.Module) -> None:
1193
  """Replace the decoder's LM head."""
1194
+ self.language_model.set_output_embeddings(new_embeddings)
1195
 
1196
+ def get_processor(self) -> ASRProcessor:
1197
  """Get the processor for this model."""
 
 
 
 
 
1198
  return ASRProcessor(
1199
  feature_extractor=self.feature_extractor,
1200
  tokenizer=self.tokenizer,
 
1203
  audio_token=self.audio_token,
1204
  )
1205
 
1206
+ @overload
1207
+ def state_dict(
1208
+ self, *, destination: StateDictT, prefix: str = ..., keep_vars: bool = ...
1209
+ ) -> StateDictT: ...
1210
+
1211
+ @overload
1212
+ def state_dict(self, *, prefix: str = ..., keep_vars: bool = ...) -> dict[str, Any]: ...
1213
+
1214
+ def state_dict(
1215
+ self,
1216
+ *,
1217
+ destination: dict[str, Any] | None = None,
1218
+ prefix: str = "",
1219
+ keep_vars: bool = False,
1220
+ ) -> dict[str, Any]:
1221
  """Save trainable weights: projector, plus the encoder/LM when unfrozen.
1222
 
1223
  Every module this returns is gated on the same freeze flag that decides
 
1238
  `freeze_text_embed_tokens` freezes an embedding that must still
1239
  round-trip with the rest of the LM.
1240
  """
1241
+ sd = {
1242
+ f"projector.{k}": v for k, v in self.projector.state_dict(keep_vars=keep_vars).items()
1243
+ }
1244
  # Keyed on autograd state, not `freeze_audio_encoder`. Under the
1245
  # documented partial-unfreeze recipe that flag stays True while the top
1246
  # N blocks train, so gating on it meant a trained encoder was never
 
1253
  sd.update(
1254
  {
1255
  f"audio_tower.{k}": v
1256
+ for k, v in self.audio_tower.state_dict(keep_vars=keep_vars).items()
1257
  if fully or k in enc_trainable
1258
  }
1259
  )
1260
  if not getattr(self.config, "freeze_language_model", True):
1261
  lm = self.language_model
1262
  if hasattr(lm, "peft_config"):
1263
+ for name, v in lm.state_dict(keep_vars=keep_vars).items():
1264
  if "lora_" in name:
1265
  continue
1266
  k = name.removeprefix("base_model.model.")
 
1268
  k = k.replace(".base_layer.", ".")
1269
  sd[f"language_model.{k}"] = v
1270
  else:
1271
+ sd.update(
1272
+ {
1273
+ f"language_model.{k}": v
1274
+ for k, v in lm.state_dict(keep_vars=keep_vars).items()
1275
+ }
1276
+ )
1277
+ if destination is None:
1278
+ destination = {}
1279
+ destination.update({f"{prefix}{k}": v for k, v in sd.items()})
1280
+ return destination
1281
 
1282
+ def _ple_text_model(self) -> "Gemma4TextModel | None":
1283
  """Locate the submodule owning `get_per_layer_inputs`, or None.
1284
 
1285
  Only Gemma 4 style decoders have one. The attribute sits on
 
1293
  )
1294
  for candidate in candidates:
1295
  if candidate is not None and hasattr(candidate, "get_per_layer_inputs"):
1296
+ # Checked just above; only Gemma 4 text models define it.
1297
+ return cast("Gemma4TextModel", candidate)
1298
  return None
1299
 
1300
+ def _per_layer_kwargs(self, input_ids: torch.Tensor | None) -> dict[str, torch.Tensor]:
1301
  """Precompute Gemma 4 per-layer embeddings (PLE) from clean `input_ids`.
1302
 
1303
  Gemma 4 builds a token-identity PLE component by looking `input_ids` up
 
1323
  return {}
1324
  return {"per_layer_inputs": text_model.get_per_layer_inputs(input_ids, None)}
1325
 
1326
+ def _embed_tokens(self, input_ids: torch.Tensor | None) -> torch.Tensor:
1327
+ """Look `input_ids` up in the decoder's token embedding table."""
1328
+ embeds: torch.Tensor = self.language_model.get_input_embeddings()(input_ids)
1329
+ return embeds
1330
+
1331
  def _compute_encoder_output_lengths(
1332
  self,
1333
  audio_attention_mask: torch.Tensor,
 
1435
  mask `mask_length` frames forward from each), but reimplemented in
1436
  pure torch so it stays inside the autograd graph without crossing
1437
  the numpy boundary. This avoids inductor codegen failures
1438
+ (e.g. the `'zuf0' was not declared` error from the prior numpy ->
1439
  torch.tensor round-trip) AND avoids the per-forward host-to-GPU
1440
  sync that the numpy path required.
1441
 
 
1482
  valid_lengths = None
1483
  if attention_mask is not None:
1484
  if attention_mask.shape[-1] != sequence_length:
1485
+ msg = (
1486
  "SpecAugment attention_mask time axis "
1487
  f"({attention_mask.shape[-1]}) does not match input_features "
1488
  f"({sequence_length}). Masking against the wrong axis is silent, "
1489
  "so this is raised rather than ignored."
1490
  )
1491
+ raise ValueError(msg)
1492
  valid_lengths = attention_mask.to(device).sum(dim=-1)
1493
 
1494
  if getattr(config, "mask_time_prob", 0.0) > 0:
 
1557
  (mask_prob * lengths.float() / mask_length + 0.5).long(), min=min_masks
1558
  ) # (B,)
1559
 
1560
+ # Sample start positions independently per sample x span, inside that
1561
  # sample's own valid range. Clamp so a span of length mask_length never
1562
  # starts past the end of short content. torch.rand is [0, 1), so the
1563
  # floor below never reaches max_start.
 
1590
  use_cache: bool | None = None,
1591
  cache_position: torch.Tensor | None = None,
1592
  audio_token_counts: torch.Tensor | None = None,
1593
+ **kwargs: Any,
1594
  ) -> CausalLMOutputWithPast:
1595
  """Forward pass for training and inference."""
1596
  if inputs_embeds is None:
1597
+ inputs_embeds = self._embed_tokens(input_ids)
1598
 
1599
  # Gemma 4 PLE: derived from input_ids, so it must be captured before the
1600
  # masked_scatter below replaces the audio rows. No-op ({}) elsewhere.
 
1679
  if labels is not None and self._lm_accepts_skip_logits:
1680
  kwargs.setdefault("skip_logits", True)
1681
 
1682
+ outputs: CausalLMOutputWithPast = self.language_model(
1683
  attention_mask=attention_mask,
1684
  position_ids=position_ids,
1685
  past_key_values=past_key_values,
 
1690
  **ple_kwargs,
1691
  **kwargs,
1692
  )
1693
+ return outputs
1694
 
1695
+ def prepare_inputs_for_generation(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
1696
  """Prepare inputs for generation, handling audio features for cached decoding."""
1697
  input_features = kwargs.pop("input_features", None)
1698
  cache_position = kwargs.get("cache_position")
 
1737
  return_tensors="pt",
1738
  enable_thinking=False, # Disable Qwen3 thinking mode for ASR
1739
  )
1740
+ if not isinstance(chat_result, BatchEncoding):
1741
+ msg = f"apply_chat_template returned {type(chat_result).__name__}, not BatchEncoding"
1742
+ raise TypeError(msg)
1743
+ ids = chat_result["input_ids"]
1744
  return (ids[0] if ids.dim() > 1 else ids).to(torch.long)
1745
 
1746
  def _left_pad_prompt_rows(
 
1748
  rows: list[torch.Tensor],
1749
  device: torch.device,
1750
  ) -> tuple[torch.Tensor, torch.Tensor]:
1751
+ """Stack per-sample prompt rows into a left-padded batch on `device`.
1752
 
1753
+ See `left_pad_prompt_rows`; pad positions never collide with
1754
+ `audio_token_id`, so the masked_scatter below is unaffected.
 
1755
  """
1756
+ input_ids, attention_mask = left_pad_prompt_rows(rows, self.tokenizer)
 
 
 
 
 
 
 
 
 
 
1757
  return input_ids.to(device), attention_mask.to(device)
1758
 
1759
  def _prepare_audio_inputs(
 
1794
  input_ids, attention_mask = self._left_pad_prompt_rows(rows, device)
1795
 
1796
  # Get text embeddings and replace audio tokens with audio embeddings
1797
+ inputs_embeds = self._embed_tokens(input_ids)
1798
  audio_token_mask = (input_ids == self.audio_token_id).unsqueeze(-1)
1799
  inputs_embeds = inputs_embeds.masked_scatter(
1800
  audio_token_mask.to(inputs_embeds.device),
 
1815
 
1816
  Batch 1 and uniform-length batches pad nothing and pass. `eager` is
1817
  correct on MPS at any padding, and the bug is Metal-specific, so a CUDA
1818
+ box is unaffected either way. `resolve_attn_implementation` documents
1819
  the measurements.
1820
  """
1821
  if attention_mask is None or attention_mask.device.type != "mps":
1822
  return
1823
+ if (
1824
+ self.language_model.config._attn_implementation # pyright: ignore[reportPrivateUsage] # transformers has no public accessor
1825
+ != "sdpa"
1826
+ ):
1827
  return
1828
  if not bool((attention_mask == 0).any()):
1829
  return
1830
+ msg = (
1831
  "Refusing to generate: this is a left-padded batch of "
1832
  f"{attention_mask.shape[0]} on MPS with sdpa attention, where "
1833
  "Metal's kernel returns NaN for the padded rows and every one of "
 
1836
  "to decode), batch only equal-length clips, or generate one clip "
1837
  "at a time."
1838
  )
1839
+ raise ValueError(msg)
1840
 
1841
+ @torch.no_grad() # pyright: ignore[reportUntypedFunctionDecorator]
1842
  def generate(
1843
  self,
1844
+ *args: Any,
1845
  input_ids: torch.Tensor | None = None,
1846
  input_features: torch.Tensor | None = None,
1847
  audio_attention_mask: torch.Tensor | None = None,
1848
  attention_mask: torch.Tensor | None = None,
1849
+ **generate_kwargs: Any,
1850
+ ) -> GenerateOutput | torch.LongTensor:
1851
  """Generate transcription from audio input.
1852
 
1853
  Can be called in two ways:
1854
  1. With input_ids containing <audio> tokens (from processor)
1855
  2. With just audio, and we build the prompt internally
1856
+
1857
+ The audio arguments are keyword-only; `*args` exists only so the
1858
+ signature stays compatible with `GenerationMixin.generate`, whose
1859
+ positional parameters mean something else entirely.
1860
  """
1861
+ if args:
1862
+ msg = (
1863
+ "ASRModel.generate takes input_ids, input_features, "
1864
+ "audio_attention_mask and attention_mask as keyword arguments"
1865
+ )
1866
+ raise TypeError(msg)
1867
  if input_features is None:
1868
+ msg = "input_features required for generation"
1869
+ raise ValueError(msg)
1870
  if audio_attention_mask is None:
1871
+ msg = "audio_attention_mask required for generation"
1872
+ raise ValueError(msg)
1873
 
1874
  input_ids, attention_mask, inputs_embeds = self._prepare_audio_inputs(
1875
  input_features,
 
1884
  # Pull any score-related flags out of generate_kwargs and apply them to
1885
  # a derived generation_config so they actually take effect.
1886
  gen_cfg = self.generation_config
1887
+ score_flags: dict[str, Any] = {}
1888
  for flag in ("output_scores", "output_logits", "return_dict_in_generate"):
1889
  if flag in generate_kwargs:
1890
  score_flags[flag] = generate_kwargs.pop(flag)
1891
  if score_flags:
1892
+ gen_cfg = copy(self.generation_config)
 
 
1893
  for flag, value in score_flags.items():
1894
  setattr(gen_cfg, flag, value)
1895
  # output_scores requires return_dict_in_generate for HF generate to
1896
  # actually populate .scores on the output object.
1897
+ if getattr(gen_cfg, "output_scores", None) and not getattr(
1898
+ gen_cfg, "return_dict_in_generate", None
1899
+ ):
1900
  gen_cfg.return_dict_in_generate = True
1901
 
1902
+ lm_inputs = self._lm_generate_inputs(input_ids, inputs_embeds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1903
  output = self.language_model.generate(
1904
  **lm_inputs,
1905
  attention_mask=attention_mask,
 
1916
  # compatibility with existing callers.
1917
  prompt_len = input_ids.shape[1] if "input_ids" in lm_inputs else 0
1918
  if isinstance(output, torch.Tensor):
1919
+ # Slicing a token-id tensor keeps its int64 dtype.
1920
+ return cast("torch.LongTensor", output[:, prompt_len:])
1921
+ output.sequences = cast("torch.LongTensor", output.sequences[:, prompt_len:])
1922
  return output
1923
 
1924
+ def _lm_generate_inputs(
1925
+ self, input_ids: torch.Tensor, inputs_embeds: torch.Tensor
1926
+ ) -> dict[str, torch.Tensor]:
1927
+ """The prompt arguments for `language_model.generate`.
1928
+
1929
+ Default path passes both input_ids and inputs_embeds so
1930
+ repetition_penalty and no_repeat_ngram_size see the prompt (they need
1931
+ input_ids to track which tokens have been used). Gemma 4 forbids that
1932
+ combination outright -- "You must specify exactly one of input_ids or
1933
+ inputs_embeds" -- so there we pass embeds plus the PLE precomputed from
1934
+ the clean prompt ids. Gemma's prepare_inputs_for_generation drops
1935
+ per_layer_inputs after the first step, so it is correct to hand it to
1936
+ generate().
1937
+ """
1938
+ ple_kwargs = self._per_layer_kwargs(input_ids)
1939
+ if ple_kwargs:
1940
+ return {"inputs_embeds": inputs_embeds, **ple_kwargs}
1941
+ return {"input_ids": input_ids, "inputs_embeds": inputs_embeds}
1942
+
1943
  def generate_streaming(
1944
  self,
1945
  input_features: torch.Tensor,
1946
  audio_attention_mask: torch.Tensor,
1947
+ **generate_kwargs: Any,
1948
  ) -> Iterator[str]:
1949
  """Generate transcription with streaming token output.
1950
 
 
1971
  )
1972
 
1973
  # Prepare generation kwargs
1974
+ # Same prompt arguments as generate(); with input_ids present the
1975
+ # streamer's skip_prompt drops the echoed prompt tokens.
1976
+ gen_kwargs: dict[str, Any] = {
1977
+ **self._lm_generate_inputs(input_ids, inputs_embeds),
1978
  "attention_mask": attention_mask,
1979
  "generation_config": self.generation_config,
1980
  "streamer": streamer,
 
 
1981
  **generate_kwargs,
1982
  }
1983
 
 
2018
 
2019
  thread.join()
2020
 
2021
+ def save_pretrained(
2022
+ self, save_directory: str | PathLike[str], *args: Any, **kwargs: Any
2023
+ ) -> None:
2024
+ """Save model, tokenizer, and processor.
2025
 
2026
+ Extra positional arguments are forwarded to `PreTrainedModel.save_pretrained`.
2027
+ """
2028
  save_dir = Path(save_directory)
2029
  save_dir.mkdir(parents=True, exist_ok=True)
2030
 
 
2041
  self.config.vocab_size = vocab_size
2042
  self.config.text_config.vocab_size = vocab_size
2043
 
2044
+ encoder_config = self.audio_tower.config
2045
+ if hasattr(encoder_config, "num_mel_bins"):
2046
+ # The default is never used: hasattr just confirmed the attribute.
2047
+ # Read through getattr because nn.Module types `.config` as
2048
+ # Tensor | Module, which hasattr does not narrow.
2049
+ self.config.audio_config.num_mel_bins = getattr(encoder_config, "num_mel_bins", None)
2050
 
2051
  # Save model (temporarily remove non-serializable attributes)
2052
  tokenizer = self.tokenizer
2053
  del self.tokenizer
2054
 
2055
  try:
2056
+ super().save_pretrained(
2057
+ save_dir,
2058
+ *args,
2059
+ **kwargs,
2060
+ )
2061
  finally:
2062
  self.tokenizer = tokenizer
2063
 
 
2096
 
2097
  # Add processor auto_map to preprocessor_config.json
2098
  config_path = save_dir / "preprocessor_config.json"
2099
+ processor_config: dict[str, Any]
2100
  if config_path.exists():
2101
  with config_path.open() as f:
2102
  processor_config = json.load(f)
 
2146
  if card_fn is not None:
2147
  card_fn(str(output_dir))
2148
 
2149
+ # typeshed's functools._Wrapped (how PreTrainedModel declares push_to_hub) is
2150
+ # not a descriptor, so no method can match it; the signature is the
2151
+ # PushToHubMixin one that PreTrainedModel wraps.
2152
+ def push_to_hub( # pyright: ignore[reportIncompatibleVariableOverride]
2153
+ self,
2154
+ repo_id: str,
2155
+ *,
2156
+ commit_message: str | None = None,
2157
+ commit_description: str | None = None,
2158
+ private: bool | None = None,
2159
+ token: bool | str | None = None,
2160
+ revision: str | None = None,
2161
+ create_pr: bool = False,
2162
+ max_shard_size: int | str | None = "50GB",
2163
+ tags: list[str] | None = None,
2164
+ ) -> str:
2165
  """Push model to HuggingFace Hub, ensuring adapter_config points to repo.
2166
 
2167
  IMPORTANT: Sets base_model_name_or_path in adapter_config.json to repo_id
 
2171
  # Store repo_id in config so save_pretrained can access it
2172
  self.config.pretrained_model_path = repo_id
2173
  # Call parent's push_to_hub
2174
+ return PreTrainedModel.push_to_hub(
2175
+ self,
2176
+ repo_id,
2177
+ commit_message=commit_message,
2178
+ commit_description=commit_description,
2179
+ private=private,
2180
+ token=token,
2181
+ revision=revision,
2182
+ create_pr=create_pr,
2183
+ max_shard_size=max_shard_size,
2184
+ tags=tags,
2185
+ )
2186
 
2187
 
2188
  # Register with transformers Auto classes
asr_pipeline.py CHANGED
@@ -1,32 +1,41 @@
1
  """ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
2
 
3
  import re
 
4
  from pathlib import Path
5
- from typing import Any, cast
6
 
7
  import numpy as np
 
8
  import torch
9
  import transformers
10
  from transformers.pipelines.audio_utils import ffmpeg_read
11
 
12
- try:
13
- from .alignment import ForcedAligner, QwenForcedAligner
 
 
 
14
  from .asr_modeling import ASRModel
15
  from .asr_processing import prepend_lead_in
16
- from .diarization import NemotronDiarizer, SpeakerDiarizer
17
- except ImportError:
18
- from alignment import ForcedAligner, QwenForcedAligner # type: ignore[no-redef]
19
- from asr_modeling import ASRModel # type: ignore[no-redef]
20
- from asr_processing import prepend_lead_in # type: ignore[no-redef]
21
- from diarization import NemotronDiarizer, SpeakerDiarizer # type: ignore[no-redef]
 
 
 
 
 
 
22
 
23
  # Re-export for backwards compatibility
24
  __all__ = [
25
  "ASRPipeline",
26
- "ForcedAligner",
27
  "NemotronDiarizer",
28
  "QwenForcedAligner",
29
- "SpeakerDiarizer",
30
  ]
31
 
32
  # Audio is transcribed in chunks cut at the quietest point between
@@ -37,11 +46,15 @@ CHUNK_MIN_S = 8.0
37
 
38
 
39
  def chunk_bounds(
40
- audio: np.ndarray, sample_rate: int, max_s: float = CHUNK_MAX_S, min_s: float = CHUNK_MIN_S
 
 
 
41
  ) -> list[tuple[int, int]]:
42
  """Sample ranges of at most `max_s`, each cut at the quietest 100 ms frame after `min_s`."""
43
  frame = int(0.1 * sample_rate)
44
- bounds, start, n = [], 0, len(audio)
 
45
  while n - start > max_s * sample_rate:
46
  lo = start + int(min_s * sample_rate)
47
  hi = start + int(max_s * sample_rate)
@@ -62,7 +75,7 @@ def chunk_bounds(
62
  SILENCE_RMS = 1e-5
63
 
64
 
65
- def is_silent(audio: np.ndarray) -> bool:
66
  """True for digital silence (or an empty array): nothing for the model to hear."""
67
  return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
68
 
@@ -73,32 +86,61 @@ _TRAILING_CHAR_RE = re.compile(rf"(.)\1{{{_MIN_REPEATS - 1},}}$")
73
  _TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
77
  """ASR Pipeline for audio-to-text transcription."""
78
 
79
  model: ASRModel
80
 
81
- def __init__(self, model: ASRModel, **kwargs):
82
  """Initialize ASR pipeline.
83
 
84
  Args:
85
  model: ASRModel instance for transcription
86
  **kwargs: Additional arguments (feature_extractor, tokenizer, device)
87
  """
88
- feature_extractor = kwargs.pop("feature_extractor", None)
89
- tokenizer = kwargs.pop("tokenizer", model.tokenizer)
90
 
91
  if feature_extractor is None:
92
  feature_extractor = model.get_processor().feature_extractor
93
 
94
  super().__init__(
95
- model=model, feature_extractor=feature_extractor, tokenizer=tokenizer, **kwargs
 
 
 
 
96
  )
97
 
98
- def _sanitize_parameters(self, **kwargs):
 
 
 
 
 
 
 
 
 
99
  """Intercept our custom parameters before parent class validates them."""
100
- # Remove our custom parameters so parent doesn't see them
101
- kwargs.pop("return_timestamps", None)
 
 
102
  kwargs.pop("return_speakers", None)
103
  kwargs.pop("num_speakers", None)
104
  kwargs.pop("min_speakers", None)
@@ -106,13 +148,25 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
106
  kwargs.pop("hf_token", None)
107
  kwargs.pop("user_prompt", None)
108
 
109
- return super()._sanitize_parameters(**kwargs)
 
 
 
 
 
 
 
110
 
 
 
111
  def __call__(
112
  self,
113
- inputs,
114
- **kwargs,
115
- ):
 
 
 
116
  """Transcribe audio with optional word-level timestamps and speaker diarization.
117
 
118
  Args:
@@ -128,16 +182,25 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
128
  Dict with 'text' key, 'words' key if return_timestamps=True,
129
  and speaker labels on words plus 'speaker_segments' if return_speakers=True
130
  """
 
 
 
 
 
 
 
 
131
  # Extract our params before super().__call__ (which will also call _sanitize_parameters)
132
- return_timestamps = kwargs.pop("return_timestamps", False)
133
- return_speakers = kwargs.pop("return_speakers", False)
134
- user_prompt = kwargs.pop("user_prompt", None)
135
  if kwargs.pop("min_speakers", None) is not None:
136
- raise ValueError(
137
  "min_speakers is not supported: Nemotron-3-Diarization decides how many "
138
  "speakers it hears. Pass num_speakers (exact) or max_speakers instead."
139
  )
140
- diarization_params = {
 
141
  "num_speakers": kwargs.pop("num_speakers", None),
142
  "max_speakers": kwargs.pop("max_speakers", None),
143
  }
@@ -150,7 +213,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
150
  # exception anywhere in the call leaves the custom prompt in place for
151
  # every later call on this pipeline, and the eval harness swallows that
152
  # exception and keeps scoring against the wrong prompt.
153
- original_prompt = None
154
  if user_prompt:
155
  original_prompt = self.model.TRANSCRIBE_PROMPT
156
  self.model.TRANSCRIBE_PROMPT = user_prompt
@@ -168,7 +231,9 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
168
  if original_prompt is not None:
169
  self.model.TRANSCRIBE_PROMPT = original_prompt
170
 
171
- def _transcribe_plain(self, inputs, **kwargs):
 
 
172
  """Transcribe in the same chunks as `_transcribe_timed`, without timing words.
173
 
174
  One call over a whole recording returns only its first
@@ -187,7 +252,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
187
  if len(bounds) == 1:
188
  return self._transcribe_chunk(array, sr, **kwargs)
189
  result: dict[str, Any] = {}
190
- texts = []
191
  for s, e in bounds:
192
  out = self._transcribe_chunk(array[s:e], sr, **kwargs)
193
  texts.append(out["text"])
@@ -198,23 +263,26 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
198
  result["text"] = " ".join(t for t in texts if t)
199
  return result
200
 
201
- def _transcribe_chunk(self, chunk: np.ndarray, sample_rate: int, **kwargs) -> dict:
 
 
202
  """One model call on one chunk; digital silence is "" without calling the model."""
203
  if is_silent(chunk):
204
  return {"text": ""}
205
- # One input in, one dict out; the parent is typed for batches as well.
206
  return cast(
207
- "dict", super().__call__({"raw": chunk, "sampling_rate": sample_rate}, **kwargs)
 
208
  )
209
 
210
  def _transcribe_timed(
211
  self,
212
- inputs,
213
  *,
214
  return_speakers: bool,
215
- diarization_params: dict,
216
- **kwargs,
217
- ):
218
  """Transcribe in chunks, time every word, and label speakers if requested.
219
 
220
  Chunks of 8-18 s cut at quiet points are transcribed one by one and each
@@ -226,21 +294,25 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
226
  """
227
  audio = self._extract_audio(inputs)
228
  if audio is None:
229
- raise ValueError(f"Cannot read audio from {type(inputs).__name__} for timestamps")
 
230
  array = np.asarray(audio["array"], dtype=np.float32)
231
  sr = audio.get("sampling_rate", 16000)
232
 
233
  bounds = chunk_bounds(array, sr)
234
- texts = [self._transcribe_chunk(array[s:e], sr, **kwargs)["text"] for s, e in bounds]
 
 
235
  result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
236
 
237
  try:
238
  aligned = QwenForcedAligner.align_chunks(
239
- [(array[s:e], text) for (s, e), text in zip(bounds, texts)], sample_rate=sr
 
240
  )
241
  result["words"] = [
242
  {**w, "start": w["start"] + s / sr, "end": w["end"] + s / sr}
243
- for (s, _), chunk_words in zip(bounds, aligned)
244
  for w in chunk_words
245
  ]
246
  except Exception as e:
@@ -261,18 +333,19 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
261
 
262
  return result
263
 
264
- def _extract_audio(self, inputs) -> dict | None:
265
  """Extract audio array from various input formats using HF utilities."""
266
  if isinstance(inputs, dict):
267
- if "array" in inputs:
 
268
  return {
269
- "array": inputs["array"],
270
- "sampling_rate": inputs.get("sampling_rate", 16000),
271
  }
272
- if "raw" in inputs:
273
  return {
274
- "array": inputs["raw"],
275
- "sampling_rate": inputs.get("sampling_rate", 16000),
276
  }
277
  elif isinstance(inputs, str):
278
  # File path - load audio using ffmpeg (same as HF pipeline)
@@ -287,21 +360,25 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
287
 
288
  return None
289
 
290
- def preprocess(self, inputs, **preprocess_params):
291
  """Preprocess audio inputs for the model.
292
 
293
  Args:
294
- inputs: Audio input (dict with array, file path, etc.)
 
295
  **preprocess_params: Additional preprocessing parameters
296
 
297
  Yields:
298
  Model input dicts with input_features and attention_mask
299
  """
 
300
  # Handle dict with "array" key (from datasets)
301
  if isinstance(inputs, dict) and "array" in inputs:
 
 
302
  inputs = {
303
- "raw": inputs["array"],
304
- "sampling_rate": inputs.get("sampling_rate", self.feature_extractor.sampling_rate),
305
  }
306
 
307
  # Inference-only lead-in silence. See prepend_lead_in for the
@@ -319,23 +396,26 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
319
  else 0.0
320
  )
321
  if lead_in > 0 and isinstance(inputs, dict) and "raw" in inputs:
322
- inputs = dict(inputs)
323
- inputs["raw"] = prepend_lead_in(
324
- inputs["raw"],
325
- inputs.get("sampling_rate", self.feature_extractor.sampling_rate),
 
326
  lead_in,
327
  )
 
328
 
329
  for item in super().preprocess(inputs, **preprocess_params):
330
  if "is_last" not in item:
331
  item["is_last"] = True
332
  yield item
333
 
334
- def _forward(self, model_inputs, **generate_kwargs) -> dict[str, Any]:
335
  """Run model forward pass to generate transcription.
336
 
337
  Args:
338
- model_inputs: Dict with input_features and attention_mask
 
339
  **generate_kwargs: Generation parameters. Pass ``output_scores=True``
340
  (and ``return_dict_in_generate=True``, which is then implied) to
341
  also return per-step top-1 and top-2 log-probabilities — used by
@@ -347,6 +427,9 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
347
  ``top1_logprob`` / ``top2_logprob`` tensors when scores were
348
  requested.
349
  """
 
 
 
350
  # Extract audio features and is_last flag
351
  is_last = model_inputs.pop("is_last", True) if isinstance(model_inputs, dict) else True
352
 
@@ -383,11 +466,12 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
383
  # annotated with sample 0's confidence and nothing downstream could
384
  # tell. Fail loudly instead of returning a plausible wrong number.
385
  if sequences.shape[0] > 1:
386
- raise ValueError(
387
  f"ASRPipeline received a batch of {sequences.shape[0]} but only "
388
  "returns one transcript; per-item confidence would be wrong. "
389
  "Call it with one clip per invocation."
390
  )
 
391
  top1_logprobs: list[float] = []
392
  top2_logprobs: list[float] = []
393
  if scores:
@@ -397,7 +481,8 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
397
  torch.topk(torch.log_softmax(step_logits[0].float(), dim=-1), k=2).values
398
  for step_logits in scores
399
  ]
400
- for top1, top2 in torch.stack(per_step).tolist():
 
401
  top1_logprobs.append(top1)
402
  top2_logprobs.append(top2)
403
  return {
@@ -407,7 +492,14 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
407
  "is_last": is_last,
408
  }
409
 
410
- def postprocess(self, model_outputs, **kwargs) -> dict[str, str]:
 
 
 
 
 
 
 
411
  """Convert model output tokens to text.
412
 
413
  Args:
@@ -423,7 +515,13 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
423
 
424
  tokens = model_outputs.get("tokens")
425
  if tokens is None:
426
- return super().postprocess(model_outputs, **kwargs)
 
 
 
 
 
 
427
 
428
  if torch.is_tensor(tokens):
429
  tokens = tokens.cpu()
@@ -432,12 +530,18 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
432
 
433
  # Filter out eos tokens that the tokenizer doesn't recognize as special
434
  # (generation_config.eos_token_id may differ from tokenizer.eos_token_id)
435
- eos_ids = self.model.generation_config.eos_token_id
 
 
436
  if eos_ids is not None:
437
  eos_set = set(eos_ids) if isinstance(eos_ids, list) else {eos_ids}
438
- tokens = [t for t in tokens.tolist() if t not in eos_set]
 
439
 
440
- text = self.tokenizer.decode(tokens, skip_special_tokens=True).strip()
 
 
 
441
  # Strip <think>...</think> tags (Qwen3 doesn't respect /no_think prompt)
442
  if "<think>" in text:
443
  text = _THINK_TAG_RE.sub("", text).strip()
@@ -453,6 +557,22 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
453
  return out
454
 
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  def _truncate_repetitions(text: str) -> str:
457
  """Truncate repeated words/phrases/characters at end of text.
458
 
 
1
  """ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
2
 
3
  import re
4
+ from collections.abc import Iterator, MutableMapping
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
16
+
17
+ if TYPE_CHECKING:
18
+ from .alignment import QwenForcedAligner
19
  from .asr_modeling import ASRModel
20
  from .asr_processing import prepend_lead_in
21
+ from .diarization import NemotronDiarizer
22
+ else:
23
+ try:
24
+ from .alignment import QwenForcedAligner
25
+ from .asr_modeling import ASRModel
26
+ from .asr_processing import prepend_lead_in
27
+ from .diarization import NemotronDiarizer
28
+ except ImportError: # flat layout on the Hub: sibling modules, no package
29
+ from alignment import QwenForcedAligner
30
+ from asr_modeling import ASRModel
31
+ from asr_processing import prepend_lead_in
32
+ from diarization import NemotronDiarizer
33
 
34
  # Re-export for backwards compatibility
35
  __all__ = [
36
  "ASRPipeline",
 
37
  "NemotronDiarizer",
38
  "QwenForcedAligner",
 
39
  ]
40
 
41
  # Audio is transcribed in chunks cut at the quietest point between
 
46
 
47
 
48
  def chunk_bounds(
49
+ audio: npt.NDArray[np.float32],
50
+ sample_rate: int,
51
+ max_s: float = CHUNK_MAX_S,
52
+ min_s: float = CHUNK_MIN_S,
53
  ) -> list[tuple[int, int]]:
54
  """Sample ranges of at most `max_s`, each cut at the quietest 100 ms frame after `min_s`."""
55
  frame = int(0.1 * sample_rate)
56
+ bounds: list[tuple[int, int]] = []
57
+ start, n = 0, len(audio)
58
  while n - start > max_s * sample_rate:
59
  lo = start + int(min_s * sample_rate)
60
  hi = start + int(max_s * sample_rate)
 
75
  SILENCE_RMS = 1e-5
76
 
77
 
78
+ def is_silent(audio: npt.NDArray[np.float32]) -> bool:
79
  """True for digital silence (or an empty array): nothing for the model to hear."""
80
  return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
81
 
 
86
  _TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
87
 
88
 
89
+ class _RawAudio(TypedDict):
90
+ """A waveform and its sampling rate, as `_extract_audio` reads them."""
91
+
92
+ array: npt.ArrayLike
93
+ sampling_rate: int
94
+
95
+
96
+ class _DiarizationParams(TypedDict):
97
+ """Speaker-count hints forwarded to `NemotronDiarizer.top_speakers`."""
98
+
99
+ num_speakers: int | None
100
+ max_speakers: int | None
101
+
102
+
103
  class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
104
  """ASR Pipeline for audio-to-text transcription."""
105
 
106
  model: ASRModel
107
 
108
+ def __init__(self, model: ASRModel, **kwargs: Any) -> None:
109
  """Initialize ASR pipeline.
110
 
111
  Args:
112
  model: ASRModel instance for transcription
113
  **kwargs: Additional arguments (feature_extractor, tokenizer, device)
114
  """
115
+ feature_extractor: SequenceFeatureExtractor | None = kwargs.pop("feature_extractor", None)
116
+ tokenizer: PreTrainedTokenizerBase | None = kwargs.pop("tokenizer", model.tokenizer)
117
 
118
  if feature_extractor is None:
119
  feature_extractor = model.get_processor().feature_extractor
120
 
121
  super().__init__(
122
+ model=model,
123
+ feature_extractor=feature_extractor,
124
+ # Annotated as the slow `PreTrainedTokenizer`, but any tokenizer works.
125
+ tokenizer=tokenizer, # pyright: ignore[reportArgumentType]
126
+ **kwargs,
127
  )
128
 
129
+ def _sanitize_parameters(
130
+ self,
131
+ chunk_length_s: float | None = None,
132
+ stride_length_s: float | None = None,
133
+ ignore_warning: bool | None = None,
134
+ decoder_kwargs: dict[str, Any] | None = None,
135
+ return_timestamps: bool | str | None = None,
136
+ return_language: bool | None = None,
137
+ **kwargs: Any,
138
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
139
  """Intercept our custom parameters before parent class validates them."""
140
+ # Remove our custom parameters so parent doesn't see them.
141
+ # `return_timestamps` means word timestamps here (handled in __call__),
142
+ # not the parent's CTC/Whisper timestamps, so it is dropped too.
143
+ del return_timestamps
144
  kwargs.pop("return_speakers", None)
145
  kwargs.pop("num_speakers", None)
146
  kwargs.pop("min_speakers", None)
 
148
  kwargs.pop("hf_token", None)
149
  kwargs.pop("user_prompt", None)
150
 
151
+ return super()._sanitize_parameters(
152
+ chunk_length_s=chunk_length_s,
153
+ stride_length_s=stride_length_s,
154
+ ignore_warning=ignore_warning,
155
+ decoder_kwargs=decoder_kwargs,
156
+ return_language=return_language,
157
+ **kwargs,
158
+ )
159
 
160
+ # The parent annotates `list[dict]`, but one input yields one dict (here and
161
+ # in the parent); a batch the parent handles yields a list. Hence `Any`.
162
  def __call__(
163
  self,
164
+ inputs: Any,
165
+ *args: Any,
166
+ num_workers: int | None = None,
167
+ batch_size: int | None = None,
168
+ **kwargs: Any,
169
+ ) -> Any:
170
  """Transcribe audio with optional word-level timestamps and speaker diarization.
171
 
172
  Args:
 
182
  Dict with 'text' key, 'words' key if return_timestamps=True,
183
  and speaker labels on words plus 'speaker_segments' if return_speakers=True
184
  """
185
+ if args:
186
+ msg = f"ASRPipeline.__call__ takes 1 positional input, got {1 + len(args)}"
187
+ raise TypeError(msg)
188
+ # Not ours: handed back to the parent pipeline's __call__ untouched.
189
+ if num_workers is not None:
190
+ kwargs["num_workers"] = num_workers
191
+ if batch_size is not None:
192
+ kwargs["batch_size"] = batch_size
193
  # Extract our params before super().__call__ (which will also call _sanitize_parameters)
194
+ return_timestamps: bool = kwargs.pop("return_timestamps", False)
195
+ return_speakers: bool = kwargs.pop("return_speakers", False)
196
+ user_prompt: str | None = kwargs.pop("user_prompt", None)
197
  if kwargs.pop("min_speakers", None) is not None:
198
+ msg = (
199
  "min_speakers is not supported: Nemotron-3-Diarization decides how many "
200
  "speakers it hears. Pass num_speakers (exact) or max_speakers instead."
201
  )
202
+ raise ValueError(msg)
203
+ diarization_params: _DiarizationParams = {
204
  "num_speakers": kwargs.pop("num_speakers", None),
205
  "max_speakers": kwargs.pop("max_speakers", None),
206
  }
 
213
  # exception anywhere in the call leaves the custom prompt in place for
214
  # every later call on this pipeline, and the eval harness swallows that
215
  # exception and keeps scoring against the wrong prompt.
216
+ original_prompt: str | None = None
217
  if user_prompt:
218
  original_prompt = self.model.TRANSCRIBE_PROMPT
219
  self.model.TRANSCRIBE_PROMPT = user_prompt
 
231
  if original_prompt is not None:
232
  self.model.TRANSCRIBE_PROMPT = original_prompt
233
 
234
+ def _transcribe_plain(
235
+ self, inputs: Any, **kwargs: Any
236
+ ) -> dict[str, Any] | list[dict[str, Any]]:
237
  """Transcribe in the same chunks as `_transcribe_timed`, without timing words.
238
 
239
  One call over a whole recording returns only its first
 
252
  if len(bounds) == 1:
253
  return self._transcribe_chunk(array, sr, **kwargs)
254
  result: dict[str, Any] = {}
255
+ texts: list[str] = []
256
  for s, e in bounds:
257
  out = self._transcribe_chunk(array[s:e], sr, **kwargs)
258
  texts.append(out["text"])
 
263
  result["text"] = " ".join(t for t in texts if t)
264
  return result
265
 
266
+ def _transcribe_chunk(
267
+ self, chunk: npt.NDArray[np.float32], sample_rate: int, **kwargs: Any
268
+ ) -> dict[str, Any]:
269
  """One model call on one chunk; digital silence is "" without calling the model."""
270
  if is_silent(chunk):
271
  return {"text": ""}
272
+ # One input in, one dict out; the parent is annotated as always returning a list.
273
  return cast(
274
+ "dict[str, Any]",
275
+ super().__call__({"raw": chunk, "sampling_rate": sample_rate}, **kwargs),
276
  )
277
 
278
  def _transcribe_timed(
279
  self,
280
+ inputs: Any,
281
  *,
282
  return_speakers: bool,
283
+ diarization_params: _DiarizationParams,
284
+ **kwargs: Any,
285
+ ) -> dict[str, Any]:
286
  """Transcribe in chunks, time every word, and label speakers if requested.
287
 
288
  Chunks of 8-18 s cut at quiet points are transcribed one by one and each
 
294
  """
295
  audio = self._extract_audio(inputs)
296
  if audio is None:
297
+ msg = f"Cannot read audio from {type(inputs).__name__} for timestamps"
298
+ raise ValueError(msg)
299
  array = np.asarray(audio["array"], dtype=np.float32)
300
  sr = audio.get("sampling_rate", 16000)
301
 
302
  bounds = chunk_bounds(array, sr)
303
+ texts: list[str] = [
304
+ self._transcribe_chunk(array[s:e], sr, **kwargs)["text"] for s, e in bounds
305
+ ]
306
  result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
307
 
308
  try:
309
  aligned = QwenForcedAligner.align_chunks(
310
+ [(array[s:e], text) for (s, e), text in zip(bounds, texts, strict=True)],
311
+ sample_rate=sr,
312
  )
313
  result["words"] = [
314
  {**w, "start": w["start"] + s / sr, "end": w["end"] + s / sr}
315
+ for (s, _), chunk_words in zip(bounds, aligned, strict=True)
316
  for w in chunk_words
317
  ]
318
  except Exception as e:
 
333
 
334
  return result
335
 
336
+ def _extract_audio(self, inputs: object) -> _RawAudio | None:
337
  """Extract audio array from various input formats using HF utilities."""
338
  if isinstance(inputs, dict):
339
+ sample = inputs
340
+ if "array" in sample:
341
  return {
342
+ "array": sample["array"],
343
+ "sampling_rate": sample.get("sampling_rate", 16000),
344
  }
345
+ if "raw" in sample:
346
  return {
347
+ "array": sample["raw"],
348
+ "sampling_rate": sample.get("sampling_rate", 16000),
349
  }
350
  elif isinstance(inputs, str):
351
  # File path - load audio using ffmpeg (same as HF pipeline)
 
360
 
361
  return None
362
 
363
+ def preprocess(self, *args: Any, **preprocess_params: Any) -> Iterator[dict[str, Any]]:
364
  """Preprocess audio inputs for the model.
365
 
366
  Args:
367
+ inputs: Audio input (dict with array, file path, etc.), the one
368
+ positional argument (see `_single_input`)
369
  **preprocess_params: Additional preprocessing parameters
370
 
371
  Yields:
372
  Model input dicts with input_features and attention_mask
373
  """
374
+ inputs = _single_input(args, preprocess_params, "inputs")
375
  # Handle dict with "array" key (from datasets)
376
  if isinstance(inputs, dict) and "array" in inputs:
377
+ assert self.feature_extractor is not None # always set by __init__
378
+ sample = inputs
379
  inputs = {
380
+ "raw": sample["array"],
381
+ "sampling_rate": sample.get("sampling_rate", self.feature_extractor.sampling_rate),
382
  }
383
 
384
  # Inference-only lead-in silence. See prepend_lead_in for the
 
396
  else 0.0
397
  )
398
  if lead_in > 0 and isinstance(inputs, dict) and "raw" in inputs:
399
+ assert self.feature_extractor is not None # always set by __init__
400
+ padded: dict[str, Any] = dict(inputs)
401
+ padded["raw"] = prepend_lead_in(
402
+ padded["raw"],
403
+ padded.get("sampling_rate", self.feature_extractor.sampling_rate),
404
  lead_in,
405
  )
406
+ inputs = padded
407
 
408
  for item in super().preprocess(inputs, **preprocess_params):
409
  if "is_last" not in item:
410
  item["is_last"] = True
411
  yield item
412
 
413
+ def _forward(self, *args: Any, **generate_kwargs: Any) -> dict[str, Any]:
414
  """Run model forward pass to generate transcription.
415
 
416
  Args:
417
+ model_inputs: Dict with input_features and attention_mask, the one
418
+ positional argument (see `_single_input`)
419
  **generate_kwargs: Generation parameters. Pass ``output_scores=True``
420
  (and ``return_dict_in_generate=True``, which is then implied) to
421
  also return per-step top-1 and top-2 log-probabilities — used by
 
427
  ``top1_logprob`` / ``top2_logprob`` tensors when scores were
428
  requested.
429
  """
430
+ model_inputs: MutableMapping[str, Any] = _single_input(
431
+ args, generate_kwargs, "model_inputs"
432
+ )
433
  # Extract audio features and is_last flag
434
  is_last = model_inputs.pop("is_last", True) if isinstance(model_inputs, dict) else True
435
 
 
466
  # annotated with sample 0's confidence and nothing downstream could
467
  # tell. Fail loudly instead of returning a plausible wrong number.
468
  if sequences.shape[0] > 1:
469
+ msg = (
470
  f"ASRPipeline received a batch of {sequences.shape[0]} but only "
471
  "returns one transcript; per-item confidence would be wrong. "
472
  "Call it with one clip per invocation."
473
  )
474
+ raise ValueError(msg)
475
  top1_logprobs: list[float] = []
476
  top2_logprobs: list[float] = []
477
  if scores:
 
481
  torch.topk(torch.log_softmax(step_logits[0].float(), dim=-1), k=2).values
482
  for step_logits in scores
483
  ]
484
+ steps: list[list[float]] = np.asarray(torch.stack(per_step).cpu()).tolist()
485
+ for top1, top2 in steps:
486
  top1_logprobs.append(top1)
487
  top2_logprobs.append(top2)
488
  return {
 
492
  "is_last": is_last,
493
  }
494
 
495
+ def postprocess(
496
+ self,
497
+ model_outputs: dict[str, Any] | list[dict[str, Any]],
498
+ decoder_kwargs: dict[str, Any] | None = None,
499
+ return_timestamps: bool | str | None = None,
500
+ return_language: bool | None = None,
501
+ **kwargs: Any,
502
+ ) -> dict[str, Any]:
503
  """Convert model output tokens to text.
504
 
505
  Args:
 
515
 
516
  tokens = model_outputs.get("tokens")
517
  if tokens is None:
518
+ return super().postprocess(
519
+ model_outputs,
520
+ decoder_kwargs=decoder_kwargs,
521
+ return_timestamps=return_timestamps,
522
+ return_language=return_language,
523
+ **kwargs,
524
+ )
525
 
526
  if torch.is_tensor(tokens):
527
  tokens = tokens.cpu()
 
530
 
531
  # Filter out eos tokens that the tokenizer doesn't recognize as special
532
  # (generation_config.eos_token_id may differ from tokenizer.eos_token_id)
533
+ eos_ids: int | list[int] | None = getattr(
534
+ self.model.generation_config, "eos_token_id", None
535
+ )
536
  if eos_ids is not None:
537
  eos_set = set(eos_ids) if isinstance(eos_ids, list) else {eos_ids}
538
+ token_ids: list[int] = np.asarray(tokens).tolist()
539
+ tokens = [t for t in token_ids if t not in eos_set]
540
 
541
+ assert self.tokenizer is not None # always set by __init__
542
+ decoded = self.tokenizer.decode(tokens, skip_special_tokens=True)
543
+ assert isinstance(decoded, str) # one sequence in, one string out
544
+ text = decoded.strip()
545
  # Strip <think>...</think> tags (Qwen3 doesn't respect /no_think prompt)
546
  if "<think>" in text:
547
  text = _THINK_TAG_RE.sub("", text).strip()
 
557
  return out
558
 
559
 
560
+ def _single_input(args: tuple[Any, ...], kwargs: dict[str, Any], name: str) -> Any:
561
+ """The one input of a pipeline stage, given positionally or as `name=`.
562
+
563
+ `preprocess` and `_forward` take `*args` because their two transformers bases
564
+ name that input differently (`inputs`/`model_inputs` in the ASR pipeline,
565
+ `input_`/`input_tensors` in `Pipeline`), and no single name overrides both.
566
+ `Pipeline` always passes it positionally.
567
+ """
568
+ if len(args) == 1 and name not in kwargs:
569
+ return args[0]
570
+ if not args and name in kwargs:
571
+ return kwargs.pop(name)
572
+ msg = f"expected exactly one {name!r} argument, got {len(args)} positional"
573
+ raise TypeError(msg)
574
+
575
+
576
  def _truncate_repetitions(text: str) -> str:
577
  """Truncate repeated words/phrases/characters at end of text.
578
 
asr_processing.py CHANGED
@@ -1,24 +1,82 @@
1
  """Processor that turns raw audio (and optional text) into model inputs."""
2
 
3
- from typing import ClassVar, Union
 
4
 
5
  import numpy as np
 
6
  import torch
7
  import transformers
8
  from torch.nn.utils.rnn import pad_sequence
9
- from transformers import ProcessorMixin
 
 
 
 
 
10
 
11
- try:
12
- from .asr_config import DEFAULT_ENCODER_CONV_LAYERS, ASRConfig, compute_encoder_output_length
13
- except ImportError:
14
- from asr_config import ( # type: ignore[no-redef]
15
  DEFAULT_ENCODER_CONV_LAYERS,
16
  ASRConfig,
 
17
  compute_encoder_output_length,
18
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- def prepend_lead_in(audio, sampling_rate: int, seconds: float):
 
 
 
 
 
 
 
 
 
 
 
22
  """Prepend `seconds` of silence to a waveform (or each waveform in a list).
23
 
24
  Peoples ships fixed ~15s grid cuts rather than sentence-aligned segments,
@@ -38,19 +96,24 @@ def prepend_lead_in(audio, sampling_rate: int, seconds: float):
38
  return audio
39
 
40
  if isinstance(audio, (list, tuple)) and audio and not isinstance(audio[0], (int, float)):
41
- return [prepend_lead_in(a, sampling_rate, seconds) for a in audio]
 
42
 
 
43
  pad = round(sampling_rate * seconds)
44
  if pad <= 0:
45
- return audio
46
- arr = np.asarray(audio)
47
- return np.concatenate([np.zeros(pad, dtype=arr.dtype), arr])
 
48
 
49
 
50
  class ASRProcessor(ProcessorMixin):
51
  """Processor for Whisper-based ASR models."""
52
 
53
  attributes: ClassVar[list[str]] = ["feature_extractor", "tokenizer"]
 
 
54
  feature_extractor_class = "AutoFeatureExtractor"
55
  tokenizer_class = "AutoTokenizer"
56
  # Fallback only. The real value comes from `ASRConfig.audio_token`, which
@@ -64,10 +127,10 @@ class ASRProcessor(ProcessorMixin):
64
 
65
  def __init__(
66
  self,
67
- feature_extractor,
68
- tokenizer,
69
- projector=None,
70
- encoder_conv_layers: list | None = None,
71
  audio_token: str | None = None,
72
  lead_in_seconds: float = 0.0,
73
  ):
@@ -107,12 +170,16 @@ class ASRProcessor(ProcessorMixin):
107
  if text is not None:
108
  messages.append({"role": "assistant", "content": text})
109
 
110
- tokenized = self.tokenizer.apply_chat_template(
111
- messages,
112
- tokenize=True,
113
- add_generation_prompt=(text is None),
114
- return_tensors="pt",
115
- enable_thinking=False, # Disable Qwen3 thinking mode for ASR
 
 
 
 
116
  )
117
 
118
  # apply_chat_template returns a bare tensor or a BatchEncoding/mapping.
@@ -120,33 +187,25 @@ class ASRProcessor(ProcessorMixin):
120
  return (ids[0] if ids.dim() > 1 else ids).to(torch.long)
121
 
122
  def _stack_prompt_rows(self, rows: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
123
- """Stack per-sample prompt rows into a batch, left-padding if ragged.
 
 
 
 
124
 
125
- Left, not right: these feed `generate`, so padding must not sit between
126
- the prompt and the first generated token. Mirrors
127
- `ASRModel._left_pad_prompt_rows`; pad positions never carry
128
- `audio_token_id`, so the model's masked_scatter is unaffected.
129
  """
130
- pad_id = self.tokenizer.pad_token_id
131
- if pad_id is None:
132
- pad_id = self.tokenizer.eos_token_id or 0
133
- input_ids = pad_sequence(
134
- rows, batch_first=True, padding_value=int(pad_id), padding_side="left"
135
- )
136
- # Padded from ones rather than `input_ids != pad_id`: a real token may
137
- # equal `pad_id` when pad falls back to eos.
138
- attention_mask = pad_sequence(
139
- [torch.ones_like(row) for row in rows], batch_first=True, padding_side="left"
140
- )
141
- return input_ids, attention_mask
142
 
143
- def __call__(
144
  self,
145
- audio: Union[list, "torch.Tensor"] | None = None,
146
  text: str | None = None,
147
  return_tensors: str = "pt",
148
- **kwargs,
149
- ) -> dict:
150
  """Process audio and text inputs for inference.
151
 
152
  Args:
@@ -157,15 +216,16 @@ class ASRProcessor(ProcessorMixin):
157
  Returns:
158
  Dict with input_features, input_ids, attention_mask
159
  """
160
- result = {}
161
  token_counts = [0]
162
 
163
  # Process audio
164
  if audio is not None:
165
  sr = getattr(self.feature_extractor, "sampling_rate", 16000)
166
- audio = prepend_lead_in(audio, sr, self.lead_in_seconds)
167
- audio_inputs = self.feature_extractor(
168
- audio,
 
169
  sampling_rate=getattr(self.feature_extractor, "sampling_rate", 16000),
170
  return_attention_mask=True,
171
  return_tensors=return_tensors,
@@ -175,10 +235,11 @@ class ASRProcessor(ProcessorMixin):
175
  result["audio_attention_mask"] = audio_inputs["attention_mask"]
176
 
177
  if self.projector is None:
178
- raise ValueError(
179
  "ASRProcessor needs a projector to size the audio prompt. Build it "
180
  "with ASRModel.get_processor() instead of constructing it directly."
181
  )
 
182
 
183
  # One count per sample, from that sample's own mel length. Sizing a
184
  # single shared prompt from the batch max -- which this used to do --
 
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,
13
+ PreTrainedTokenizerBase,
14
+ ProcessorMixin,
15
+ SequenceFeatureExtractor,
16
+ )
17
 
18
+ if TYPE_CHECKING:
19
+ from .asr_config import (
 
 
20
  DEFAULT_ENCODER_CONV_LAYERS,
21
  ASRConfig,
22
+ ConvLayerSpec,
23
  compute_encoder_output_length,
24
  )
25
+ from .asr_types import AudioFeatureExtractor, AudioInput, Waveform
26
+ from .projectors import MLPAudioProjector
27
+ else:
28
+ try:
29
+ from .asr_config import (
30
+ DEFAULT_ENCODER_CONV_LAYERS,
31
+ ASRConfig,
32
+ ConvLayerSpec,
33
+ compute_encoder_output_length,
34
+ )
35
+ from .asr_types import AudioInput
36
+ except ImportError: # flat layout on the Hub: sibling modules, no package
37
+ from asr_config import (
38
+ DEFAULT_ENCODER_CONV_LAYERS,
39
+ ASRConfig,
40
+ ConvLayerSpec,
41
+ compute_encoder_output_length,
42
+ )
43
+ from asr_types import AudioInput
44
+
45
 
46
+ def left_pad_prompt_rows(
47
+ rows: list[torch.Tensor], tokenizer: PreTrainedTokenizerBase
48
+ ) -> tuple[torch.Tensor, torch.Tensor]:
49
+ """Stack per-sample prompt rows into a left-padded batch: `(input_ids, attention_mask)`.
50
+
51
+ Left, not right: these feed `generate`, so padding must not sit between
52
+ the prompt and the first generated token. Pads with the tokenizer's pad
53
+ token, falling back to eos, then 0. Pad positions never carry
54
+ `audio_token_id`, so the model's masked_scatter is unaffected.
55
+ """
56
+ # transformers types special-token ids as any token value; a single id is an int.
57
+ pad_id = cast("int | None", tokenizer.pad_token_id)
58
+ if pad_id is None:
59
+ pad_id = cast("int | None", tokenizer.eos_token_id) or 0
60
+ input_ids = pad_sequence(rows, batch_first=True, padding_value=int(pad_id), padding_side="left")
61
+ # Padded from ones rather than `input_ids != pad_id`: a real token may
62
+ # equal `pad_id` when pad falls back to eos.
63
+ attention_mask = pad_sequence(
64
+ [torch.ones_like(row) for row in rows], batch_first=True, padding_side="left"
65
+ )
66
+ return input_ids, attention_mask
67
 
68
+
69
+ @overload
70
+ def prepend_lead_in[ScalarT: np.generic](
71
+ audio: npt.NDArray[ScalarT], sampling_rate: int, seconds: float | None
72
+ ) -> npt.NDArray[ScalarT]: ...
73
+ @overload
74
+ def prepend_lead_in[ScalarT: np.generic](
75
+ audio: list[npt.NDArray[ScalarT]], sampling_rate: int, seconds: float | None
76
+ ) -> list[npt.NDArray[ScalarT]]: ...
77
+ @overload
78
+ def prepend_lead_in(audio: AudioInput, sampling_rate: int, seconds: float | None) -> AudioInput: ...
79
+ def prepend_lead_in(audio: AudioInput, sampling_rate: int, seconds: float | None) -> AudioInput:
80
  """Prepend `seconds` of silence to a waveform (or each waveform in a list).
81
 
82
  Peoples ships fixed ~15s grid cuts rather than sentence-aligned segments,
 
96
  return audio
97
 
98
  if isinstance(audio, (list, tuple)) and audio and not isinstance(audio[0], (int, float)):
99
+ batch = cast("Sequence[Waveform]", audio)
100
+ return [cast("Waveform", prepend_lead_in(a, sampling_rate, seconds)) for a in batch]
101
 
102
+ waveform = cast("Waveform", audio)
103
  pad = round(sampling_rate * seconds)
104
  if pad <= 0:
105
+ return waveform
106
+ arr: npt.NDArray[Any] = np.asarray(waveform)
107
+ padded: npt.NDArray[Any] = np.pad(arr, (pad, 0))
108
+ return padded
109
 
110
 
111
  class ASRProcessor(ProcessorMixin):
112
  """Processor for Whisper-based ASR models."""
113
 
114
  attributes: ClassVar[list[str]] = ["feature_extractor", "tokenizer"]
115
+ feature_extractor: SequenceFeatureExtractor
116
+ tokenizer: PreTrainedTokenizerBase
117
  feature_extractor_class = "AutoFeatureExtractor"
118
  tokenizer_class = "AutoTokenizer"
119
  # Fallback only. The real value comes from `ASRConfig.audio_token`, which
 
127
 
128
  def __init__(
129
  self,
130
+ feature_extractor: SequenceFeatureExtractor,
131
+ tokenizer: PreTrainedTokenizerBase,
132
+ projector: "MLPAudioProjector | None" = None,
133
+ encoder_conv_layers: list[ConvLayerSpec] | None = None,
134
  audio_token: str | None = None,
135
  lead_in_seconds: float = 0.0,
136
  ):
 
170
  if text is not None:
171
  messages.append({"role": "assistant", "content": text})
172
 
173
+ # With `tokenize=True, return_tensors="pt"` the ids come back as tensors.
174
+ tokenized = cast(
175
+ "torch.Tensor | Mapping[str, torch.Tensor]",
176
+ self.tokenizer.apply_chat_template(
177
+ messages,
178
+ tokenize=True,
179
+ add_generation_prompt=(text is None),
180
+ return_tensors="pt",
181
+ enable_thinking=False, # Disable Qwen3 thinking mode for ASR
182
+ ),
183
  )
184
 
185
  # apply_chat_template returns a bare tensor or a BatchEncoding/mapping.
 
187
  return (ids[0] if ids.dim() > 1 else ids).to(torch.long)
188
 
189
  def _stack_prompt_rows(self, rows: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
190
+ """Stack per-sample prompt rows into a batch (see `left_pad_prompt_rows`)."""
191
+ return left_pad_prompt_rows(rows, self.tokenizer)
192
+
193
+ def __call__(self, *args: Any, **kwargs: Any) -> BatchFeature:
194
+ """Process audio and text inputs for inference; see `_process` for the arguments.
195
 
196
+ `ProcessorMixin.__call__` takes `(images, text, videos, audio, ...)`; this
197
+ processor takes audio first, so the arguments are forwarded unchanged to
198
+ `_process`, which carries the real signature.
 
199
  """
200
+ return BatchFeature(data=self._process(*args, **kwargs))
 
 
 
 
 
 
 
 
 
 
 
201
 
202
+ def _process(
203
  self,
204
+ audio: AudioInput | None = None,
205
  text: str | None = None,
206
  return_tensors: str = "pt",
207
+ **kwargs: Any,
208
+ ) -> dict[str, torch.Tensor]:
209
  """Process audio and text inputs for inference.
210
 
211
  Args:
 
216
  Returns:
217
  Dict with input_features, input_ids, attention_mask
218
  """
219
+ result: dict[str, torch.Tensor] = {}
220
  token_counts = [0]
221
 
222
  # Process audio
223
  if audio is not None:
224
  sr = getattr(self.feature_extractor, "sampling_rate", 16000)
225
+ padded_audio = prepend_lead_in(audio, sr, self.lead_in_seconds)
226
+ extract = cast("AudioFeatureExtractor", self.feature_extractor)
227
+ audio_inputs = extract(
228
+ padded_audio,
229
  sampling_rate=getattr(self.feature_extractor, "sampling_rate", 16000),
230
  return_attention_mask=True,
231
  return_tensors=return_tensors,
 
235
  result["audio_attention_mask"] = audio_inputs["attention_mask"]
236
 
237
  if self.projector is None:
238
+ msg = (
239
  "ASRProcessor needs a projector to size the audio prompt. Build it "
240
  "with ASRModel.get_processor() instead of constructing it directly."
241
  )
242
+ raise ValueError(msg)
243
 
244
  # One count per sample, from that sample's own mel length. Sizing a
245
  # single shared prompt from the batch max -- which this used to do --
asr_types.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Static types shared by the asr_* modules: audio aliases, loader kwargs, protocols.
2
+
3
+ Typing scaffolding with no behaviour of its own: the `TypedDict`s describe
4
+ the keyword arguments ASRModel forwards to `from_pretrained` / `cached_file`,
5
+ and the protocol classes describe surfaces with several implementations (or
6
+ test fakes) and no common library base that declares them.
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING, Any, Protocol, TypedDict, TypeVar
11
+
12
+ import numpy.typing as npt
13
+ import torch
14
+ from transformers import BatchFeature, PreTrainedModel
15
+ from transformers.generation.utils import GenerateOutput, GenerationMixin
16
+
17
+ # One waveform (array, tensor or list of samples), or a batch of them.
18
+ Waveform = npt.ArrayLike | torch.Tensor
19
+ AudioInput = Waveform | Sequence[Waveform]
20
+
21
+ # A `state_dict(destination=...)` mapping, returned as the caller's own type.
22
+ StateDictT = TypeVar("StateDictT", bound=dict[str, Any])
23
+
24
+ if TYPE_CHECKING:
25
+
26
+ class GenerativeDecoder(PreTrainedModel, GenerationMixin):
27
+ """Static view of the decoder: a PreTrainedModel that can generate.
28
+
29
+ transformers annotates GenerationMixin's `self` with a protocol
30
+ (`GenerativePreTrainedModel`) that no PreTrainedModel satisfies -- it
31
+ asks for a mutable `device` where the model has a read-only property --
32
+ so neither checker can bind `generate` on a real model. This restates
33
+ the two generation entry points ASRModel calls without that bound.
34
+ Type-checking only; the runtime object is whatever the loader returned.
35
+ """
36
+
37
+ def generate(self, *args: Any, **kwargs: Any) -> GenerateOutput | torch.LongTensor:
38
+ """`GenerationMixin.generate`."""
39
+ ...
40
+
41
+ def prepare_inputs_for_generation(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
42
+ """`GenerationMixin.prepare_inputs_for_generation`."""
43
+ ...
44
+
45
+
46
+ class LoadKwargs(TypedDict):
47
+ """Loader arguments shared by the encoder and decoder `from_pretrained` calls."""
48
+
49
+ attn_implementation: str | None
50
+ low_cpu_mem_usage: bool
51
+ dtype: torch.dtype
52
+
53
+
54
+ class DecoderLoadKwargs(LoadKwargs):
55
+ """Decoder loader arguments: the shared ones plus remote-code opt-in."""
56
+
57
+ trust_remote_code: bool
58
+
59
+
60
+ class HubFileKwargs(TypedDict, total=False):
61
+ """Hub location arguments forwarded to `cached_file` and PEFT's loader."""
62
+
63
+ subfolder: str
64
+ revision: str
65
+
66
+
67
+ class AudioFeatureExtractor(Protocol):
68
+ """A concrete audio feature extractor's `__call__` (e.g. Whisper's).
69
+
70
+ `SequenceFeatureExtractor` declares no `__call__`, though every concrete
71
+ extractor has one; cast an extractor to this to call it.
72
+ """
73
+
74
+ def __call__(
75
+ self,
76
+ raw_speech: AudioInput,
77
+ /,
78
+ *,
79
+ sampling_rate: int,
80
+ return_attention_mask: bool,
81
+ return_tensors: str,
82
+ padding: bool | str = ...,
83
+ **kwargs: Any,
84
+ ) -> BatchFeature:
85
+ """Featurize raw audio into `input_features` (plus `attention_mask`)."""
86
+ ...
87
+
88
+
89
+ class OutputLengthProjector(Protocol):
90
+ """The projector surface the token-count check needs."""
91
+
92
+ def get_output_length(self, input_length: torch.Tensor) -> torch.Tensor:
93
+ """Projector output length for encoder output length `input_length`."""
94
+ ...
95
+
96
+
97
+ class LoadStateDictResult(Protocol):
98
+ """What `load_state_dict` reports back (torch's `_IncompatibleKeys`)."""
99
+
100
+ @property
101
+ def missing_keys(self) -> Sequence[str]:
102
+ """Model keys the state dict did not provide."""
103
+ ...
104
+
105
+ @property
106
+ def unexpected_keys(self) -> Sequence[str]:
107
+ """State-dict keys the model has no slot for."""
108
+ ...
diarization.py CHANGED
@@ -1,26 +1,32 @@
1
- """Speaker diarization: Nemotron-3-Diarization, and TEN-VAD + ECAPA-TDNN + spectral clustering.
2
 
3
- Spectral clustering implementation adapted from FunASR/3D-Speaker:
4
- https://github.com/alibaba-damo-academy/FunASR
5
- MIT License (https://opensource.org/licenses/MIT)
6
- """
7
-
8
- import warnings
9
 
10
  import numpy as np
 
11
  import torch
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- # The ECAPA SpeakerDiarizer's packages, imported inside try blocks: transformers
14
- # scans every bundled file's imports before loading the model and refuses to load
15
- # if one is missing, and it skips imports inside `try`. The pipeline diarizes with
16
- # NemotronDiarizer, so these stay optional.
17
- _ECAPA_EXTRAS = (
18
- "SpeakerDiarizer (ECAPA) needs extra packages: "
19
- "pip install speechbrain ten-vad scikit-learn scipy librosa"
20
- )
21
 
 
 
 
22
 
23
- def _get_device() -> torch.device:
 
24
  """Get best available device for inference."""
25
  if torch.cuda.is_available():
26
  return torch.device("cuda")
@@ -29,13 +35,13 @@ def _get_device() -> torch.device:
29
  return torch.device("cpu")
30
 
31
 
32
- def _label_runs(labels: np.ndarray) -> list[tuple[int, int, int]]:
33
  """Split a 1-D label array into maximal constant runs.
34
 
35
  Returns `(value, start, end)` triples with `end` exclusive, in order. This
36
- is the frames-to-segments step for both the VAD decisions and the voting
37
- grid: a boundary is wherever the label changes, and the last run always
38
- closes at `len(labels)`, so a trailing run needs no special case.
39
  """
40
  labels = np.asarray(labels)
41
  if labels.size == 0:
@@ -43,671 +49,7 @@ def _label_runs(labels: np.ndarray) -> list[tuple[int, int, int]]:
43
  change = np.flatnonzero(labels[1:] != labels[:-1]) + 1
44
  starts = np.concatenate(([0], change))
45
  ends = np.concatenate((change, [labels.size]))
46
- return [(labels[s].item(), int(s), int(e)) for s, e in zip(starts, ends)]
47
-
48
-
49
- class SpectralCluster:
50
- """Spectral clustering using unnormalized Laplacian of affinity matrix.
51
-
52
- Adapted from FunASR/3D-Speaker and SpeechBrain implementations.
53
- Uses eigenvalue gap to automatically determine number of speakers.
54
- """
55
-
56
- def __init__(self, min_num_spks: int = 1, max_num_spks: int = 15, pval: float = 0.06):
57
- """Configure the speaker-count search range and the affinity pruning fraction."""
58
- self.min_num_spks = min_num_spks
59
- self.max_num_spks = max_num_spks
60
- self.pval = pval
61
-
62
- def __call__(self, embeddings: np.ndarray, oracle_num: int | None = None) -> np.ndarray:
63
- """Run spectral clustering on embeddings.
64
-
65
- Args:
66
- embeddings: Speaker embeddings of shape [N, D]
67
- oracle_num: Optional known number of speakers
68
-
69
- Returns:
70
- Cluster labels of shape [N]
71
- """
72
- # Similarity matrix computation
73
- sim_mat = self.get_sim_mat(embeddings)
74
-
75
- # Refining similarity matrix with pval
76
- prunned_sim_mat = self.p_pruning(sim_mat)
77
-
78
- # Symmetrization
79
- sym_prund_sim_mat = 0.5 * (prunned_sim_mat + prunned_sim_mat.T)
80
-
81
- # Laplacian calculation
82
- laplacian = self.get_laplacian(sym_prund_sim_mat)
83
-
84
- # Get Spectral Embeddings
85
- emb, num_of_spk = self.get_spec_embs(laplacian, oracle_num)
86
-
87
- # Perform clustering
88
- return self.cluster_embs(emb, num_of_spk)
89
-
90
- def get_sim_mat(self, embeddings: np.ndarray) -> np.ndarray:
91
- """Compute cosine similarity matrix."""
92
- try:
93
- from sklearn.metrics.pairwise import cosine_similarity
94
- except ImportError as e:
95
- raise ImportError(_ECAPA_EXTRAS) from e
96
-
97
- return cosine_similarity(embeddings, embeddings)
98
-
99
- def p_pruning(self, affinity: np.ndarray) -> np.ndarray:
100
- """Prune low similarity values in affinity matrix (keep top pval fraction)."""
101
- n = affinity.shape[0]
102
- pval = max(self.pval, 6.0 / n)
103
- k_keep = max(1, int(pval * n))
104
-
105
- # Vectorized: find top-k indices per row and zero out the rest
106
- top_k_idx = np.argpartition(affinity, -k_keep, axis=1)[:, -k_keep:]
107
- mask = np.zeros_like(affinity, dtype=bool)
108
- np.put_along_axis(mask, top_k_idx, True, axis=1)
109
- affinity[~mask] = 0
110
- return affinity
111
-
112
- def get_laplacian(self, sim_mat: np.ndarray) -> np.ndarray:
113
- """Compute unnormalized Laplacian matrix."""
114
- try:
115
- from scipy.sparse.csgraph import laplacian
116
- except ImportError as e:
117
- raise ImportError(_ECAPA_EXTRAS) from e
118
-
119
- np.fill_diagonal(sim_mat, 0)
120
- return laplacian(sim_mat, normed=False)
121
-
122
- def get_spec_embs(
123
- self, laplacian: np.ndarray, k_oracle: int | None = None
124
- ) -> tuple[np.ndarray, int]:
125
- """Extract spectral embeddings from Laplacian."""
126
- try:
127
- from scipy.linalg import eigh
128
- except ImportError as e:
129
- raise ImportError(_ECAPA_EXTRAS) from e
130
-
131
- lambdas, eig_vecs = eigh(laplacian)
132
-
133
- if k_oracle is not None:
134
- num_of_spk = k_oracle
135
- else:
136
- lambda_gap_list = self.get_eigen_gaps(
137
- lambdas[self.min_num_spks - 1 : self.max_num_spks + 1]
138
- )
139
- num_of_spk = np.argmax(lambda_gap_list) + self.min_num_spks
140
-
141
- emb = eig_vecs[:, :num_of_spk]
142
- return emb, num_of_spk
143
-
144
- def cluster_embs(self, emb: np.ndarray, k: int) -> np.ndarray:
145
- """Cluster spectral embeddings using k-means."""
146
- try:
147
- from sklearn.cluster._kmeans import k_means
148
- except ImportError as e:
149
- raise ImportError(_ECAPA_EXTRAS) from e
150
-
151
- _, labels, _ = k_means(emb, k, n_init=10)
152
- return labels
153
-
154
- def get_eigen_gaps(self, eig_vals: np.ndarray) -> np.ndarray:
155
- """Compute gaps between consecutive eigenvalues."""
156
- return np.diff(eig_vals)
157
-
158
-
159
- class SpeakerClusterer:
160
- """Speaker clustering backend using spectral clustering with speaker merging.
161
-
162
- Features:
163
- - Spectral clustering with eigenvalue gap for auto speaker count detection
164
- - P-pruning for affinity matrix refinement
165
- - Post-clustering speaker merging by cosine similarity
166
- """
167
-
168
- def __init__(
169
- self,
170
- min_num_spks: int = 2,
171
- max_num_spks: int = 10,
172
- merge_thr: float = 0.90, # Moderate merging
173
- ):
174
- """Configure the speaker-count range and the centroid-merge cosine threshold."""
175
- self.min_num_spks = min_num_spks
176
- self.max_num_spks = max_num_spks
177
- self.merge_thr = merge_thr
178
- self._spectral_cluster: SpectralCluster | None = None
179
-
180
- def _get_spectral_cluster(self) -> SpectralCluster:
181
- """Lazy-load spectral clusterer."""
182
- if self._spectral_cluster is None:
183
- self._spectral_cluster = SpectralCluster(
184
- min_num_spks=self.min_num_spks,
185
- max_num_spks=self.max_num_spks,
186
- )
187
- return self._spectral_cluster
188
-
189
- def __call__(self, embeddings: np.ndarray, num_speakers: int | None = None) -> np.ndarray:
190
- """Cluster speaker embeddings and return labels.
191
-
192
- Args:
193
- embeddings: Speaker embeddings of shape [N, D]
194
- num_speakers: Optional oracle number of speakers
195
-
196
- Returns:
197
- Cluster labels of shape [N]
198
- """
199
- if len(embeddings.shape) != 2:
200
- raise ValueError(f"Expected 2D array, got shape {embeddings.shape}")
201
-
202
- # Handle edge cases
203
- if embeddings.shape[0] == 0:
204
- return np.array([], dtype=int)
205
- if embeddings.shape[0] == 1:
206
- return np.array([0], dtype=int)
207
- if embeddings.shape[0] < 6:
208
- return np.zeros(embeddings.shape[0], dtype=int)
209
-
210
- # Normalize embeddings and replace NaN/inf
211
- try:
212
- from sklearn.preprocessing import normalize
213
- except ImportError as e:
214
- raise ImportError(_ECAPA_EXTRAS) from e
215
-
216
- embeddings = np.nan_to_num(embeddings, nan=0.0, posinf=0.0, neginf=0.0)
217
- embeddings = normalize(embeddings)
218
-
219
- # Run spectral clustering (suppress numerical warnings)
220
- spectral = self._get_spectral_cluster()
221
-
222
- # Update min/max for oracle case
223
- if num_speakers is not None:
224
- spectral.min_num_spks = num_speakers
225
- spectral.max_num_spks = num_speakers
226
-
227
- with warnings.catch_warnings():
228
- warnings.filterwarnings("ignore", category=RuntimeWarning)
229
- labels = spectral(embeddings, oracle_num=num_speakers)
230
-
231
- # Reset min/max
232
- if num_speakers is not None:
233
- spectral.min_num_spks = self.min_num_spks
234
- spectral.max_num_spks = self.max_num_spks
235
-
236
- # Merge similar speakers if no oracle
237
- if num_speakers is None:
238
- labels = self._merge_by_cos(labels, embeddings, self.merge_thr)
239
-
240
- # Re-index labels sequentially
241
- _, labels = np.unique(labels, return_inverse=True)
242
-
243
- return labels
244
-
245
- def _merge_by_cos(self, labels: np.ndarray, embs: np.ndarray, cos_thr: float) -> np.ndarray:
246
- """Merge similar speakers by cosine similarity of centroids."""
247
- try:
248
- from sklearn.cluster import AgglomerativeClustering
249
- except ImportError as e:
250
- raise ImportError(_ECAPA_EXTRAS) from e
251
- try:
252
- from sklearn.preprocessing import normalize
253
- except ImportError as e:
254
- raise ImportError(_ECAPA_EXTRAS) from e
255
-
256
- unique_labels, inverse = np.unique(labels, return_inverse=True)
257
- if len(unique_labels) <= 1:
258
- return labels
259
-
260
- # Average-linkage agglomeration over the normalized speaker centroids,
261
- # cut where centroids are closer than `1 - cos_thr` in cosine distance.
262
- centroids = normalize(np.array([embs[labels == lbl].mean(0) for lbl in unique_labels]))
263
- merged = AgglomerativeClustering(
264
- n_clusters=None,
265
- distance_threshold=1.0 - cos_thr,
266
- metric="cosine",
267
- linkage="average",
268
- ).fit_predict(centroids)
269
- return merged[inverse]
270
-
271
-
272
- class SpeakerDiarizer:
273
- """Speaker diarization using TEN-VAD + ECAPA-TDNN + spectral clustering.
274
-
275
- Example:
276
- >>> segments = SpeakerDiarizer.diarize(audio_array)
277
- >>> for seg in segments:
278
- ... print(f"{seg['speaker']}: {seg['start']:.2f} - {seg['end']:.2f}")
279
-
280
- Pipeline:
281
- 1. TEN-VAD detects speech segments
282
- 2. Sliding window (1.0s, 75% overlap) for uniform embedding extraction
283
- 3. ECAPA-TDNN extracts speaker embeddings per window
284
- 4. Spectral clustering with eigenvalue gap for auto speaker detection
285
- 5. Frame-level consensus voting for segment reconstruction
286
- 6. Post-processing merges short segments to reduce flicker
287
-
288
- Tunable Parameters (class attributes):
289
- - WINDOW_SIZE: Embedding extraction window size in seconds
290
- - STEP_SIZE: Sliding window step size (overlap = WINDOW_SIZE - STEP_SIZE)
291
- - VAD_THRESHOLD: Speech detection threshold (lower = more sensitive)
292
- - VAD_MIN_DURATION: Minimum speech segment duration
293
- - VAD_MAX_GAP: Maximum gap to bridge between segments
294
- - VAD_PAD_ONSET/OFFSET: Padding added to speech segments
295
- - VOTING_RATE: Frame resolution for consensus voting
296
- - MIN_SEGMENT_DURATION: Minimum final segment duration
297
- - SAME_SPEAKER_GAP: Maximum gap to merge same-speaker segments
298
- - TAIL_COVERAGE_RATIO: Minimum tail coverage to add extra window
299
- """
300
-
301
- _ten_vad_model = None
302
- _ecapa_model = None
303
- _device = None
304
-
305
- # ==================== TUNABLE PARAMETERS ====================
306
-
307
- # Sliding window for embedding extraction
308
- WINDOW_SIZE = 0.75 # seconds - shorter window for finer resolution
309
- STEP_SIZE = 0.15 # seconds (80% overlap for more votes)
310
- TAIL_COVERAGE_RATIO = 0.1 # Add extra window if tail > this ratio of window
311
-
312
- # VAD hysteresis parameters
313
- VAD_THRESHOLD = 0.25 # Balanced threshold
314
- VAD_MIN_DURATION = 0.05 # Minimum speech segment duration (seconds)
315
- VAD_MAX_GAP = 0.50 # Bridge gaps shorter than this (seconds)
316
- VAD_PAD_ONSET = 0.05 # Padding at segment start (seconds)
317
- VAD_PAD_OFFSET = 0.05 # Padding at segment end (seconds)
318
-
319
- # Frame-level voting
320
- VOTING_RATE = 0.01 # 10ms resolution for consensus voting
321
-
322
- # Post-processing
323
- MIN_SEGMENT_DURATION = 0.15 # Minimum final segment duration (seconds)
324
- SHORT_SEGMENT_GAP = 0.1 # Gap threshold for merging short segments
325
- SAME_SPEAKER_GAP = 0.5 # Gap threshold for merging same-speaker segments
326
-
327
- # ===========================================================
328
-
329
- @classmethod
330
- def _get_ten_vad_model(cls):
331
- """Lazy-load TEN-VAD model (singleton)."""
332
- if cls._ten_vad_model is None:
333
- try:
334
- from ten_vad import TenVad
335
- except ImportError as e:
336
- raise ImportError(_ECAPA_EXTRAS) from e
337
-
338
- cls._ten_vad_model = TenVad(hop_size=256, threshold=cls.VAD_THRESHOLD)
339
- return cls._ten_vad_model
340
-
341
- @classmethod
342
- def _get_device(cls) -> torch.device:
343
- """Get the best available device."""
344
- if cls._device is None:
345
- cls._device = _get_device()
346
- return cls._device
347
-
348
- @classmethod
349
- def _get_ecapa_model(cls):
350
- """Lazy-load ECAPA-TDNN speaker embedding model (singleton)."""
351
- if cls._ecapa_model is None:
352
- # Suppress torchaudio deprecation warning from SpeechBrain
353
- with warnings.catch_warnings():
354
- warnings.filterwarnings("ignore", message="torchaudio._backend")
355
- try:
356
- from speechbrain.inference.speaker import EncoderClassifier
357
- except ImportError as e:
358
- raise ImportError(_ECAPA_EXTRAS) from e
359
-
360
- device = cls._get_device()
361
- cls._ecapa_model = EncoderClassifier.from_hparams(
362
- source="speechbrain/spkrec-ecapa-voxceleb",
363
- run_opts={"device": str(device)},
364
- )
365
-
366
- return cls._ecapa_model
367
-
368
- @classmethod
369
- def diarize(
370
- cls,
371
- audio: np.ndarray | str,
372
- sample_rate: int = 16000,
373
- num_speakers: int | None = None,
374
- min_speakers: int = 2,
375
- max_speakers: int = 10,
376
- **_kwargs,
377
- ) -> list[dict]:
378
- """Run speaker diarization on audio.
379
-
380
- Args:
381
- audio: Audio waveform as numpy array or path to audio file
382
- sample_rate: Audio sample rate (default 16000)
383
- num_speakers: Exact number of speakers (if known)
384
- min_speakers: Minimum number of speakers
385
- max_speakers: Maximum number of speakers
386
-
387
- Returns:
388
- List of dicts with 'speaker', 'start', 'end' keys
389
- """
390
- # Handle file path input
391
- if isinstance(audio, str):
392
- try:
393
- import librosa
394
- except ImportError as e:
395
- raise ImportError(_ECAPA_EXTRAS) from e
396
-
397
- audio, sample_rate = librosa.load(audio, sr=16000)
398
-
399
- # Ensure correct sample rate
400
- if sample_rate != 16000:
401
- try:
402
- import librosa
403
- except ImportError as e:
404
- raise ImportError(_ECAPA_EXTRAS) from e
405
-
406
- audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=16000)
407
- sample_rate = 16000
408
-
409
- audio = audio.astype(np.float32)
410
- total_duration = len(audio) / sample_rate
411
-
412
- # Step 1: VAD (returns segments and raw frame-level decisions)
413
- segments, vad_frames = cls._get_speech_segments(audio, sample_rate)
414
- if not segments:
415
- return []
416
-
417
- # Step 2: Extract embeddings
418
- embeddings, window_segments = cls._extract_embeddings(audio, segments, sample_rate)
419
- if len(embeddings) == 0:
420
- return []
421
-
422
- # Step 3: Cluster
423
- clusterer = SpeakerClusterer(min_num_spks=min_speakers, max_num_spks=max_speakers)
424
- labels = clusterer(embeddings, num_speakers)
425
-
426
- # Step 4: Post-process with consensus voting (VAD-aware)
427
- return cls._postprocess_segments(window_segments, labels, total_duration, vad_frames)
428
-
429
- @classmethod
430
- def _get_speech_segments(
431
- cls, audio_array: np.ndarray, sample_rate: int = 16000
432
- ) -> tuple[list[dict], list[bool]]:
433
- """Get speech segments using TEN-VAD.
434
-
435
- Returns:
436
- Tuple of (segments list, vad_frames list of per-frame speech decisions)
437
- """
438
- vad_model = cls._get_ten_vad_model()
439
-
440
- # Convert to int16 as required by TEN-VAD
441
- # Clip to prevent integer overflow
442
- if audio_array.dtype != np.int16:
443
- audio_int16 = (np.clip(audio_array, -1.0, 1.0) * 32767).astype(np.int16)
444
- else:
445
- audio_int16 = audio_array
446
-
447
- # Process frame by frame
448
- hop_size = 256
449
- frame_duration = hop_size / sample_rate
450
- speech_frames: list[bool] = []
451
-
452
- # `- hop_size + 1`, not `- hop_size`: the latter stops one frame
453
- # early and drops the last COMPLETE frame (3 frames for 1024 samples
454
- # instead of 4), truncating the trailing speech segment by one hop and
455
- # forcing a silence/speaker cut at the end of every clip.
456
- for i in range(0, len(audio_int16) - hop_size + 1, hop_size):
457
- frame = audio_int16[i : i + hop_size]
458
- _, is_speech = vad_model.process(frame)
459
- speech_frames.append(is_speech)
460
-
461
- # Convert frame-level decisions to segments: one per run of speech frames.
462
- segments = []
463
- for is_speech, start_idx, end_idx in _label_runs(np.array(speech_frames, dtype=bool)):
464
- if not is_speech:
465
- continue
466
- start_time = start_idx * frame_duration
467
- end_time = end_idx * frame_duration
468
- segments.append(
469
- {
470
- "start": start_time,
471
- "end": end_time,
472
- "start_sample": int(start_time * sample_rate),
473
- "end_sample": int(end_time * sample_rate),
474
- }
475
- )
476
-
477
- return cls._apply_vad_hysteresis(segments, sample_rate), speech_frames
478
-
479
- @classmethod
480
- def _apply_vad_hysteresis(cls, segments: list[dict], sample_rate: int = 16000) -> list[dict]:
481
- """Apply hysteresis-like post-processing to VAD segments."""
482
- if not segments:
483
- return segments
484
-
485
- segments = sorted(segments, key=lambda x: x["start"])
486
-
487
- # Fill short gaps
488
- merged = [segments[0].copy()]
489
- for seg in segments[1:]:
490
- gap = seg["start"] - merged[-1]["end"]
491
- if gap <= cls.VAD_MAX_GAP:
492
- merged[-1]["end"] = seg["end"]
493
- merged[-1]["end_sample"] = seg["end_sample"]
494
- else:
495
- merged.append(seg.copy())
496
-
497
- # Remove short segments
498
- filtered = [seg for seg in merged if (seg["end"] - seg["start"]) >= cls.VAD_MIN_DURATION]
499
-
500
- # Dilate segments (add padding)
501
- for seg in filtered:
502
- seg["start"] = max(0.0, seg["start"] - cls.VAD_PAD_ONSET)
503
- seg["end"] = seg["end"] + cls.VAD_PAD_OFFSET
504
- seg["start_sample"] = int(seg["start"] * sample_rate)
505
- seg["end_sample"] = int(seg["end"] * sample_rate)
506
-
507
- return filtered
508
-
509
- @classmethod
510
- def _extract_embeddings(
511
- cls, audio_array: np.ndarray, segments: list[dict], sample_rate: int
512
- ) -> tuple[np.ndarray, list[dict]]:
513
- """Extract speaker embeddings using sliding windows."""
514
- speaker_model = cls._get_ecapa_model()
515
-
516
- window_samples = int(cls.WINDOW_SIZE * sample_rate)
517
- step_samples = int(cls.STEP_SIZE * sample_rate)
518
-
519
- embeddings = []
520
- window_segments = []
521
-
522
- with torch.no_grad():
523
- for seg in segments:
524
- seg_start = seg["start_sample"]
525
- seg_end = seg["end_sample"]
526
- chunk = audio_array[seg_start:seg_end]
527
-
528
- if len(chunk) <= window_samples:
529
- # Pad short segments with reflection to a full window.
530
- starts = [seg_start]
531
- padded = np.pad(chunk, (0, window_samples - len(chunk)), mode="reflect")
532
- windows = torch.from_numpy(padded).float().unsqueeze(0)
533
- else:
534
- chunk = torch.from_numpy(chunk).float()
535
- # `unfold` is the sliding window: row i covers
536
- # [i * step, i * step + window), as far as a full window fits.
537
- windows = chunk.unfold(0, window_samples, step_samples)
538
- starts = [seg_start + i * step_samples for i in range(windows.shape[0])]
539
-
540
- # Cover tail if > TAIL_COVERAGE_RATIO of window remains
541
- remainder = seg_end - (starts[-1] + window_samples)
542
- if remainder > (window_samples * cls.TAIL_COVERAGE_RATIO):
543
- windows = torch.cat([windows, chunk[-window_samples:].unsqueeze(0)])
544
- starts.append(seg_end - window_samples)
545
-
546
- # One batched forward per segment: every window is exactly
547
- # `window_samples` long, so no length masking is needed.
548
- batch = speaker_model.encode_batch(windows).reshape(len(starts), -1).cpu().numpy()
549
-
550
- for c_start, embedding in zip(starts, batch):
551
- # Validate embedding
552
- if np.isfinite(embedding).all() and np.linalg.norm(embedding) > 1e-8:
553
- embeddings.append(embedding)
554
- window_segments.append(
555
- {
556
- "start": c_start / sample_rate,
557
- "end": min(c_start + window_samples, seg_end) / sample_rate,
558
- }
559
- )
560
-
561
- # Normalize all embeddings at once
562
- if embeddings:
563
- try:
564
- from sklearn.preprocessing import normalize
565
- except ImportError as e:
566
- raise ImportError(_ECAPA_EXTRAS) from e
567
-
568
- return normalize(np.array(embeddings)), window_segments
569
- return np.array([]), []
570
-
571
- @classmethod
572
- def _resample_vad(cls, vad_frames: list[bool], num_frames: int) -> np.ndarray:
573
- """Resample VAD frame decisions to match voting grid resolution.
574
-
575
- VAD operates at 256 samples / 16000 Hz = 16ms per frame.
576
- Voting operates at VOTING_RATE (default 10ms) per frame.
577
- This maps VAD decisions to the finer voting grid.
578
- """
579
- if not vad_frames:
580
- return np.zeros(num_frames, dtype=bool)
581
-
582
- vad_rate = 256 / 16000 # 16ms per VAD frame
583
- vad_arr = np.array(vad_frames)
584
-
585
- # Vectorized: compute VAD frame indices for each voting frame
586
- voting_times = np.arange(num_frames) * cls.VOTING_RATE
587
- vad_indices = np.clip((voting_times / vad_rate).astype(int), 0, len(vad_arr) - 1)
588
- return vad_arr[vad_indices]
589
-
590
- @classmethod
591
- def _postprocess_segments(
592
- cls,
593
- window_segments: list[dict],
594
- labels: np.ndarray,
595
- total_duration: float,
596
- vad_frames: list[bool],
597
- ) -> list[dict]:
598
- """Post-process using frame-level consensus voting with VAD-aware silence."""
599
- if not window_segments or len(labels) == 0:
600
- return []
601
-
602
- # Correct labels to be contiguous
603
- unique_labels, clean_labels = np.unique(labels, return_inverse=True)
604
- num_speakers = len(unique_labels)
605
-
606
- if num_speakers == 0:
607
- return []
608
-
609
- # Create voting grid
610
- num_frames = int(np.ceil(total_duration / cls.VOTING_RATE)) + 1
611
- votes = np.zeros((num_frames, num_speakers), dtype=np.float32)
612
-
613
- # Accumulate votes
614
- for win, label in zip(window_segments, clean_labels):
615
- start_frame = int(win["start"] / cls.VOTING_RATE)
616
- end_frame = int(win["end"] / cls.VOTING_RATE)
617
- end_frame = min(end_frame, num_frames)
618
- if start_frame < end_frame:
619
- votes[start_frame:end_frame, label] += 1.0
620
-
621
- # Determine winner per frame
622
- frame_speakers = np.argmax(votes, axis=1)
623
- max_votes = np.max(votes, axis=1)
624
-
625
- # Resample VAD to voting grid resolution for silence-aware voting
626
- vad_resampled = cls._resample_vad(vad_frames, num_frames)
627
-
628
- # Force silence (-1) where VAD says no speech or no window voted.
629
- frame_speakers = np.where((max_votes > 0) & vad_resampled, frame_speakers, -1)
630
-
631
- # Convert frames to segments: one per run of the same speaker.
632
- final_segments = [
633
- {
634
- "speaker": f"SPEAKER_{speaker}",
635
- "start": start * cls.VOTING_RATE,
636
- "end": end * cls.VOTING_RATE,
637
- }
638
- for speaker, start, end in _label_runs(frame_speakers)
639
- if speaker != -1
640
- ]
641
-
642
- return cls._merge_short_segments(final_segments)
643
-
644
- @classmethod
645
- def _merge_short_segments(cls, segments: list[dict]) -> list[dict]:
646
- """Merge short segments to reduce flicker."""
647
- if not segments:
648
- return []
649
-
650
- clean: list[dict] = []
651
- for seg in segments:
652
- dur = seg["end"] - seg["start"]
653
- if dur < cls.MIN_SEGMENT_DURATION:
654
- if (
655
- clean
656
- and clean[-1]["speaker"] == seg["speaker"]
657
- and seg["start"] - clean[-1]["end"] < cls.SHORT_SEGMENT_GAP
658
- ):
659
- clean[-1]["end"] = seg["end"]
660
- continue
661
-
662
- if (
663
- clean
664
- and clean[-1]["speaker"] == seg["speaker"]
665
- and seg["start"] - clean[-1]["end"] < cls.SAME_SPEAKER_GAP
666
- ):
667
- clean[-1]["end"] = seg["end"]
668
- else:
669
- clean.append(seg)
670
-
671
- return clean
672
-
673
- @classmethod
674
- def assign_speakers_to_words(
675
- cls,
676
- words: list[dict],
677
- speaker_segments: list[dict],
678
- ) -> list[dict]:
679
- """Assign speaker labels to words based on timestamp overlap.
680
-
681
- Args:
682
- words: List of word dicts with 'word', 'start', 'end' keys
683
- speaker_segments: List of speaker dicts with 'speaker', 'start', 'end' keys
684
-
685
- Returns:
686
- Words list with 'speaker' key added to each word
687
- """
688
- for word in words:
689
- word_mid = (word["start"] + word["end"]) / 2
690
-
691
- # Find the speaker segment that contains this word's midpoint
692
- best_speaker = None
693
- for seg in speaker_segments:
694
- if seg["start"] <= word_mid <= seg["end"]:
695
- best_speaker = seg["speaker"]
696
- break
697
-
698
- # If no exact match, find closest segment
699
- if best_speaker is None and speaker_segments:
700
- min_dist = float("inf")
701
- for seg in speaker_segments:
702
- seg_mid = (seg["start"] + seg["end"]) / 2
703
- dist = abs(word_mid - seg_mid)
704
- if dist < min_dist:
705
- min_dist = dist
706
- best_speaker = seg["speaker"]
707
-
708
- word["speaker"] = best_speaker
709
-
710
- return words
711
 
712
 
713
  class NemotronDiarizer:
@@ -733,46 +75,54 @@ class NemotronDiarizer:
733
  FRAME_S = 0.01
734
  SEGMENT_THRESHOLD = 0.5 # a speaker's segment = frames above this
735
  MIN_WORD_ACTIVITY = 0.1 # below this nobody is heard: the word inherits its neighbour
736
- _model = None
737
- _processor = None
738
 
739
  @classmethod
740
- def get_instance(cls):
741
- if cls._model is None:
742
- import importlib.util
743
-
 
744
  if importlib.util.find_spec("transformers.models.nemotron3_diarization") is None:
745
- raise ImportError(
746
  "Speaker diarization uses Nemotron-3-Diarization, which needs a transformers "
747
  "build with `nemotron3_diarization`: "
748
  "pip install git+https://github.com/huggingface/transformers"
749
  )
750
- from transformers import AutoModelForAudioFrameClassification, AutoProcessor
751
-
752
- model = AutoModelForAudioFrameClassification.from_pretrained(cls.MODEL_ID)
753
- cls._model = model.to(_get_device()).eval()
754
- cls._processor = AutoProcessor.from_pretrained(cls.MODEL_ID)
 
 
 
 
 
755
  return cls._model, cls._processor
756
 
757
  @classmethod
758
  @torch.inference_mode()
759
- def activity(cls, audio: np.ndarray, sample_rate: int = 16000) -> np.ndarray:
760
  """(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
761
  if sample_rate != 16000:
762
- import torchaudio
763
-
764
- audio = torchaudio.functional.resample(
765
- torch.as_tensor(audio, dtype=torch.float32), sample_rate, 16000
766
- ).numpy()
 
767
  model, processor = cls.get_instance()
768
  inputs = processor(np.asarray(audio, dtype=np.float32), sampling_rate=16000)
769
  inputs = inputs.to(model.device, dtype=model.dtype)
770
- return model(**inputs).logits[0].float().sigmoid().cpu().numpy()
 
771
 
772
  @classmethod
773
  def top_speakers(
774
  cls,
775
- activity: np.ndarray,
776
  num_speakers: int | None = None,
777
  max_speakers: int | None = None,
778
  ) -> list[int]:
@@ -793,10 +143,10 @@ class NemotronDiarizer:
793
  return active or [0]
794
 
795
  @classmethod
796
- def segments(cls, activity: np.ndarray, keep: list[int]) -> list[dict]:
797
  """Speaker turns `{"speaker", "start", "end"}` by start time; overlaps allowed."""
798
  names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
799
- out = []
800
  for c in keep:
801
  for on, s, e in _label_runs(activity[:, c] > cls.SEGMENT_THRESHOLD):
802
  if on:
@@ -809,7 +159,7 @@ class NemotronDiarizer:
809
  def speaker_columns(
810
  cls,
811
  spans: list[tuple[float | None, float | None]],
812
- activity: np.ndarray,
813
  keep: list[int],
814
  ) -> list[int]:
815
  """The kept activity column that said each `(start_s, end_s)` word.
@@ -832,7 +182,7 @@ class NemotronDiarizer:
832
  picked.append(int(cols[best]) if mean[best] >= cls.MIN_WORD_ACTIVITY else None)
833
  known = [p for p in picked if p is not None]
834
  last = known[0] if known else keep[0]
835
- out = []
836
  for p in picked:
837
  last = p if p is not None else last
838
  out.append(last)
@@ -840,11 +190,11 @@ class NemotronDiarizer:
840
 
841
  @classmethod
842
  def assign_speakers_to_words(
843
- cls, words: list[dict], activity: np.ndarray, keep: list[int]
844
- ) -> list[dict]:
845
  """Label each `{"start", "end"}` word `SPEAKER_i` by `speaker_columns`."""
846
  names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
847
  spans = [(w["start"], w["end"]) for w in words]
848
- for word, col in zip(words, cls.speaker_columns(spans, activity, keep)):
849
  word["speaker"] = names[col]
850
  return words
 
1
+ """Speaker diarization with NVIDIA Nemotron-3-Diarization."""
2
 
3
+ import importlib.util
4
+ from typing import TYPE_CHECKING, Any, TypedDict
 
 
 
 
5
 
6
  import numpy as np
7
+ import numpy.typing as npt
8
  import torch
9
+ import torchaudio
10
+ from transformers import AutoModelForAudioFrameClassification, AutoProcessor
11
+
12
+ if TYPE_CHECKING:
13
+ # In transformers main after 5.17; loaded through the Auto classes at runtime
14
+ # so an older build reaches get_instance's install hint instead of failing here.
15
+ from transformers import (
16
+ Nemotron3DiarizationForAudioFrameClassification,
17
+ Nemotron3DiarizationProcessor,
18
+ )
19
+
20
 
21
+ class SpeakerSegment(TypedDict):
22
+ """One speaker turn, in seconds."""
 
 
 
 
 
 
23
 
24
+ speaker: str
25
+ start: float
26
+ end: float
27
 
28
+
29
+ def get_device() -> torch.device:
30
  """Get best available device for inference."""
31
  if torch.cuda.is_available():
32
  return torch.device("cuda")
 
35
  return torch.device("cpu")
36
 
37
 
38
+ def _label_runs(labels: npt.NDArray[np.generic]) -> list[tuple[int, int, int]]:
39
  """Split a 1-D label array into maximal constant runs.
40
 
41
  Returns `(value, start, end)` triples with `end` exclusive, in order. This
42
+ is the frames-to-segments step for speaker activity: a boundary is wherever
43
+ the label changes, and the last run always closes at `len(labels)`, so a
44
+ trailing run needs no special case.
45
  """
46
  labels = np.asarray(labels)
47
  if labels.size == 0:
 
49
  change = np.flatnonzero(labels[1:] != labels[:-1]) + 1
50
  starts = np.concatenate(([0], change))
51
  ends = np.concatenate((change, [labels.size]))
52
+ return [(labels[s].item(), int(s), int(e)) for s, e in zip(starts, ends, strict=True)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  class NemotronDiarizer:
 
75
  FRAME_S = 0.01
76
  SEGMENT_THRESHOLD = 0.5 # a speaker's segment = frames above this
77
  MIN_WORD_ACTIVITY = 0.1 # below this nobody is heard: the word inherits its neighbour
78
+ _model: "Nemotron3DiarizationForAudioFrameClassification | None" = None
79
+ _processor: "Nemotron3DiarizationProcessor | None" = None
80
 
81
  @classmethod
82
+ def get_instance(
83
+ cls,
84
+ ) -> "tuple[Nemotron3DiarizationForAudioFrameClassification, Nemotron3DiarizationProcessor]":
85
+ """Load the diarization model and processor once, then return the cached pair."""
86
+ if cls._model is None or cls._processor is None:
87
  if importlib.util.find_spec("transformers.models.nemotron3_diarization") is None:
88
+ msg = (
89
  "Speaker diarization uses Nemotron-3-Diarization, which needs a transformers "
90
  "build with `nemotron3_diarization`: "
91
  "pip install git+https://github.com/huggingface/transformers"
92
  )
93
+ raise ImportError(msg)
94
+ model: Nemotron3DiarizationForAudioFrameClassification = (
95
+ AutoModelForAudioFrameClassification.from_pretrained(cls.MODEL_ID)
96
+ )
97
+ # PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
98
+ model.to(get_device()) # pyright: ignore[reportArgumentType]
99
+ model.eval()
100
+ cls._model = model
101
+ processor: Nemotron3DiarizationProcessor = AutoProcessor.from_pretrained(cls.MODEL_ID)
102
+ cls._processor = processor
103
  return cls._model, cls._processor
104
 
105
  @classmethod
106
  @torch.inference_mode()
107
+ def activity(cls, audio: npt.ArrayLike, sample_rate: int = 16000) -> npt.NDArray[np.float32]:
108
  """(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
109
  if sample_rate != 16000:
110
+ audio = np.asarray(
111
+ torchaudio.functional.resample(
112
+ torch.as_tensor(audio, dtype=torch.float32), sample_rate, 16000
113
+ ),
114
+ dtype=np.float32,
115
+ )
116
  model, processor = cls.get_instance()
117
  inputs = processor(np.asarray(audio, dtype=np.float32), sampling_rate=16000)
118
  inputs = inputs.to(model.device, dtype=model.dtype)
119
+ probs: npt.NDArray[np.float32] = model(**inputs).logits[0].float().sigmoid().cpu().numpy()
120
+ return probs
121
 
122
  @classmethod
123
  def top_speakers(
124
  cls,
125
+ activity: npt.NDArray[np.float32],
126
  num_speakers: int | None = None,
127
  max_speakers: int | None = None,
128
  ) -> list[int]:
 
143
  return active or [0]
144
 
145
  @classmethod
146
+ def segments(cls, activity: npt.NDArray[np.float32], keep: list[int]) -> list[SpeakerSegment]:
147
  """Speaker turns `{"speaker", "start", "end"}` by start time; overlaps allowed."""
148
  names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
149
+ out: list[SpeakerSegment] = []
150
  for c in keep:
151
  for on, s, e in _label_runs(activity[:, c] > cls.SEGMENT_THRESHOLD):
152
  if on:
 
159
  def speaker_columns(
160
  cls,
161
  spans: list[tuple[float | None, float | None]],
162
+ activity: npt.NDArray[np.float32],
163
  keep: list[int],
164
  ) -> list[int]:
165
  """The kept activity column that said each `(start_s, end_s)` word.
 
182
  picked.append(int(cols[best]) if mean[best] >= cls.MIN_WORD_ACTIVITY else None)
183
  known = [p for p in picked if p is not None]
184
  last = known[0] if known else keep[0]
185
+ out: list[int] = []
186
  for p in picked:
187
  last = p if p is not None else last
188
  out.append(last)
 
190
 
191
  @classmethod
192
  def assign_speakers_to_words(
193
+ cls, words: list[dict[str, Any]], activity: npt.NDArray[np.float32], keep: list[int]
194
+ ) -> list[dict[str, Any]]:
195
  """Label each `{"start", "end"}` word `SPEAKER_i` by `speaker_columns`."""
196
  names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
197
  spans = [(w["start"], w["end"]) for w in words]
198
+ for word, col in zip(words, cls.speaker_columns(spans, activity, keep), strict=True):
199
  word["speaker"] = names[col]
200
  return words
handler.py CHANGED
@@ -1,26 +1,25 @@
1
  """Custom inference handler for HuggingFace Inference Endpoints."""
2
 
3
- from typing import Any
 
4
 
5
- import torch
6
 
7
- try:
8
- # For remote execution, imports are relative
9
  from .asr_modeling import ASRModel
10
  from .asr_pipeline import ASRPipeline
11
- except ImportError:
12
- # For local execution, imports are not relative
13
- from asr_modeling import ASRModel # type: ignore[no-redef]
14
- from asr_pipeline import ASRPipeline # type: ignore[no-redef]
15
-
16
-
17
- def _best_device() -> torch.device:
18
- """Best available inference device (cuda > mps > cpu)."""
19
- if torch.cuda.is_available():
20
- return torch.device("cuda")
21
- if torch.backends.mps.is_available():
22
- return torch.device("mps")
23
- return torch.device("cpu")
24
 
25
 
26
  class EndpointHandler:
@@ -36,11 +35,12 @@ class EndpointHandler:
36
  Args:
37
  path: Path to model directory or HuggingFace model ID
38
  """
39
- import os
40
-
41
- import nltk
42
-
43
- nltk.download("punkt_tab", quiet=True)
 
44
 
45
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
46
 
@@ -56,7 +56,8 @@ class EndpointHandler:
56
  # flash_attn is missing.
57
  self.model = ASRModel.from_pretrained(path)
58
  self.device = _best_device()
59
- self.model.to(self.device)
 
60
  self.model.eval()
61
 
62
  self.pipe = ASRPipeline(
@@ -77,9 +78,11 @@ class EndpointHandler:
77
  """
78
  inputs = data.get("inputs")
79
  if inputs is None:
80
- raise ValueError("Missing 'inputs' in request data")
 
81
 
82
  # Pass through any parameters from request, let model config provide defaults
83
  params = data.get("parameters", {})
84
 
85
- return self.pipe(inputs, **params)
 
 
1
  """Custom inference handler for HuggingFace Inference Endpoints."""
2
 
3
+ import os
4
+ from typing import TYPE_CHECKING, Any
5
 
6
+ import nltk
7
 
8
+ if TYPE_CHECKING:
 
9
  from .asr_modeling import ASRModel
10
  from .asr_pipeline import ASRPipeline
11
+ from .diarization import get_device as _best_device
12
+ else:
13
+ try:
14
+ # For remote execution, imports are relative
15
+ from .asr_modeling import ASRModel
16
+ from .asr_pipeline import ASRPipeline
17
+ from .diarization import get_device as _best_device
18
+ except ImportError:
19
+ # For local execution, imports are not relative
20
+ from asr_modeling import ASRModel
21
+ from asr_pipeline import ASRPipeline
22
+ from diarization import get_device as _best_device
 
23
 
24
 
25
  class EndpointHandler:
 
35
  Args:
36
  path: Path to model directory or HuggingFace model ID
37
  """
38
+ # Only fetch when absent: nltk.download re-checks the remote index
39
+ # every call, i.e. a network round-trip on every endpoint start.
40
+ try:
41
+ nltk.data.find("tokenizers/punkt_tab/english/")
42
+ except LookupError:
43
+ nltk.download("punkt_tab", quiet=True)
44
 
45
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
46
 
 
56
  # flash_attn is missing.
57
  self.model = ASRModel.from_pretrained(path)
58
  self.device = _best_device()
59
+ # PreTrainedModel.to is functools.wraps'd, which pyright cannot bind as a method.
60
+ self.model.to(self.device) # pyright: ignore[reportArgumentType]
61
  self.model.eval()
62
 
63
  self.pipe = ASRPipeline(
 
78
  """
79
  inputs = data.get("inputs")
80
  if inputs is None:
81
+ msg = "Missing 'inputs' in request data"
82
+ raise ValueError(msg)
83
 
84
  # Pass through any parameters from request, let model config provide defaults
85
  params = data.get("parameters", {})
86
 
87
+ result: dict[str, Any] | list[dict[str, Any]] = self.pipe(inputs, **params)
88
+ return result
projectors.py CHANGED
@@ -4,6 +4,8 @@ This module contains all projector architectures:
4
  - MLPAudioProjector: Simple 2-layer MLP with frame stacking downsampling
5
  """
6
 
 
 
7
  import torch
8
  import torch.nn as nn
9
  from transformers.models.llama.modeling_llama import LlamaRMSNorm
@@ -90,7 +92,7 @@ class MLPAudioProjector(nn.Module):
90
  path from encoder to decoder and a frozen encoder that cannot adapt to it.
91
  """
92
 
93
- def __init__(self, config):
94
  """Initialize MLP projector.
95
 
96
  Args:
@@ -98,20 +100,24 @@ class MLPAudioProjector(nn.Module):
98
  """
99
  super().__init__()
100
 
101
- encoder_dim = getattr(config, "encoder_dim", 768)
102
- llm_dim = getattr(config, "llm_dim", 2048)
103
- self.k = getattr(config, "projector_pool_stride", 4)
104
 
105
  # Frame stacking: concat k adjacent frames then project
106
  in_dim = encoder_dim * self.k
107
  # Hidden dim defaults to llm_dim, can be overridden via config
108
- hidden_dim = getattr(config, "projector_hidden_dim", None) or llm_dim
109
  self.input_norm = LlamaRMSNorm(in_dim, eps=1e-6)
110
  self.linear_1 = nn.Linear(in_dim, hidden_dim)
111
  self.act = nn.GELU()
112
  self.linear_2 = nn.Linear(hidden_dim, llm_dim)
113
 
114
- def get_output_length(self, input_length: int) -> int:
 
 
 
 
115
  """Calculate output sequence length given input length (matches GLM-ASR)."""
116
  return _frame_stack_length(input_length, self.k)
117
 
@@ -136,7 +142,11 @@ class MLPAudioProjector(nn.Module):
136
  # =============================================================================
137
 
138
 
139
- def _frame_stack_length(length, k: int):
 
 
 
 
140
  """Frames left after stacking k adjacent frames (GLM-ASR's rule).
141
 
142
  Trailing frames that don't fill a complete k-frame window are dropped.
 
4
  - MLPAudioProjector: Simple 2-layer MLP with frame stacking downsampling
5
  """
6
 
7
+ from typing import overload
8
+
9
  import torch
10
  import torch.nn as nn
11
  from transformers.models.llama.modeling_llama import LlamaRMSNorm
 
92
  path from encoder to decoder and a frozen encoder that cannot adapt to it.
93
  """
94
 
95
+ def __init__(self, config: object) -> None:
96
  """Initialize MLP projector.
97
 
98
  Args:
 
100
  """
101
  super().__init__()
102
 
103
+ encoder_dim: int = getattr(config, "encoder_dim", 768)
104
+ llm_dim: int = getattr(config, "llm_dim", 2048)
105
+ self.k: int = getattr(config, "projector_pool_stride", 4)
106
 
107
  # Frame stacking: concat k adjacent frames then project
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: ...
118
+ @overload
119
+ def get_output_length(self, input_length: torch.Tensor) -> torch.Tensor: ...
120
+ def get_output_length(self, input_length: int | torch.Tensor) -> int | torch.Tensor:
121
  """Calculate output sequence length given input length (matches GLM-ASR)."""
122
  return _frame_stack_length(input_length, self.k)
123
 
 
142
  # =============================================================================
143
 
144
 
145
+ @overload
146
+ def _frame_stack_length(length: int, k: int) -> int: ...
147
+ @overload
148
+ def _frame_stack_length(length: torch.Tensor, k: int) -> torch.Tensor: ...
149
+ def _frame_stack_length(length: int | torch.Tensor, k: int) -> int | torch.Tensor:
150
  """Frames left after stacking k adjacent frames (GLM-ASR's rule).
151
 
152
  Trailing frames that don't fill a complete k-frame window are dropped.