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- alignment.py +20 -20
- asr_attention.py +10 -5
- asr_modeling.py +60 -92
- asr_pipeline.py +39 -46
- asr_processing.py +52 -36
- diarization.py +17 -23
- handler.py +36 -12
- requirements.txt +10 -2
alignment.py
CHANGED
|
@@ -38,13 +38,26 @@ class AlignedWord(TypedDict):
|
|
| 38 |
end: float
|
| 39 |
|
| 40 |
|
| 41 |
-
def
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1727 |
-
|
| 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 =
|
| 1798 |
-
|
| 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 |
-
|
| 2119 |
-
|
| 2120 |
-
|
| 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.
|
| 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 |
-
|
| 158 |
-
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 372 |
-
if len(talking) <= 1:
|
| 373 |
result = self._transcribe_aligned(array, sr, **kwargs)
|
| 374 |
-
name =
|
| 375 |
result["words"] = [{**w, "speaker": name} for w in result["words"]]
|
| 376 |
else:
|
| 377 |
chunks: list[StreamChunk] = []
|
| 378 |
-
for c in
|
| 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
|
| 419 |
if isinstance(inputs, dict):
|
| 420 |
-
|
| 421 |
-
if
|
| 422 |
-
return
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
if
|
| 427 |
-
|
| 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 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
| 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 |
-
#
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 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 =
|
| 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 |
-
|
| 163 |
-
|
| 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=
|
| 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 |
-
|
| 254 |
-
|
| 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(
|
| 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 =
|
| 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 =
|
| 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 = {
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|