mazesmazes commited on
Commit
06c9685
·
verified ·
1 Parent(s): 3edc078

Update custom model files, README, and requirements

Browse files
Files changed (8) hide show
  1. alignment.py +20 -20
  2. asr_attention.py +10 -5
  3. asr_modeling.py +60 -92
  4. asr_pipeline.py +39 -46
  5. asr_processing.py +52 -36
  6. diarization.py +17 -23
  7. handler.py +36 -12
  8. requirements.txt +10 -2
alignment.py CHANGED
@@ -38,13 +38,26 @@ class AlignedWord(TypedDict):
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():
46
- return "mps"
47
- return "cpu"
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  class QwenForcedAligner:
@@ -73,9 +86,9 @@ class QwenForcedAligner:
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
  )
@@ -86,19 +99,6 @@ class QwenForcedAligner:
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(
@@ -116,7 +116,7 @@ class QwenForcedAligner:
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 "
 
38
  end: float
39
 
40
 
41
+ def get_device() -> torch.device:
42
  """Get best available device for inference."""
43
  if torch.cuda.is_available():
44
+ return torch.device("cuda")
45
  if torch.backends.mps.is_available():
46
+ return torch.device("mps")
47
+ return torch.device("cpu")
48
+
49
+
50
+ def to_16k(audio: npt.ArrayLike | torch.Tensor, sample_rate: int) -> npt.NDArray[np.float32]:
51
+ """Flatten `audio` to a float32 mono array resampled to 16 kHz."""
52
+ if isinstance(audio, torch.Tensor):
53
+ audio = audio.cpu()
54
+ out: npt.NDArray[np.float32] = np.asarray(audio, dtype=np.float32).reshape(-1)
55
+ if sample_rate != 16000:
56
+ out = np.asarray(
57
+ torchaudio.functional.resample(torch.as_tensor(out), sample_rate, 16000),
58
+ dtype=np.float32,
59
+ )
60
+ return out
61
 
62
 
63
  class QwenForcedAligner:
 
86
  if cls._model is None or cls._processor is None:
87
  if _QWEN3_ASR_IMPORT_ERROR is not None:
88
  raise _QWEN3_ASR_IMPORT_ERROR
89
+ device = get_device()
90
  # Batches are padded, and Metal sdpa returns NaN for fully masked rows.
91
+ attn = "eager" if device.type == "mps" else "sdpa"
92
  model = Qwen3ASRForTokenClassification.from_pretrained(
93
  cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
94
  )
 
99
  cls._processor = Qwen3ASRProcessor.from_pretrained(cls.MODEL_ID)
100
  return cls._model, cls._processor
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  @classmethod
103
  @torch.inference_mode()
104
  def align_chunks(
 
116
  kept = [w for w in text.split() if _clean_tokens([w])]
117
  if not kept:
118
  continue
119
+ audio = 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 "
asr_attention.py CHANGED
@@ -9,6 +9,7 @@ requested implementation otherwise.
9
  import functools
10
  import importlib
11
  import logging
 
12
 
13
  import torch
14
  from transformers import AutoConfig
@@ -41,6 +42,13 @@ def _max_attention_head_dim(text_config: object) -> int | None:
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.
@@ -58,8 +66,7 @@ def _has_sliding_window_attention(model_id: str) -> bool:
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 "
@@ -156,9 +163,7 @@ def resolve_decoder_attn_implementation(requested: str | None, model_id: str) ->
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; "
 
9
  import functools
10
  import importlib
11
  import logging
12
+ from typing import Any
13
 
14
  import torch
15
  from transformers import AutoConfig
 
42
  return max(usable) if usable else None
43
 
44
 
45
+ @functools.lru_cache(maxsize=16)
46
+ def _text_config(model_id: str) -> Any:
47
+ """`model_id`'s decoder config: the text sub-config of a composite config, else itself."""
48
+ probe = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
49
+ return probe.get_text_config() if hasattr(probe, "get_text_config") else probe
50
+
51
+
52
  @functools.lru_cache(maxsize=16)
53
  def _has_sliding_window_attention(model_id: str) -> bool:
54
  """Whether `model_id`'s text stack has any sliding-window attention layer.
 
66
  keeps the conservative eager path on MPS.
67
  """
68
  try:
69
+ text_config = _text_config(model_id)
 
70
  except Exception:
71
  logger.warning(
72
  "Could not read the config for %s to check for sliding-window "
 
163
  """
164
  attn_implementation = resolve_attn_implementation(requested, model_id)
165
  if attn_implementation == "flash_attention_2":
166
+ head_dim = _max_attention_head_dim(_text_config(model_id))
 
 
167
  if head_dim is not None and head_dim > FLASH_ATTENTION_MAX_HEAD_DIM:
168
  logger.warning(
169
  "%s has max head_dim=%d, above FlashAttention's limit of %d; "
asr_modeling.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import functools
2
  import importlib.util
3
  import inspect
@@ -22,7 +23,6 @@ from transformers import (
22
  AutoModelForCausalLM,
23
  AutoModelForSeq2SeqLM,
24
  AutoTokenizer,
25
- BatchEncoding,
26
  PreTrainedModel,
27
  PreTrainedTokenizerBase,
28
  SequenceFeatureExtractor,
@@ -47,7 +47,12 @@ if TYPE_CHECKING:
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,
@@ -67,14 +72,24 @@ else:
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
 
@@ -86,10 +101,8 @@ else:
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
 
@@ -197,6 +210,16 @@ def _assert_audio_token_counts(
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
 
@@ -321,7 +344,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
321
  supports_gradient_checkpointing = True
322
  _is_loading_from_pretrained: bool = False
323
 
324
- TRANSCRIBE_PROMPT = "Transcribe the speech to text"
325
 
326
  @classmethod
327
  def from_pretrained(
@@ -481,9 +504,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
481
 
482
  # Setup LoRA if enabled (Stage 2 fine-tuning)
483
  # Skip if loading from pretrained - from_pretrained will handle adapter loading
484
- if getattr(config, "use_lora", False) and not getattr(
485
- self.__class__, "_is_loading_from_pretrained", False
486
- ):
487
  self._setup_lora(config)
488
 
489
  # Freeze projector if specified (for Stage 2 LoRA-only training)
@@ -757,7 +778,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
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
 
@@ -766,17 +787,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
766
  shell.generation_config = loaded.generation_config
767
  shell.tie_weights()
768
 
769
- # Gemma4ForCausalLM does not drop `per_layer_inputs` between decode
770
- # steps, while the multimodal wrapper does. Without the drop, step 2
771
- # of generation passes PLE alongside `input_ids` and forward raises
772
- # "You cannot specify per_layer_inputs if input_ids is provided". The
773
- # drop is semantically right, not a workaround: PLE describes the
774
- # prompt, and from step 2 on generation feeds one freshly sampled
775
- # token whose PLE Gemma derives itself from that token's id.
776
- #
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)
@@ -1171,12 +1182,23 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1171
  to the projector); includes the encoder only when it's trainable.
1172
  """
1173
  targets: list[nn.Module] = [self.language_model]
1174
- # Autograd state again, not the flag: a partially unfrozen encoder has
1175
- # an activation tape worth checkpointing even though the flag is True.
1176
- if any(p.requires_grad for p in self.audio_tower.parameters()):
1177
  targets.append(self.audio_tower)
1178
  return targets
1179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
  def get_input_embeddings(self) -> nn.Module:
1181
  """Return the decoder's token embedding module."""
1182
  return self.language_model.get_input_embeddings()
@@ -1381,22 +1403,12 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1381
  ):
1382
  tower_kwargs["attention_mask"] = audio_attention_mask.to(audio_features.device)
1383
 
1384
- # Gate on autograd state, NOT on `freeze_audio_encoder` alone. The
1385
- # documented partial-unfreeze recipe is `freeze_audio_encoder: true`
1386
- # PLUS `encoder_trainable_top_layers: N` (see ASRConfig), which leaves
1387
- # the flag True while `_load_audio_encoder` switches the top N blocks
1388
- # back on. Reading the flag here then wrapped those blocks in no_grad
1389
- # and zeroed their gradients -- exactly the failure the comment above
1390
- # warns about -- so the run paid ~1.6 GiB of AdamW state for 198M
1391
- # params that could never move, with nothing in the logs saying so.
1392
- encoder_frozen = not any(p.requires_grad for p in self.audio_tower.parameters())
1393
- if encoder_frozen:
1394
- with torch.no_grad():
1395
- encoder_out = self.audio_tower(**tower_kwargs)
1396
- hidden_states = encoder_out.last_hidden_state
1397
- else:
1398
  encoder_out = self.audio_tower(**tower_kwargs)
1399
- hidden_states = encoder_out.last_hidden_state
1400
 
1401
  # Conformer encoders (Granite) return their own validity mask, halved
1402
  # alongside each subsampling block. That is the encoder's arithmetic
@@ -1613,11 +1625,7 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1613
  input_features, audio_token_counts, audio_attention_mask
1614
  )
1615
 
1616
- audio_token_mask = is_audio_token.unsqueeze(-1)
1617
- inputs_embeds = inputs_embeds.masked_scatter(
1618
- audio_token_mask.to(inputs_embeds.device),
1619
- audio_embeds.to(inputs_embeds.device, dtype=inputs_embeds.dtype),
1620
- )
1621
 
1622
  # Forward label_smoothing to the LM's loss_function via **kwargs.
1623
  # transformers.loss.loss_utils.ForCausalLMLoss → fixed_cross_entropy
@@ -1705,43 +1713,11 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1705
 
1706
  return model_inputs
1707
 
1708
- def _get_num_audio_tokens(
1709
- self,
1710
- audio_attention_mask: torch.Tensor,
1711
- ) -> int:
1712
- """Audio token count for the LONGEST sample in the batch.
1713
-
1714
- mel_frames -> encoder_frames (via conv formulas) -> projector tokens.
1715
-
1716
- Do not build a shared prompt from this. Every row shorter than the
1717
- batch max needs fewer placeholders than this returns; see
1718
- `_prepare_audio_inputs`, which renders one prompt per sample.
1719
- """
1720
- encoder_lengths = self._compute_encoder_output_lengths(audio_attention_mask)
1721
- encoder_output_len = int(encoder_lengths.max().item())
1722
- return int(self.projector.get_output_length(encoder_output_len))
1723
-
1724
  def _render_audio_prompt(self, num_audio_tokens: int) -> torch.Tensor:
1725
  """Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
1726
- messages: list[dict[str, str]] = []
1727
- # Audio tokens only (instruction-free) unless a transcribe prompt is set
1728
- user_content = self.audio_token * num_audio_tokens
1729
- if self.TRANSCRIBE_PROMPT:
1730
- user_content += " " + self.TRANSCRIBE_PROMPT
1731
- messages.append({"role": "user", "content": user_content})
1732
-
1733
- chat_result = self.tokenizer.apply_chat_template(
1734
- messages,
1735
- tokenize=True,
1736
- add_generation_prompt=True,
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(
1747
  self,
@@ -1794,11 +1770,8 @@ class ASRModel(PreTrainedModel, GenerationMixin):
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),
1801
- audio_embeds.to(inputs_embeds.device, dtype=inputs_embeds.dtype),
1802
  )
1803
  return input_ids, attention_mask, inputs_embeds
1804
 
@@ -2115,14 +2088,9 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2115
 
2116
  # Copy source files for auto-loading
2117
  src_dir = Path(__file__).parent
2118
- for asr_file in src_dir.glob("asr_*.py"):
2119
- shutil.copy(asr_file, save_dir / asr_file.name)
2120
- # Copy projectors module
2121
- shutil.copy(src_dir / "projectors.py", save_dir / "projectors.py")
2122
- # Copy alignment module
2123
- shutil.copy(src_dir / "alignment.py", save_dir / "alignment.py")
2124
- # Copy diarization module
2125
- shutil.copy(src_dir / "diarization.py", save_dir / "diarization.py")
2126
 
2127
  def create_or_update_model_card(self, output_dir: str | Path) -> None:
2128
  """Re-apply the PEFT card metadata to `output_dir/README.md`.
 
1
+ import contextlib
2
  import functools
3
  import importlib.util
4
  import inspect
 
23
  AutoModelForCausalLM,
24
  AutoModelForSeq2SeqLM,
25
  AutoTokenizer,
 
26
  PreTrainedModel,
27
  PreTrainedTokenizerBase,
28
  SequenceFeatureExtractor,
 
47
  from .asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
48
  from .asr_config import ASRConfig, compute_encoder_output_length
49
  from .asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
50
+ from .asr_processing import (
51
+ DEFAULT_TRANSCRIBE_PROMPT,
52
+ ASRProcessor,
53
+ left_pad_prompt_rows,
54
+ render_audio_prompt,
55
+ )
56
  from .asr_types import (
57
  DecoderLoadKwargs,
58
  GenerativeDecoder,
 
72
  from .asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
73
  from .asr_config import ASRConfig, compute_encoder_output_length
74
  from .asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
75
+ from .asr_processing import (
76
+ DEFAULT_TRANSCRIBE_PROMPT,
77
+ ASRProcessor,
78
+ left_pad_prompt_rows,
79
+ render_audio_prompt,
80
+ )
81
  from .asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
82
  from .projectors import PROJECTOR_CLASSES
83
  except ImportError: # flat layout on the Hub: sibling modules, no package
84
  from asr_attention import resolve_attn_implementation, resolve_decoder_attn_implementation
85
  from asr_config import ASRConfig, compute_encoder_output_length
86
  from asr_layers import chunk_oversized_embeddings, unfreeze_encoder_top_layers
87
+ from asr_processing import (
88
+ DEFAULT_TRANSCRIBE_PROMPT,
89
+ ASRProcessor,
90
+ left_pad_prompt_rows,
91
+ render_audio_prompt,
92
+ )
93
  from asr_types import LoadStateDictResult, OutputLengthProjector, StateDictT
94
  from projectors import PROJECTOR_CLASSES
95
 
 
101
  except ImportError: # transformers < 5.16: no native granite_speech5
102
  GraniteSpeech5Encoder = None
103
  try:
 
104
  from transformers.models.gemma4.modeling_gemma4 import Gemma4ForCausalLM
105
  except ImportError: # transformers without the gemma4 architecture
 
106
  Gemma4ForCausalLM = None
107
 
108
 
 
210
  raise ValueError(msg)
211
 
212
 
213
+ def _splice_audio(
214
+ inputs_embeds: torch.Tensor, is_audio_token: torch.Tensor, audio_embeds: torch.Tensor
215
+ ) -> torch.Tensor:
216
+ """Scatter `audio_embeds` into `inputs_embeds` at the audio-placeholder positions."""
217
+ dev = inputs_embeds.device
218
+ return inputs_embeds.masked_scatter(
219
+ is_audio_token.unsqueeze(-1).to(dev), audio_embeds.to(dev, dtype=inputs_embeds.dtype)
220
+ )
221
+
222
+
223
  def _patch_gemma_decode_loop(model: "GenerativeDecoder") -> None:
224
  """Make a Gemma4ForCausalLM drop `per_layer_inputs` after the first step.
225
 
 
344
  supports_gradient_checkpointing = True
345
  _is_loading_from_pretrained: bool = False
346
 
347
+ TRANSCRIBE_PROMPT = DEFAULT_TRANSCRIBE_PROMPT
348
 
349
  @classmethod
350
  def from_pretrained(
 
504
 
505
  # Setup LoRA if enabled (Stage 2 fine-tuning)
506
  # Skip if loading from pretrained - from_pretrained will handle adapter loading
507
+ if getattr(config, "use_lora", False) and not type(self)._is_loading_from_pretrained:
 
 
508
  self._setup_lora(config)
509
 
510
  # Freeze projector if specified (for Stage 2 LoRA-only training)
 
778
  raise ImportError(msg)
779
 
780
  # Only reached for gemma-4 checkpoints, whose text config is Gemma4TextConfig.
781
+ text_config = cast("Gemma4TextConfig", loaded.config.get_text_config())
782
  with torch.device("meta"):
783
  shell = Gemma4ForCausalLM(text_config)
784
 
 
787
  shell.generation_config = loaded.generation_config
788
  shell.tie_weights()
789
 
790
+ # See _patch_gemma_decode_loop for why PLE is dropped after step 1.
 
 
 
 
 
 
 
 
 
 
791
  # Gemma4ForCausalLM is a PreTrainedModel with GenerationMixin, which is
792
  # all `GenerativeDecoder` describes.
793
  decoder = cast("GenerativeDecoder", shell)
 
1182
  to the projector); includes the encoder only when it's trainable.
1183
  """
1184
  targets: list[nn.Module] = [self.language_model]
1185
+ if self._encoder_has_trainable_params():
 
 
1186
  targets.append(self.audio_tower)
1187
  return targets
1188
 
1189
+ def _encoder_has_trainable_params(self) -> bool:
1190
+ """True if any encoder parameter requires grad.
1191
+
1192
+ Keyed on autograd state, NOT on `freeze_audio_encoder`. The documented
1193
+ partial-unfreeze recipe is `freeze_audio_encoder: true` PLUS
1194
+ `encoder_trainable_top_layers: N` (see ASRConfig), which leaves the flag
1195
+ True while `_load_audio_encoder` switches the top N blocks back on.
1196
+ Reading the flag instead once wrapped those blocks in no_grad (so the
1197
+ run paid ~1.6 GiB of AdamW state for 198M params that could never
1198
+ move) and kept them out of the checkpoint.
1199
+ """
1200
+ return any(p.requires_grad for p in self.audio_tower.parameters())
1201
+
1202
  def get_input_embeddings(self) -> nn.Module:
1203
  """Return the decoder's token embedding module."""
1204
  return self.language_model.get_input_embeddings()
 
1403
  ):
1404
  tower_kwargs["attention_mask"] = audio_attention_mask.to(audio_features.device)
1405
 
1406
+ grad_ctx = (
1407
+ contextlib.nullcontext() if self._encoder_has_trainable_params() else torch.no_grad()
1408
+ )
1409
+ with grad_ctx:
 
 
 
 
 
 
 
 
 
 
1410
  encoder_out = self.audio_tower(**tower_kwargs)
1411
+ hidden_states = encoder_out.last_hidden_state
1412
 
1413
  # Conformer encoders (Granite) return their own validity mask, halved
1414
  # alongside each subsampling block. That is the encoder's arithmetic
 
1625
  input_features, audio_token_counts, audio_attention_mask
1626
  )
