Automatic Speech Recognition
Transformers
Safetensors
English
asr_model
feature-extraction
asr
speech-recognition
speech-to-text
audio
speech-llm
word-timestamps
speaker-diarization
qwen
granite-speech
custom_code
Instructions to use mazesmazes/tiny-audio with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use mazesmazes/tiny-audio with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("automatic-speech-recognition", model="mazesmazes/tiny-audio", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("mazesmazes/tiny-audio", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Update custom model files, README, and requirements
Browse files- README.md +128 -87
- alignment.py +76 -261
- asr_attention.py +171 -0
- asr_config.py +51 -25
- asr_layers.py +201 -0
- asr_modeling.py +433 -569
- asr_pipeline.py +188 -68
- asr_processing.py +108 -47
- asr_types.py +108 -0
- diarization.py +62 -712
- handler.py +28 -25
- 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
|
|
|
|
| 31 |
|
| 32 |
-
A frozen Granite Speech encoder is connected to a frozen Qwen3.5-2B decoder through a trained MLP
|
|
|
|
| 33 |
|
| 34 |
## Quick Start
|
| 35 |
|
| 36 |
```python
|
| 37 |
from transformers import pipeline
|
| 38 |
|
| 39 |
-
pipe = pipeline(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
| 145 |
-
|
|
| 146 |
-
| LibriSpeech test-
|
| 147 |
-
|
|
| 148 |
-
|
|
| 149 |
-
|
|
| 150 |
-
|
|
| 151 |
-
|
|
| 152 |
-
|
|
| 153 |
-
|
|
| 154 |
-
|
|
| 155 |
-
|
|
| 156 |
-
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 164 |
```
|
| 165 |
|
| 166 |
-
| Component
|
| 167 |
-
|-----------|-------
|
| 168 |
-
| Audio Encoder
|
| 169 |
-
| Projector
|
| 170 |
-
| Language Model | [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B)
|
| 171 |
-
| LoRA adapters
|
| 172 |
|
| 173 |
### How It Works
|
| 174 |
|
| 175 |
1. **Audio encoder**: Granite Speech turns 16kHz audio into frame-level embeddings.
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
At inference, 0.25s of silence is prepended to each clip (`inference_lead_in_seconds`). This keeps
|
|
|
|
| 180 |
|
| 181 |
## Model Specifications
|
| 182 |
|
| 183 |
-
| Specification
|
| 184 |
-
|---------------|-------|
|
| 185 |
-
| Input
|
| 186 |
-
| Output
|
| 187 |
-
| Max Audio Length | ~30 seconds per call
|
| 188 |
-
| Vocabulary
|
| 189 |
-
| Languages
|
| 190 |
-
| Generation
|
| 191 |
|
| 192 |
## Training Details
|
| 193 |
|
| 194 |
-
|
|
| 195 |
-
|---|---|
|
| 196 |
-
| **Data**
|
| 197 |
-
| **Hardware**
|
| 198 |
-
| **Steps**
|
| 199 |
-
| **Batch Size**
|
| 200 |
-
| **Optimizer**
|
| 201 |
-
| **Learning Rate** | 1e-3 (projector), 1e-4 (LoRA)
|
| 202 |
-
| **Precision**
|
| 203 |
|
| 204 |
-
The training recipe is
|
|
|
|
| 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
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 220 |
-
|------|-------------|
|
| 221 |
-
| `config.json`
|
| 222 |
-
| `model.safetensors`
|
| 223 |
-
| `adapter_config.json` / `adapter_model.safetensors`
|
| 224 |
-
| `preprocessor_config.json`
|
| 225 |
-
| `tokenizer.json` / `tokenizer_config.json` / `chat_template.jinja` | Tokenizer
|
| 226 |
-
| `asr_*.py`, `projectors.py`, `alignment.py`, `diarization.py`
|
| 227 |
|
| 228 |
-
Only the projector and LoRA weights are stored here. The encoder (Granite Speech) and decoder
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 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
|
|
|
|
| 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
|
| 262 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import numpy as np
|
|
|
|
| 4 |
import torch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def _get_device() -> str:
|
| 8 |
-
"""Get best available device for
|
| 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 |
-
|
| 268 |
-
|
| 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 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
| 278 |
return cls._model, cls._processor
|
| 279 |
|
| 280 |
@staticmethod
|
| 281 |
-
def _to_16k(audio, sample_rate: int) -> np.
|
|
|
|
| 282 |
if isinstance(audio, torch.Tensor):
|
| 283 |
-
audio = audio.cpu()
|
| 284 |
-
|
| 285 |
if sample_rate != 16000:
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
return audio
|
| 292 |
|
| 293 |
@classmethod
|
| 294 |
@torch.inference_mode()
|
| 295 |
def align_chunks(
|
| 296 |
cls,
|
| 297 |
-
chunks:
|
| 298 |
sample_rate: int = 16000,
|
| 299 |
language: str = "English",
|
| 300 |
-
) -> list[list[
|
| 301 |
"""Align each `(audio, text)` pair (each <= 5 min); one word list per pair."""
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
results: list[list[
|
| 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 |
-
|
| 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
|
| 321 |
-
|
| 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
|
| 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(
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 341 |
# Override dtype to match model_dtype
|
| 342 |
-
|
| 343 |
else:
|
| 344 |
-
|
|
|
|
| 345 |
|
|
|
|
| 346 |
if "text_config" not in kwargs:
|
| 347 |
-
|
| 348 |
text_model_id, trust_remote_code=True
|
| 349 |
)
|
| 350 |
# Override dtype to match model_dtype
|
| 351 |
-
|
| 352 |
else:
|
| 353 |
-
|
| 354 |
|
| 355 |
-
if isinstance(
|
| 356 |
# Reconstruct config from dict using the model_type stored in the dict
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 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
|
| 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 |
-
|
| 13 |
-
from
|
|
|
|
| 14 |
from transformers import (
|
| 15 |
-
|
| 16 |
AutoModel,
|
| 17 |
AutoModelForCausalLM,
|
|
|
|
| 18 |
AutoTokenizer,
|
|
|
|
| 19 |
PreTrainedModel,
|
|
|
|
|
|
|
| 20 |
TextIteratorStreamer,
|
|
|
|
| 21 |
)
|
| 22 |
-
from transformers
|
|
|
|
| 23 |
from transformers.modeling_outputs import CausalLMOutputWithPast
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
from .asr_config import ASRConfig, compute_encoder_output_length
|
| 27 |
-
from .
|
| 28 |
-
|
| 29 |
-
from
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
"""Load model from pretrained, handling device placement correctly."""
|
| 576 |
-
|
| 577 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 656 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 790 |
-
|
| 791 |
-
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
|
| 837 |
# Granite's block-attention implementation has no FA2 kernel and
|
| 838 |
# raises ValueError on attn_implementation="flash_attention_2".
|
| 839 |
-
#
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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(
|
| 861 |
-
holder =
|
|
|
|
|
|
|
| 862 |
if not hasattr(holder, "audio_tower"):
|
| 863 |
-
|
| 864 |
-
f"{type(
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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) ->
|
| 932 |
"""Load and freeze the language model."""
|
| 933 |
-
|
| 934 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
| 1046 |
|
| 1047 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1073 |
"Gemma text-only conversion left parameters on the meta device "
|
| 1074 |
f"(would silently produce garbage): {stranded[:5]}"
|
| 1075 |
)
|
| 1076 |
-
|
|
|
|
| 1077 |
|
| 1078 |
-
def _create_projector(self, config: ASRConfig, dtype: torch.dtype) ->
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
|
| 1141 |
-
def _init_tokenizer(self, config: ASRConfig):
|
| 1142 |
"""Initialize tokenizer with audio token."""
|
| 1143 |
-
self.tokenizer = AutoTokenizer.from_pretrained(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 1247 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 1378 |
-
|
| 1379 |
-
|
| 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,
|
| 1409 |
"""Replace the decoder's LM head."""
|
| 1410 |
-
self.language_model.set_output_embeddings(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
| 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(
|
| 1477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 `
|
| 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 |
-
|
| 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
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1940 |
-
|
| 1941 |
-
collide with `audio_token_id`, so the masked_scatter below is unaffected.
|
| 1942 |
"""
|
| 1943 |
-
|
| 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.
|
| 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. `
|
| 2016 |
the measurements.
|
| 2017 |
"""
|
| 2018 |
if attention_mask is None or attention_mask.device.type != "mps":
|
| 2019 |
return
|
| 2020 |
-
if
|
|
|
|
|
|
|
|
|
|
| 2021 |
return
|
| 2022 |
if not bool((attention_mask == 0).any()):
|
| 2023 |
return
|
| 2024 |
-
|
| 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 |
-
|
|
|
|
| 2051 |
if audio_attention_mask is None:
|
| 2052 |
-
|
|
|
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 2080 |
gen_cfg.return_dict_in_generate = True
|
| 2081 |
|
| 2082 |
-
|
| 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 |
-
|
| 2114 |
-
|
|
|
|
| 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 |
-
|
| 2149 |
-
|
|
|
|
|
|
|
| 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(
|
| 2196 |
-
|
| 2197 |
-
|
|
|
|
| 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 |
-
|
| 2216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2217 |
|
| 2218 |
# Save model (temporarily remove non-serializable attributes)
|
| 2219 |
tokenizer = self.tokenizer
|
| 2220 |
del self.tokenizer
|
| 2221 |
|
| 2222 |
try:
|
| 2223 |
-
super().save_pretrained(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
from
|
|
|
|
|
|
|
|
|
|
| 14 |
from .asr_modeling import ASRModel
|
| 15 |
from .asr_processing import prepend_lead_in
|
| 16 |
-
from .diarization import NemotronDiarizer
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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.
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
-
def _sanitize_parameters(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"""Intercept our custom parameters before parent class validates them."""
|
| 100 |
-
# Remove our custom parameters so parent doesn't see them
|
| 101 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
|
|
|
|
|
|
| 111 |
def __call__(
|
| 112 |
self,
|
| 113 |
-
inputs,
|
| 114 |
-
*
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
| 206 |
return cast(
|
| 207 |
-
"dict
|
|
|
|
| 208 |
)
|
| 209 |
|
| 210 |
def _transcribe_timed(
|
| 211 |
self,
|
| 212 |
-
inputs,
|
| 213 |
*,
|
| 214 |
return_speakers: bool,
|
| 215 |
-
diarization_params:
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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) ->
|
| 265 |
"""Extract audio array from various input formats using HF utilities."""
|
| 266 |
if isinstance(inputs, dict):
|
| 267 |
-
|
|
|
|
| 268 |
return {
|
| 269 |
-
"array":
|
| 270 |
-
"sampling_rate":
|
| 271 |
}
|
| 272 |
-
if "raw" in
|
| 273 |
return {
|
| 274 |
-
"array":
|
| 275 |
-
"sampling_rate":
|
| 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,
|
| 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":
|
| 304 |
-
"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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 436 |
if eos_ids is not None:
|
| 437 |
eos_set = set(eos_ids) if isinstance(eos_ids, list) else {eos_ids}
|
| 438 |
-
|
|
|
|
| 439 |
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
from .asr_config import
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 42 |
|
|
|
|
| 43 |
pad = round(sampling_rate * seconds)
|
| 44 |
if pad <= 0:
|
| 45 |
-
return
|
| 46 |
-
arr = np.asarray(
|
| 47 |
-
|
|
|
|
| 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 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
`
|
| 128 |
-
`audio_token_id`, so the model's masked_scatter is unaffected.
|
| 129 |
"""
|
| 130 |
-
|
| 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
|
| 144 |
self,
|
| 145 |
-
audio:
|
| 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 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
| 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 |
-
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
MIT License (https://opensource.org/licenses/MIT)
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import warnings
|
| 9 |
|
| 10 |
import numpy as np
|
|
|
|
| 11 |
import torch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 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 |
-
|
|
|
|
| 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.
|
| 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
|
| 37 |
-
|
| 38 |
-
|
| 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(
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
|
|
|
| 744 |
if importlib.util.find_spec("transformers.models.nemotron3_diarization") is None:
|
| 745 |
-
|
| 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 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
return cls._model, cls._processor
|
| 756 |
|
| 757 |
@classmethod
|
| 758 |
@torch.inference_mode()
|
| 759 |
-
def activity(cls, audio:
|
| 760 |
"""(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
|
| 761 |
if sample_rate != 16000:
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 771 |
|
| 772 |
@classmethod
|
| 773 |
def top_speakers(
|
| 774 |
cls,
|
| 775 |
-
activity: np.
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 4 |
|
| 5 |
-
import
|
| 6 |
|
| 7 |
-
|
| 8 |
-
# For remote execution, imports are relative
|
| 9 |
from .asr_modeling import ASRModel
|
| 10 |
from .asr_pipeline import ASRPipeline
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 81 |
|
| 82 |
# Pass through any parameters from request, let model config provide defaults
|
| 83 |
params = data.get("parameters", {})
|
| 84 |
|
| 85 |
-
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|