1627
 
1628
+ inputs_embeds = _splice_audio(inputs_embeds, is_audio_token, audio_embeds)
 
 
 
 
1629
 
1630
  # Forward label_smoothing to the LM's loss_function via **kwargs.
1631
  # transformers.loss.loss_utils.ForCausalLMLoss → fixed_cross_entropy
 
1713
 
1714
  return model_inputs
1715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1716
  def _render_audio_prompt(self, num_audio_tokens: int) -> torch.Tensor:
1717
  """Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
1718
+ return render_audio_prompt(
1719
+ self.tokenizer, self.audio_token, num_audio_tokens, self.TRANSCRIBE_PROMPT
 
 
 
 
 
 
 
 
 
 
 
1720
  )
 
 
 
 
 
1721
 
1722
  def _left_pad_prompt_rows(
1723
  self,
 
1770
  input_ids, attention_mask = self._left_pad_prompt_rows(rows, device)
1771
 
1772
  # Get text embeddings and replace audio tokens with audio embeddings
1773
+ inputs_embeds = _splice_audio(
1774
+ self._embed_tokens(input_ids), input_ids == self.audio_token_id, audio_embeds
 
 
 
1775
  )
1776
  return input_ids, attention_mask, inputs_embeds
1777
 
 
2088
 
2089
  # Copy source files for auto-loading
2090
  src_dir = Path(__file__).parent
2091
+ siblings = ("projectors.py", "alignment.py", "diarization.py")
2092
+ for src in [*src_dir.glob("asr_*.py"), *(src_dir / name for name in siblings)]:
2093
+ shutil.copy(src, save_dir / src.name)
 
 
 
 
 
2094
 
2095
  def create_or_update_model_card(self, output_dir: str | Path) -> None:
2096
  """Re-apply the PEFT card metadata to `output_dir/README.md`.
asr_pipeline.py CHANGED
@@ -14,7 +14,6 @@ from transformers.pipelines.audio_utils import ffmpeg_read
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
@@ -102,10 +101,21 @@ _TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$",
102
  class _RawAudio(TypedDict):
103
  """A waveform and its sampling rate, as `_extract_audio` reads them."""
104
 
105
- array: npt.ArrayLike
106
  sampling_rate: int
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
109
  class _DiarizationParams(TypedDict):
110
  """Speaker-count hints forwarded to `NemotronDiarizer.top_speakers`."""
111
 
@@ -154,12 +164,8 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
154
  # `return_timestamps` means word timestamps here (handled in __call__),
155
  # not the parent's CTC/Whisper timestamps, so it is dropped too.
156
  del return_timestamps
157
- kwargs.pop("return_speakers", None)
158
- kwargs.pop("num_speakers", None)
159
- kwargs.pop("min_speakers", None)
160
- kwargs.pop("max_speakers", None)
161
- kwargs.pop("hf_token", None)
162
- kwargs.pop("user_prompt", None)
163
 
164
  return super()._sanitize_parameters(
165
  chunk_length_s=chunk_length_s,
@@ -260,8 +266,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
260
  audio = self._extract_audio(inputs)
261
  if audio is None:
262
  return super().__call__(inputs, **kwargs)
263
- array = np.asarray(audio["array"], dtype=np.float32)
264
- sr = audio.get("sampling_rate", 16000)
265
 
266
  bounds = chunk_bounds(array, sr)
267
  if len(bounds) == 1:
@@ -310,8 +315,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
310
  if audio is None:
311
  msg = f"Cannot read audio from {type(inputs).__name__} for timestamps"
312
  raise ValueError(msg)
313
- array = np.asarray(audio["array"], dtype=np.float32)
314
- sr = audio.get("sampling_rate", 16000)
315
  if return_speakers:
316
  return self._transcribe_streams(array, sr, diarization_params, **kwargs)
317
  return self._transcribe_aligned(array, sr, **kwargs)
@@ -367,15 +371,16 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
367
  result["diarization_error"] = str(e)
368
  return result
369
 
 
 
370
  masks = {c: activity[:, c] > NemotronDiarizer.SEGMENT_THRESHOLD for c in keep}
371
- talking = [c for c in keep if masks[c].any()]
372
- if len(talking) <= 1:
373
  result = self._transcribe_aligned(array, sr, **kwargs)
374
- name = f"SPEAKER_{keep.index(talking[0]) if talking else 0}"
375
  result["words"] = [{**w, "speaker": name} for w in result["words"]]
376
  else:
377
  chunks: list[StreamChunk] = []
378
- for c in talking:
379
  spans = NemotronDiarizer.sample_spans(masks[c], sr, len(array))
380
  chunks.extend(
381
  {"col": c, "start": s, "audio": masked_audio(array, spans, s, e)}
@@ -415,31 +420,21 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
415
  return {"text": " ".join(w["word"] for w in words), "words": words}
416
 
417
  def _extract_audio(self, inputs: object) -> _RawAudio | None:
418
- """Extract audio array from various input formats using HF utilities."""
419
  if isinstance(inputs, dict):
420
- sample = inputs
421
- if "array" in sample:
422
- return {
423
- "array": sample["array"],
424
- "sampling_rate": sample.get("sampling_rate", 16000),
425
- }
426
- if "raw" in sample:
427
- return {
428
- "array": sample["raw"],
429
- "sampling_rate": sample.get("sampling_rate", 16000),
430
- }
431
- elif isinstance(inputs, str):
432
- # File path - load audio using ffmpeg (same as HF pipeline)
433
- with Path(inputs).open("rb") as f:
434
- audio = ffmpeg_read(f.read(), sampling_rate=16000)
435
- return {"array": audio, "sampling_rate": 16000}
436
- elif isinstance(inputs, bytes):
437
- audio = ffmpeg_read(inputs, sampling_rate=16000)
438
- return {"array": audio, "sampling_rate": 16000}
439
  elif isinstance(inputs, np.ndarray):
440
- return {"array": inputs, "sampling_rate": 16000}
441
-
442
- return None
 
443
 
444
  def preprocess(self, *args: Any, **preprocess_params: Any) -> Iterator[dict[str, Any]]:
445
  """Preprocess audio inputs for the model.
@@ -556,13 +551,11 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
556
  top1_logprobs: list[float] = []
557
  top2_logprobs: list[float] = []
558
  if scores:
559
- # Reduce each step on-device, then transfer every step in one go.
560
- # Calling .item() per step would block on the device 2x per token.
561
- per_step = [
562
- torch.topk(torch.log_softmax(step_logits[0].float(), dim=-1), k=2).values
563
- for step_logits in scores
564
- ]
565
- steps: list[list[float]] = np.asarray(torch.stack(per_step).cpu()).tolist()
566
  for top1, top2 in steps:
567
  top1_logprobs.append(top1)
568
  top2_logprobs.append(top2)
 
14
  if TYPE_CHECKING:
15
  from transformers import PreTrainedTokenizerBase, SequenceFeatureExtractor
16
 
 
17
  from .alignment import QwenForcedAligner
18
  from .asr_modeling import ASRModel
19
  from .asr_processing import prepend_lead_in
 
101
  class _RawAudio(TypedDict):
102
  """A waveform and its sampling rate, as `_extract_audio` reads them."""
103
 
104
+ array: npt.NDArray[np.float32]
105
  sampling_rate: int
106
 
107
 
108
+ # `__call__` keywords this pipeline handles itself; the parent never sees them.
109
+ _CUSTOM_PARAMS = (
110
+ "return_speakers",
111
+ "num_speakers",
112
+ "min_speakers",
113
+ "max_speakers",
114
+ "hf_token",
115
+ "user_prompt",
116
+ )
117
+
118
+
119
  class _DiarizationParams(TypedDict):
120
  """Speaker-count hints forwarded to `NemotronDiarizer.top_speakers`."""
121
 
 
164
  # `return_timestamps` means word timestamps here (handled in __call__),
165
  # not the parent's CTC/Whisper timestamps, so it is dropped too.
166
  del return_timestamps
167
+ for name in _CUSTOM_PARAMS:
168
+ kwargs.pop(name, None)
 
 
 
 
169
 
170
  return super()._sanitize_parameters(
171
  chunk_length_s=chunk_length_s,
 
266
  audio = self._extract_audio(inputs)
267
  if audio is None:
268
  return super().__call__(inputs, **kwargs)
269
+ array, sr = audio["array"], audio["sampling_rate"]
 
270
 
271
  bounds = chunk_bounds(array, sr)
272
  if len(bounds) == 1:
 
315
  if audio is None:
316
  msg = f"Cannot read audio from {type(inputs).__name__} for timestamps"
317
  raise ValueError(msg)
318
+ array, sr = audio["array"], audio["sampling_rate"]
 
319
  if return_speakers:
320
  return self._transcribe_streams(array, sr, diarization_params, **kwargs)
321
  return self._transcribe_aligned(array, sr, **kwargs)
 
371
  result["diarization_error"] = str(e)
372
  return result
373
 
374
+ # `top_speakers` keeps only columns that are ever active, so every
375
+ # mask has speech -- except its `[0]` fallback when nobody spoke.
376
  masks = {c: activity[:, c] > NemotronDiarizer.SEGMENT_THRESHOLD for c in keep}
377
+ if len(keep) <= 1:
 
378
  result = self._transcribe_aligned(array, sr, **kwargs)
379
+ name = NemotronDiarizer.speaker_names(keep)[keep[0]]
380
  result["words"] = [{**w, "speaker": name} for w in result["words"]]
381
  else:
382
  chunks: list[StreamChunk] = []
383
+ for c in keep:
384
  spans = NemotronDiarizer.sample_spans(masks[c], sr, len(array))
385
  chunks.extend(
386
  {"col": c, "start": s, "audio": masked_audio(array, spans, s, e)}
 
420
  return {"text": " ".join(w["word"] for w in words), "words": words}
421
 
422
  def _extract_audio(self, inputs: object) -> _RawAudio | None:
423
+ """Extract a float32 audio array and its rate from various input formats."""
424
  if isinstance(inputs, dict):
425
+ key = "array" if "array" in inputs else "raw" if "raw" in inputs else None
426
+ if key is None:
427
+ return None
428
+ array, sr = inputs[key], inputs.get("sampling_rate", 16000)
429
+ elif isinstance(inputs, (str, bytes)):
430
+ # File path or encoded bytes - decode with ffmpeg (same as HF pipeline)
431
+ data = Path(inputs).read_bytes() if isinstance(inputs, str) else inputs
432
+ array, sr = ffmpeg_read(data, sampling_rate=16000), 16000
 
 
 
 
 
 
 
 
 
 
 
433
  elif isinstance(inputs, np.ndarray):
434
+ array, sr = inputs, 16000
435
+ else:
436
+ return None
437
+ return {"array": np.asarray(array, dtype=np.float32), "sampling_rate": sr}
438
 
439
  def preprocess(self, *args: Any, **preprocess_params: Any) -> Iterator[dict[str, Any]]:
440
  """Preprocess audio inputs for the model.
 
551
  top1_logprobs: list[float] = []
552
  top2_logprobs: list[float] = []
553
  if scores:
554
+ # One batched reduction over every step, then one device transfer.
555
+ logits = torch.stack([step_logits[0] for step_logits in scores]).float()
556
+ steps: list[list[float]] = (
557
+ logits.log_softmax(dim=-1).topk(k=2, dim=-1).values.cpu().tolist()
558
+ )
 
 
559
  for top1, top2 in steps:
560
  top1_logprobs.append(top1)
561
  top2_logprobs.append(top2)
asr_processing.py CHANGED
@@ -43,6 +43,51 @@ else:
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]:
@@ -123,7 +168,7 @@ class ASRProcessor(ProcessorMixin):
123
  # never added to that vocab, so it tokenizes into ordinary subwords and
124
  # the prompt ends up with zero scatter positions for N audio embeddings.
125
  AUDIO_TOKEN = "<audio>"
126
- TRANSCRIBE_PROMPT = "Transcribe the speech to text"
127
 
128
  def __init__(
129
  self,
@@ -153,39 +198,12 @@ class ASRProcessor(ProcessorMixin):
153
  self.encoder_conv_layers = encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
154
  self.lead_in_seconds = float(lead_in_seconds)
155
 
156
- def _compute_encoder_output_length(self, mel_length: int) -> int:
157
- """Compute encoder output length using conv layer formulas."""
158
- return compute_encoder_output_length(mel_length, self.encoder_conv_layers)
159
-
160
  def _render_prompt(self, num_audio_tokens: int, text: str | None) -> torch.Tensor:
161
  """Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
162
- if num_audio_tokens > 0:
163
- user_content = self.audio_token * num_audio_tokens
164
- if self.TRANSCRIBE_PROMPT:
165
- user_content += " " + self.TRANSCRIBE_PROMPT
166
- else:
167
- user_content = self.TRANSCRIBE_PROMPT or ""
168
-
169
- messages = [{"role": "user", "content": user_content}]
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.
186
- ids = tokenized if isinstance(tokenized, torch.Tensor) else tokenized["input_ids"]
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)
@@ -226,7 +244,7 @@ class ASRProcessor(ProcessorMixin):
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,
232
  **kwargs,
@@ -249,11 +267,9 @@ class ASRProcessor(ProcessorMixin):
249
  # silently. This is the same failure `_prepare_audio_inputs`
250
  # documents as fixed on the model side, and it only shows up on a
251
  # ragged batch, so batch-1 eval never sees it.
252
- mel_lengths = audio_inputs["attention_mask"].sum(dim=-1).reshape(-1)
253
- token_counts = [
254
- int(self.projector.get_output_length(self._compute_encoder_output_length(int(m))))
255
- for m in mel_lengths
256
- ]
257
 
258
  rows = [self._render_prompt(n, text) for n in token_counts]
259
  input_ids, attention_mask = self._stack_prompt_rows(rows)
 
43
  from asr_types import AudioInput
44
 
45
 
46
+ # The instruction the model trained on (scripts/train_collator.py); the model
47
+ # and processor both default to it.
48
+ DEFAULT_TRANSCRIBE_PROMPT = "Transcribe the speech to text"
49
+
50
+
51
+ def render_audio_prompt(
52
+ tokenizer: PreTrainedTokenizerBase,
53
+ audio_token: str,
54
+ num_audio_tokens: int,
55
+ prompt: str | None,
56
+ text: str | None = None,
57
+ ) -> torch.Tensor:
58
+ """Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders.
59
+
60
+ The user turn is the placeholders, then `prompt` (if any); `text`, when
61
+ given, is the assistant's reply, otherwise the generation prompt is added.
62
+ """
63
+ if num_audio_tokens > 0:
64
+ user_content = audio_token * num_audio_tokens
65
+ if prompt:
66
+ user_content += " " + prompt
67
+ else:
68
+ user_content = prompt or ""
69
+
70
+ messages = [{"role": "user", "content": user_content}]
71
+ if text is not None:
72
+ messages.append({"role": "assistant", "content": text})
73
+
74
+ # With `tokenize=True, return_tensors="pt"` the ids come back as tensors.
75
+ tokenized = cast(
76
+ "torch.Tensor | Mapping[str, torch.Tensor]",
77
+ tokenizer.apply_chat_template(
78
+ messages,
79
+ tokenize=True,
80
+ add_generation_prompt=(text is None),
81
+ return_tensors="pt",
82
+ enable_thinking=False, # Disable Qwen3 thinking mode for ASR
83
+ ),
84
+ )
85
+
86
+ # apply_chat_template returns a bare tensor or a BatchEncoding/mapping.
87
+ ids = tokenized if isinstance(tokenized, torch.Tensor) else tokenized["input_ids"]
88
+ return (ids[0] if ids.dim() > 1 else ids).to(torch.long)
89
+
90
+
91
  def left_pad_prompt_rows(
92
  rows: list[torch.Tensor], tokenizer: PreTrainedTokenizerBase
93
  ) -> tuple[torch.Tensor, torch.Tensor]:
 
168
  # never added to that vocab, so it tokenizes into ordinary subwords and
169
  # the prompt ends up with zero scatter positions for N audio embeddings.
170
  AUDIO_TOKEN = "<audio>"
171
+ TRANSCRIBE_PROMPT = DEFAULT_TRANSCRIBE_PROMPT
172
 
173
  def __init__(
174
  self,
 
198
  self.encoder_conv_layers = encoder_conv_layers or DEFAULT_ENCODER_CONV_LAYERS
199
  self.lead_in_seconds = float(lead_in_seconds)
200
 
 
 
 
 
201
  def _render_prompt(self, num_audio_tokens: int, text: str | None) -> torch.Tensor:
202
  """Tokenize one chat prompt carrying exactly `num_audio_tokens` placeholders."""
203
+ return render_audio_prompt(
204
+ self.tokenizer, self.audio_token, num_audio_tokens, self.TRANSCRIBE_PROMPT, text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  )
206
 
 
 
 
 
207
  def _stack_prompt_rows(self, rows: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
208
  """Stack per-sample prompt rows into a batch (see `left_pad_prompt_rows`)."""
209
  return left_pad_prompt_rows(rows, self.tokenizer)
 
244
  extract = cast("AudioFeatureExtractor", self.feature_extractor)
245
  audio_inputs = extract(
246
  padded_audio,
247
+ sampling_rate=sr,
248
  return_attention_mask=True,
249
  return_tensors=return_tensors,
250
  **kwargs,
 
267
  # silently. This is the same failure `_prepare_audio_inputs`
268
  # documents as fixed on the model side, and it only shows up on a
269
  # ragged batch, so batch-1 eval never sees it.
270
+ mel_lengths = audio_inputs["attention_mask"].sum(dim=-1).reshape(-1).long()
271
+ encoder_lengths = compute_encoder_output_length(mel_lengths, self.encoder_conv_layers)
272
+ token_counts = self.projector.get_output_length(encoder_lengths).tolist()
 
 
273
 
274
  rows = [self._render_prompt(n, text) for n in token_counts]
275
  input_ids, attention_mask = self._stack_prompt_rows(rows)
diarization.py CHANGED
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, TypedDict
7
  import numpy as np
8
  import numpy.typing as npt
9
  import torch
10
- import torchaudio
11
  from transformers import AutoModelForAudioFrameClassification, AutoProcessor
12
 
13
  if TYPE_CHECKING:
@@ -18,7 +17,12 @@ if TYPE_CHECKING:
18
  Nemotron3DiarizationProcessor,
19
  )
20
 
21
- from .alignment import AlignedWord
 
 
 
 
 
22
 
23
 
24
  class SpeakerSegment(TypedDict):
@@ -29,15 +33,6 @@ class SpeakerSegment(TypedDict):
29
  end: float
30
 
31
 
32
- def get_device() -> torch.device:
33
- """Get best available device for inference."""
34
- if torch.cuda.is_available():
35
- return torch.device("cuda")
36
- if torch.backends.mps.is_available():
37
- return torch.device("mps")
38
- return torch.device("cpu")
39
-
40
-
41
  def _label_runs(labels: npt.NDArray[np.generic]) -> list[tuple[int, int, int]]:
42
  """Split a 1-D label array into maximal constant runs.
43
 
@@ -161,15 +156,8 @@ class NemotronDiarizer:
161
  @torch.inference_mode()
162
  def activity(cls, audio: npt.ArrayLike, sample_rate: int = 16000) -> npt.NDArray[np.float32]:
163
  """(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
164
- if sample_rate != 16000:
165
- audio = np.asarray(
166
- torchaudio.functional.resample(
167
- torch.as_tensor(audio, dtype=torch.float32), sample_rate, 16000
168
- ),
169
- dtype=np.float32,
170
- )
171
  model, processor = cls.get_instance()
172
- inputs = processor(np.asarray(audio, dtype=np.float32), sampling_rate=16000)
173
  inputs = inputs.to(model.device, dtype=model.dtype)
174
  probs: npt.NDArray[np.float32] = model(**inputs).logits[0].float().sigmoid().cpu().numpy()
175
  return probs
@@ -196,10 +184,15 @@ class NemotronDiarizer:
196
  active = sorted(sorted(active, key=lambda c: -mass[c])[:cap])
197
  return active or [0]
198
 
 
 
 
 
 
199
  @classmethod
200
  def segments(cls, activity: npt.NDArray[np.float32], keep: list[int]) -> list[SpeakerSegment]:
201
  """Speaker turns `{"speaker", "start", "end"}` by start time; overlaps allowed."""
202
- names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
203
  out: list[SpeakerSegment] = []
204
  for c in keep:
205
  for on, s, e in _label_runs(activity[:, c] > cls.SEGMENT_THRESHOLD):
@@ -257,7 +250,7 @@ class NemotronDiarizer:
257
  A word placed wholly in its own speaker's silenced audio (`masks`) is a
258
  hallucination on silence and is dropped.
259
  """
260
- names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
261
  words: list[dict[str, Any]] = []
262
  for ch, chunk_words in zip(chunks, aligned, strict=True):
263
  offset = ch["start"] / sample_rate
@@ -287,7 +280,8 @@ class NemotronDiarizer:
287
  leaked into both masks: the copy whose speaker is more active over its
288
  span stays, the earlier word on a tie.
289
  """
290
- cols = {f"SPEAKER_{i}": c for i, c in enumerate(keep)}
 
291
  dropped: set[int] = set()
292
  for i, a in enumerate(words):
293
  for j in range(i + 1, len(words)):
@@ -296,7 +290,7 @@ class NemotronDiarizer:
296
  break
297
  if i in dropped or j in dropped or a["speaker"] == b["speaker"]:
298
  continue
299
- if not _norm_word(a["word"]) or _norm_word(a["word"]) != _norm_word(b["word"]):
300
  continue
301
  score_a = cls._span_activity(activity, cols[a["speaker"]], a["start"], a["end"])
302
  score_b = cls._span_activity(activity, cols[b["speaker"]], b["start"], b["end"])
 
7
  import numpy as np
8
  import numpy.typing as npt
9
  import torch
 
10
  from transformers import AutoModelForAudioFrameClassification, AutoProcessor
11
 
12
  if TYPE_CHECKING:
 
17
  Nemotron3DiarizationProcessor,
18
  )
19
 
20
+ from .alignment import AlignedWord, get_device, to_16k
21
+ else:
22
+ try:
23
+ from .alignment import get_device, to_16k
24
+ except ImportError: # flat layout on the Hub: sibling modules, no package
25
+ from alignment import get_device, to_16k
26
 
27
 
28
  class SpeakerSegment(TypedDict):
 
33
  end: float
34
 
35
 
 
 
 
 
 
 
 
 
 
36
  def _label_runs(labels: npt.NDArray[np.generic]) -> list[tuple[int, int, int]]:
37
  """Split a 1-D label array into maximal constant runs.
38
 
 
156
  @torch.inference_mode()
157
  def activity(cls, audio: npt.ArrayLike, sample_rate: int = 16000) -> npt.NDArray[np.float32]:
158
  """(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
 
 
 
 
 
 
 
159
  model, processor = cls.get_instance()
160
+ inputs = processor(to_16k(audio, sample_rate), sampling_rate=16000)
161
  inputs = inputs.to(model.device, dtype=model.dtype)
162
  probs: npt.NDArray[np.float32] = model(**inputs).logits[0].float().sigmoid().cpu().numpy()
163
  return probs
 
184
  active = sorted(sorted(active, key=lambda c: -mass[c])[:cap])
185
  return active or [0]
186
 
187
+ @staticmethod
188
+ def speaker_names(keep: list[int]) -> dict[int, str]:
189
+ """Column -> label: `SPEAKER_i` for the column's position in `keep`."""
190
+ return {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
191
+
192
  @classmethod
193
  def segments(cls, activity: npt.NDArray[np.float32], keep: list[int]) -> list[SpeakerSegment]:
194
  """Speaker turns `{"speaker", "start", "end"}` by start time; overlaps allowed."""
195
+ names = cls.speaker_names(keep)
196
  out: list[SpeakerSegment] = []
197
  for c in keep:
198
  for on, s, e in _label_runs(activity[:, c] > cls.SEGMENT_THRESHOLD):
 
250
  A word placed wholly in its own speaker's silenced audio (`masks`) is a
251
  hallucination on silence and is dropped.
252
  """
253
+ names = cls.speaker_names(keep)
254
  words: list[dict[str, Any]] = []
255
  for ch, chunk_words in zip(chunks, aligned, strict=True):
256
  offset = ch["start"] / sample_rate
 
280
  leaked into both masks: the copy whose speaker is more active over its
281
  span stays, the earlier word on a tie.
282
  """
283
+ cols = {name: c for c, name in cls.speaker_names(keep).items()}
284
+ norms = [_norm_word(w["word"]) for w in words]
285
  dropped: set[int] = set()
286
  for i, a in enumerate(words):
287
  for j in range(i + 1, len(words)):
 
290
  break
291
  if i in dropped or j in dropped or a["speaker"] == b["speaker"]:
292
  continue
293
+ if not norms[i] or norms[i] != norms[j]:
294
  continue
295
  score_a = cls._span_activity(activity, cols[a["speaker"]], a["start"], a["end"])
296
  score_b = cls._span_activity(activity, cols[b["speaker"]], b["start"], b["end"])
handler.py CHANGED
@@ -1,27 +1,49 @@
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:
26
  """HuggingFace Inference Endpoints handler for ASR model.
27
 
@@ -35,13 +57,6 @@ 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
 
47
  # `ASRModel.from_pretrained` constructs its own submodules and forwards
@@ -66,15 +81,24 @@ class EndpointHandler:
66
  tokenizer=self.model.tokenizer,
67
  device=self.device,
68
  )
 
 
 
 
 
69
 
70
  def __call__(self, data: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
71
  """Process an inference request.
72
 
73
  Args:
74
- data: Request data containing 'inputs' (audio path/bytes) and optional 'parameters'
 
 
 
75
 
76
  Returns:
77
- Transcription result with 'text' key
 
78
  """
79
  inputs = data.get("inputs")
80
  if inputs is None:
@@ -84,5 +108,5 @@ class EndpointHandler:
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
 
1
  """Custom inference handler for HuggingFace Inference Endpoints."""
2
 
3
+ import base64
4
+ import binascii
5
  import os
6
+ from pathlib import Path
7
  from typing import TYPE_CHECKING, Any
8
 
 
 
9
  if TYPE_CHECKING:
10
+ from .alignment import QwenForcedAligner
11
  from .asr_modeling import ASRModel
12
  from .asr_pipeline import ASRPipeline
13
+ from .diarization import NemotronDiarizer
14
  from .diarization import get_device as _best_device
15
  else:
16
  try:
17
  # For remote execution, imports are relative
18
+ from .alignment import QwenForcedAligner
19
  from .asr_modeling import ASRModel
20
  from .asr_pipeline import ASRPipeline
21
+ from .diarization import NemotronDiarizer
22
  from .diarization import get_device as _best_device
23
  except ImportError:
24
  # For local execution, imports are not relative
25
+ from alignment import QwenForcedAligner
26
  from asr_modeling import ASRModel
27
  from asr_pipeline import ASRPipeline
28
+ from diarization import NemotronDiarizer
29
  from diarization import get_device as _best_device
30
 
31
 
32
+ def decode_inputs(inputs: Any) -> Any:
33
+ """A JSON request's base64 audio as bytes; paths, raw bytes and arrays unchanged.
34
+
35
+ The toolkit hands `inputs` over as raw bytes for an `audio/*` body, but a
36
+ JSON body can only carry audio as a base64 string, which the pipeline
37
+ would otherwise read as a file path.
38
+ """
39
+ if not isinstance(inputs, str) or Path(inputs).exists():
40
+ return inputs
41
+ try:
42
+ return base64.b64decode(inputs, validate=True)
43
+ except (binascii.Error, ValueError):
44
+ return inputs
45
+
46
+
47
  class EndpointHandler:
48
  """HuggingFace Inference Endpoints handler for ASR model.
49
 
 
57
  Args:
58
  path: Path to model directory or HuggingFace model ID
59
  """
 
 
 
 
 
 
 
60
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
61
 
62
  # `ASRModel.from_pretrained` constructs its own submodules and forwards
 
81
  tokenizer=self.model.tokenizer,
82
  device=self.device,
83
  )
84
+ # Load the forced aligner (return_timestamps) and Nemotron diarizer
85
+ # (return_speakers) at boot, as the Spaces demo does, so the first
86
+ # request that asks for them doesn't pay the download and load.
87
+ QwenForcedAligner.get_instance()
88
+ NemotronDiarizer.get_instance()
89
 
90
  def __call__(self, data: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
91
  """Process an inference request.
92
 
93
  Args:
94
+ data: Request data containing 'inputs' (audio path, bytes or base64
95
+ string) and optional 'parameters' for the pipeline, e.g.
96
+ `return_timestamps`, `return_speakers`, `num_speakers`,
97
+ `max_speakers` -- the same options as the Spaces demo
98
 
99
  Returns:
100
+ Transcription result with 'text' key; 'words' with timestamps, plus
101
+ speaker labels and 'speaker_segments' when diarizing
102
  """
103
  inputs = data.get("inputs")
104
  if inputs is None:
 
108
  # Pass through any parameters from request, let model config provide defaults
109
  params = data.get("parameters", {})
110
 
111
+ result: dict[str, Any] | list[dict[str, Any]] = self.pipe(decode_inputs(inputs), **params)
112
  return result
requirements.txt CHANGED
@@ -5,6 +5,14 @@
5
  # Speaker diarization (return_speakers=True) uses Nemotron-3-Diarization, which is in
6
  # transformers main after 5.17 -- pinned to the commit the pipeline was verified on.
7
  transformers @ git+https://github.com/huggingface/transformers@91ea1192a00e0ebb684400434375fbd2345c8449
8
- torch
9
- torchaudio
 
 
 
10
  peft
 
 
 
 
 
 
5
  # Speaker diarization (return_speakers=True) uses Nemotron-3-Diarization, which is in
6
  # transformers main after 5.17 -- pinned to the commit the pipeline was verified on.
7
  transformers @ git+https://github.com/huggingface/transformers@91ea1192a00e0ebb684400434375fbd2345c8449
8
+ # Pinned floor, not just "torch": the Inference Endpoints image ships an older
9
+ # torch that satisfies a bare requirement, and transformers disables PyTorch
10
+ # below 2.5 ("Could not import module 'WhisperForConditionalGeneration'").
11
+ torch>=2.5
12
+ torchaudio>=2.5
13
  peft
14
+ safetensors
15
+ numpy
16
+ # Fast path for Qwen3.5's gated-delta-rule layers (transformers falls back to a
17
+ # slower reference implementation without it). Pure Triton, nothing to compile.
18
+ flash-linear-attention>=0.2.2