mazesmazes commited on
Commit
37f59d3
·
verified ·
1 Parent(s): 36b481d

Update custom model files, README, and requirements

Browse files
Files changed (9) hide show
  1. alignment.py +2 -3
  2. asr_config.py +7 -4
  3. asr_layers.py +4 -5
  4. asr_modeling.py +42 -45
  5. asr_pipeline.py +126 -69
  6. asr_processing.py +103 -9
  7. asr_types.py +8 -0
  8. diarization.py +16 -0
  9. requirements.txt +2 -15
alignment.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from collections.abc import Sequence
6
  from typing import TYPE_CHECKING, TypedDict
7
 
@@ -126,9 +127,7 @@ class QwenForcedAligner:
126
  if not todo: # nothing to time: don't load the model
127
  return results
128
  model, processor = cls.get_instance()
129
- # Slices, not itertools.batched (3.12+): the Hub copy runs on Python 3.11.
130
- for start in range(0, len(todo), cls.BATCH_SIZE):
131
- batch = todo[start : start + cls.BATCH_SIZE]
132
  features, word_lists = processor.prepare_forced_aligner_inputs(
133
  [audio for _, audio, _ in batch],
134
  [" ".join(kept) for _, _, kept in batch],
 
2
 
3
  from __future__ import annotations
4
 
5
+ import itertools
6
  from collections.abc import Sequence
7
  from typing import TYPE_CHECKING, TypedDict
8
 
 
127
  if not todo: # nothing to time: don't load the model
128
  return results
129
  model, processor = cls.get_instance()
130
+ for batch in itertools.batched(todo, cls.BATCH_SIZE):
 
 
131
  features, word_lists = processor.prepare_forced_aligner_inputs(
132
  [audio for _, audio, _ in batch],
133
  [" ".join(kept) for _, _, kept in batch],
asr_config.py CHANGED
@@ -76,9 +76,10 @@ def compute_encoder_output_length(
76
 
77
  def _config_from_dict(model_type: str, values: dict[str, Any]) -> transformers.PretrainedConfig:
78
  """Rebuild a serialized sub-config as the config class registered for `model_type`."""
79
- auto_config = transformers.AutoConfig
80
- default = auto_config.for_model(model_type)
81
- return default.__class__(**values)
 
82
 
83
 
84
  class ASRConfig(transformers.PretrainedConfig):
@@ -396,10 +397,12 @@ class ASRConfig(transformers.PretrainedConfig):
396
  # The pipeline looks for config.encoder._name_or_path for feature extractor
397
  self.encoder = self.audio_config
398
 
 
 
399
  self.auto_map = {
400
  "AutoConfig": "asr_config.ASRConfig",
401
- "AutoModel": "asr_modeling.ASRModel",
402
  "AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
 
403
  "AutoProcessor": "asr_processing.ASRProcessor",
404
  }
405
  self.custom_pipelines = {
 
76
 
77
  def _config_from_dict(model_type: str, values: dict[str, Any]) -> transformers.PretrainedConfig:
78
  """Rebuild a serialized sub-config as the config class registered for `model_type`."""
79
+ # Serialized configs carry their own `model_type`, which would collide with
80
+ # for_model's positional argument; the config class already sets it.
81
+ kwargs = {key: value for key, value in values.items() if key != "model_type"}
82
+ return transformers.AutoConfig.for_model(model_type, **kwargs)
83
 
84
 
85
  class ASRConfig(transformers.PretrainedConfig):
 
397
  # The pipeline looks for config.encoder._name_or_path for feature extractor
398
  self.encoder = self.audio_config
399
 
400
+ # The Hub infers the model's task from the first model class listed, so
401
+ # AutoModelForSpeechSeq2Seq must precede AutoModel (else: feature-extraction).
402
  self.auto_map = {
403
  "AutoConfig": "asr_config.ASRConfig",
 
404
  "AutoModelForSpeechSeq2Seq": "asr_modeling.ASRModel",
405
+ "AutoModel": "asr_modeling.ASRModel",
406
  "AutoProcessor": "asr_processing.ASRProcessor",
407
  }
408
  self.custom_pipelines = {
asr_layers.py CHANGED
@@ -115,11 +115,10 @@ def find_encoder_layer_stack(encoder: nn.Module) -> tuple[str, nn.ModuleList] |
115
  """
116
  candidates = ("layers", "blocks", "encoder_layers", "encoder.layers", "encoder.blocks")
117
  for path in candidates:
118
- obj: nn.Module | None = encoder
119
- for part in path.split("."):
120
- obj = getattr(obj, part, None)
121
- if obj is None:
122
- break
123
  if isinstance(obj, nn.ModuleList) and len(obj) > 0:
124
  return path, obj
125
  return None
 
115
  """
116
  candidates = ("layers", "blocks", "encoder_layers", "encoder.layers", "encoder.blocks")
117
  for path in candidates:
118
+ try:
119
+ obj = encoder.get_submodule(path)
120
+ except AttributeError:
121
+ continue
 
122
  if isinstance(obj, nn.ModuleList) and len(obj) > 0:
123
  return path, obj
124
  return None
asr_modeling.py CHANGED
@@ -6,7 +6,7 @@ import json
6
  import logging
7
  import shutil
8
  from collections.abc import Callable, Iterator
9
- from copy import copy
10
  from os import PathLike
11
  from pathlib import Path
12
  from threading import Thread
@@ -1852,25 +1852,18 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1852
  )
1853
  self._assert_sdpa_safe_on_mps(attention_mask)
1854
 
1855
- # transformers v5 deprecates passing generation flags as kwargs when a
1856
- # `generation_config` is also passed — the kwargs get silently dropped.
1857
- # Pull any score-related flags out of generate_kwargs and apply them to
1858
- # a derived generation_config so they actually take effect.
1859
- gen_cfg = self.generation_config
1860
- score_flags: dict[str, Any] = {}
1861
- for flag in ("output_scores", "output_logits", "return_dict_in_generate"):
1862
- if flag in generate_kwargs:
1863
- score_flags[flag] = generate_kwargs.pop(flag)
1864
- if score_flags:
1865
- gen_cfg = copy(self.generation_config)
1866
- for flag, value in score_flags.items():
1867
- setattr(gen_cfg, flag, value)
1868
- # output_scores requires return_dict_in_generate for HF generate to
1869
- # actually populate .scores on the output object.
1870
- if getattr(gen_cfg, "output_scores", None) and not getattr(
1871
- gen_cfg, "return_dict_in_generate", None
1872
- ):
1873
- gen_cfg.return_dict_in_generate = True
1874
 
1875
  lm_inputs = self._lm_generate_inputs(input_ids, inputs_embeds)
1876
  output = self.language_model.generate(
@@ -1946,10 +1939,13 @@ class ASRModel(PreTrainedModel, GenerationMixin):
1946
  # Prepare generation kwargs
1947
  # Same prompt arguments as generate(); with input_ids present the
1948
  # streamer's skip_prompt drops the echoed prompt tokens.
 
 
 
1949
  gen_kwargs: dict[str, Any] = {
1950
  **self._lm_generate_inputs(input_ids, inputs_embeds),
1951
  "attention_mask": attention_mask,
1952
- "generation_config": self.generation_config,
1953
  "streamer": streamer,
1954
  **generate_kwargs,
1955
  }
@@ -2042,30 +2038,31 @@ class ASRModel(PreTrainedModel, GenerationMixin):
2042
  # Don't save embedding layers - the <audio> token embedding is never used
2043
  # (it's replaced with projected audio embeddings before the LLM sees it)
2044
  if hasattr(self.language_model, "peft_config"):
2045
- self.language_model.save_pretrained(save_dir, save_embedding_layers=False)
2046
-
2047
- # Clear base_model_name_or_path in adapter_config.json to prevent HF pipeline
2048
- # from redirecting to the base LLM repo (like Qwen) which breaks feature
2049
- # extractor loading for multimodal models. If a repo_id is provided, use that
2050
- # so the model can be loaded directly from the Hub.
2051
- adapter_config_path = save_dir / "adapter_config.json"
2052
- if adapter_config_path.exists():
2053
- with adapter_config_path.open() as f:
2054
- adapter_config = json.load(f)
2055
-
2056
- # Use repo_id if available, otherwise clear to prevent redirect.
2057
- # Use empty string instead of None to avoid str(None) -> "None" bug
2058
- # in some transformers/PEFT versions.
2059
- repo_id = (
2060
- kwargs.get("repo_id")
2061
- or kwargs.get("push_to_hub_model_id")
2062
- or getattr(self.config, "pretrained_model_path", None)
2063
- or "" # Use empty string instead of None
2064
- )
2065
- adapter_config["base_model_name_or_path"] = repo_id
2066
-
2067
- with adapter_config_path.open("w") as f:
2068
- json.dump(adapter_config, f, indent=2)
 
2069
 
2070
  # Add processor auto_map to preprocessor_config.json
2071
  config_path = save_dir / "preprocessor_config.json"
 
6
  import logging
7
  import shutil
8
  from collections.abc import Callable, Iterator
9
+ from copy import deepcopy
10
  from os import PathLike
11
  from pathlib import Path
12
  from threading import Thread
 
1852
  )
1853
  self._assert_sdpa_safe_on_mps(attention_mask)
1854
 
1855
+ # Fold generation flags (max_new_tokens, num_beams, output_scores, ...)
1856
+ # into a private copy of generation_config instead of passing them
1857
+ # alongside it: transformers v5 still applies such kwargs but warns that
1858
+ # combining them with `generation_config` is deprecated. update() returns
1859
+ # the kwargs that aren't config fields (streamer, logits_processor, ...),
1860
+ # which still go to generate() directly.
1861
+ gen_cfg = deepcopy(self.generation_config)
1862
+ generate_kwargs = gen_cfg.update(**generate_kwargs)
1863
+ # output_scores requires return_dict_in_generate for HF generate to
1864
+ # actually populate .scores on the output object.
1865
+ if gen_cfg.output_scores:
1866
+ gen_cfg.return_dict_in_generate = True
 
 
 
 
 
 
 
1867
 
1868
  lm_inputs = self._lm_generate_inputs(input_ids, inputs_embeds)
1869
  output = self.language_model.generate(
 
1939
  # Prepare generation kwargs
1940
  # Same prompt arguments as generate(); with input_ids present the
1941
  # streamer's skip_prompt drops the echoed prompt tokens.
1942
+ # Generation flags go into a copy of generation_config, as in generate().
1943
+ gen_cfg = deepcopy(self.generation_config)
1944
+ generate_kwargs = gen_cfg.update(**generate_kwargs)
1945
  gen_kwargs: dict[str, Any] = {
1946
  **self._lm_generate_inputs(input_ids, inputs_embeds),
1947
  "attention_mask": attention_mask,
1948
+ "generation_config": gen_cfg,
1949
  "streamer": streamer,
1950
  **generate_kwargs,
1951
  }
 
2038
  # Don't save embedding layers - the <audio> token embedding is never used
2039
  # (it's replaced with projected audio embeddings before the LLM sees it)
2040
  if hasattr(self.language_model, "peft_config"):
2041
+ # Override base_model_name_or_path in the saved adapter_config.json to
2042
+ # prevent HF pipeline from redirecting to the base LLM repo (like Qwen),
2043
+ # which breaks feature extractor loading for multimodal models. If a
2044
+ # repo_id is provided, use that so the model can be loaded directly
2045
+ # from the Hub. Use empty string instead of None: PeftModel.save_pretrained
2046
+ # fills the field with the base LLM's name only when it is None, and an
2047
+ # empty string also avoids the str(None) -> "None" bug in some
2048
+ # transformers/PEFT versions.
2049
+ repo_id = (
2050
+ kwargs.get("repo_id")
2051
+ or kwargs.get("push_to_hub_model_id")
2052
+ or getattr(self.config, "pretrained_model_path", None)
2053
+ or ""
2054
+ )
2055
+ peft_configs = list(self.language_model.peft_config.values())
2056
+ # Set on the live configs only for the save, then restore, so the
2057
+ # in-memory model keeps its real base model name.
2058
+ original_names = [cfg.base_model_name_or_path for cfg in peft_configs]
2059
+ try:
2060
+ for cfg in peft_configs:
2061
+ cfg.base_model_name_or_path = repo_id
2062
+ self.language_model.save_pretrained(save_dir, save_embedding_layers=False)
2063
+ finally:
2064
+ for cfg, name in zip(peft_configs, original_names, strict=True):
2065
+ cfg.base_model_name_or_path = name
2066
 
2067
  # Add processor auto_map to preprocessor_config.json
2068
  config_path = save_dir / "preprocessor_config.json"
asr_pipeline.py CHANGED
@@ -1,7 +1,7 @@
1
  """ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
2
 
3
  import re
4
- from collections.abc import Iterator, MutableMapping
5
  from pathlib import Path
6
  from typing import TYPE_CHECKING, Any, TypedDict, cast
7
 
@@ -16,86 +16,57 @@ if TYPE_CHECKING:
16
 
17
  from .alignment import QwenForcedAligner
18
  from .asr_modeling import ASRModel
19
- from .asr_processing import prepend_lead_in
20
- from .diarization import NemotronDiarizer, StreamChunk, masked_audio, pack_spans
 
 
 
 
 
 
 
21
  else:
22
  try:
23
  from .alignment import QwenForcedAligner
24
  from .asr_modeling import ASRModel
25
- from .asr_processing import prepend_lead_in
26
- from .diarization import NemotronDiarizer, StreamChunk, masked_audio, pack_spans
 
 
 
 
 
 
 
27
  except ImportError: # flat layout on the Hub: sibling modules, no package
28
  from alignment import QwenForcedAligner
29
  from asr_modeling import ASRModel
30
- from asr_processing import prepend_lead_in
31
- from diarization import NemotronDiarizer, StreamChunk, masked_audio, pack_spans
 
 
 
 
 
 
 
32
 
33
  # Re-export for backwards compatibility
34
  __all__ = [
35
  "ASRPipeline",
36
  "NemotronDiarizer",
 
37
  "QwenForcedAligner",
 
38
  ]
39
 
40
- # Audio is transcribed in chunks cut at the quietest point between
41
- # these lengths. The model trained on clips of at most 19 s; 18 leaves room for
42
- # the inference lead-in. Short clips are one chunk, so their text is unchanged.
43
- CHUNK_MAX_S = 18.0
44
- CHUNK_MIN_S = 8.0
45
-
46
-
47
- def chunk_bounds(
48
- audio: npt.NDArray[np.float32],
49
- sample_rate: int,
50
- max_s: float = CHUNK_MAX_S,
51
- min_s: float = CHUNK_MIN_S,
52
- ) -> list[tuple[int, int]]:
53
- """Sample ranges of at most `max_s`, each cut at the quietest 100 ms frame after `min_s`."""
54
- frame = int(0.1 * sample_rate)
55
- bounds: list[tuple[int, int]] = []
56
- start, n = 0, len(audio)
57
- while n - start > max_s * sample_rate:
58
- lo = start + int(min_s * sample_rate)
59
- hi = start + int(max_s * sample_rate)
60
- k = (hi - lo) // frame
61
- rms = np.sqrt(np.mean(np.square(audio[lo : lo + k * frame].reshape(k, frame)), axis=1))
62
- cut = lo + int(np.argmin(rms)) * frame + frame // 2
63
- bounds.append((start, cut))
64
- start = cut
65
- bounds.append((start, n))
66
- return bounds
67
-
68
-
69
- def stream_chunks(
70
- audio: npt.NDArray[np.float32],
71
- spans: list[tuple[int, int]],
72
- sample_rate: int,
73
- max_s: float = CHUNK_MAX_S,
74
- ) -> list[tuple[int, int]]:
75
- """Ranges of at most `max_s` over one speaker's `spans`; long turns cut by `chunk_bounds`."""
76
- pieces: list[tuple[int, int]] = []
77
- for s, e in spans:
78
- pieces.extend((s + a, s + b) for a, b in chunk_bounds(audio[s:e], sample_rate, max_s))
79
- return pack_spans(pieces, int(max_s * sample_rate))
80
-
81
-
82
- # Below this RMS (-100 dBFS) a chunk is digital silence: exact zeros, as in
83
- # edited or remixed recordings. Given one, the model answers with a memorized
84
- # training sentence ("The film was directed by the same director who directed
85
- # 'The Man with the Moustache'") -- ten such chunks cost 2.3 WER on one AMI
86
- # meeting -- so it is skipped. Quiet real speech sits near -60 dBFS.
87
- SILENCE_RMS = 1e-5
88
-
89
-
90
- def is_silent(audio: npt.NDArray[np.float32]) -> bool:
91
- """True for digital silence (or an empty array): nothing for the model to hear."""
92
- return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
93
-
94
 
95
  _THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
96
  _MIN_REPEATS = 3
 
97
  _TRAILING_CHAR_RE = re.compile(rf"(.)\1{{{_MIN_REPEATS - 1},}}$")
98
  _TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
 
99
 
100
 
101
  class _RawAudio(TypedDict):
@@ -128,6 +99,13 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
128
 
129
  model: ASRModel
130
 
 
 
 
 
 
 
 
131
  def __init__(self, model: ASRModel, **kwargs: Any) -> None:
132
  """Initialize ASR pipeline.
133
 
@@ -269,12 +247,17 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
269
  array, sr = audio["array"], audio["sampling_rate"]
270
 
271
  bounds = chunk_bounds(array, sr)
 
 
 
 
 
272
  if len(bounds) == 1:
273
  return self._transcribe_chunk(array, sr, **kwargs)
274
  result: dict[str, Any] = {}
275
  texts: list[str] = []
276
- for s, e in bounds:
277
- out = self._transcribe_chunk(array[s:e], sr, **kwargs)
278
  texts.append(out["text"])
279
  # Per-step logprobs (output_scores=True) run on across chunks.
280
  for key in ("top1_logprob", "top2_logprob"):
@@ -283,6 +266,61 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
283
  result["text"] = " ".join(t for t in texts if t)
284
  return result
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  def _transcribe_chunk(
287
  self, chunk: npt.NDArray[np.float32], sample_rate: int, **kwargs: Any
288
  ) -> dict[str, Any]:
@@ -305,7 +343,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
305
  ) -> dict[str, Any]:
306
  """Transcribe in chunks and time every word; per-speaker streams if requested.
307
 
308
- Chunks of 8-18 s cut at quiet points are transcribed one by one and each
309
  is aligned against its own transcript (batched), then offset onto the
310
  recording's timeline -- so audio of any length gets every word timed,
311
  and the model never sees a clip longer than it trained on. Speakers
@@ -325,9 +363,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
325
  ) -> dict[str, Any]:
326
  """`{"text", "words"}` for the whole recording: chunk, transcribe, align, offset."""
327
  bounds = chunk_bounds(array, sr)
328
- texts: list[str] = [
329
- self._transcribe_chunk(array[s:e], sr, **kwargs)["text"] for s, e in bounds
330
- ]
331
  result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
332
 
333
  try:
@@ -386,7 +422,7 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
386
  {"col": c, "start": s, "audio": masked_audio(array, spans, s, e)}
387
  for s, e in stream_chunks(array, spans, sr)
388
  )
389
- texts = [self._transcribe_chunk(ch["audio"], sr, **kwargs)["text"] for ch in chunks]
390
  result = self._align_streams(chunks, texts, sr, masks, keep)
391
  if result["words"]:
392
  result["words"] = NemotronDiarizer.dedupe_words(result["words"], activity, keep)
@@ -655,6 +691,11 @@ def _truncate_repetitions(text: str) -> str:
655
  - Repeated phrases: "i am sorry i am sorry i am sorry" -> "i am sorry"
656
  - Repeated characters: "444444" -> "4"
657
 
 
 
 
 
 
658
  Args:
659
  text: Input text to process
660
 
@@ -678,7 +719,7 @@ def _truncate_repetitions(text: str) -> str:
678
  # to be possible. set(window) == window means all unique → no repetition.
679
  window = words[-_MIN_REPEATS * 2 :]
680
  if len(set(window)) == len(window):
681
- return text
682
 
683
  for phrase_len in range(2, min(21, len(words) // _MIN_REPEATS + 1)):
684
  phrase_escaped = re.escape(" ".join(words[-phrase_len:]))
@@ -691,4 +732,20 @@ def _truncate_repetitions(text: str) -> str:
691
  text = (match.group(1) + match.group(2)).strip()
692
  break
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  return text
 
1
  """ASR pipeline for audio-to-text transcription with optional timestamps and diarization."""
2
 
3
  import re
4
+ from collections.abc import Callable, Iterator, MutableMapping, Sequence
5
  from pathlib import Path
6
  from typing import TYPE_CHECKING, Any, TypedDict, cast
7
 
 
16
 
17
  from .alignment import QwenForcedAligner
18
  from .asr_modeling import ASRModel
19
+ from .asr_processing import (
20
+ audible_chunks,
21
+ chunk_bounds,
22
+ collate_chunks,
23
+ is_silent,
24
+ prepend_lead_in,
25
+ )
26
+ from .asr_types import PreparedChunk
27
+ from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
28
  else:
29
  try:
30
  from .alignment import QwenForcedAligner
31
  from .asr_modeling import ASRModel
32
+ from .asr_processing import (
33
+ audible_chunks,
34
+ chunk_bounds,
35
+ collate_chunks,
36
+ is_silent,
37
+ prepend_lead_in,
38
+ )
39
+ from .asr_types import PreparedChunk
40
+ from .diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
41
  except ImportError: # flat layout on the Hub: sibling modules, no package
42
  from alignment import QwenForcedAligner
43
  from asr_modeling import ASRModel
44
+ from asr_processing import (
45
+ audible_chunks,
46
+ chunk_bounds,
47
+ collate_chunks,
48
+ is_silent,
49
+ prepend_lead_in,
50
+ )
51
+ from asr_types import PreparedChunk
52
+ from diarization import NemotronDiarizer, StreamChunk, masked_audio, stream_chunks
53
 
54
  # Re-export for backwards compatibility
55
  __all__ = [
56
  "ASRPipeline",
57
  "NemotronDiarizer",
58
+ "PreparedChunk",
59
  "QwenForcedAligner",
60
+ "collate_chunks",
61
  ]
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  _THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
65
  _MIN_REPEATS = 3
66
+ _LOOSE_MIN_REPEATS = 6
67
  _TRAILING_CHAR_RE = re.compile(rf"(.)\1{{{_MIN_REPEATS - 1},}}$")
68
  _TRAILING_WORD_RE = re.compile(rf"\b(\w+)(?:\s+\1){{{_MIN_REPEATS - 1},}}\s*$", re.IGNORECASE)
69
+ _NON_WORD_RE = re.compile(r"\W+")
70
 
71
 
72
  class _RawAudio(TypedDict):
 
99
 
100
  model: ASRModel
101
 
102
+ #: Chunks per batched `generate` call when this pipeline runs them itself.
103
+ max_batch_size: int = 16
104
+ #: Runs prepared chunks through the model, returning one text each. None
105
+ #: means `generate_prepared` in batches of `max_batch_size`; a server
106
+ #: swaps in a queue that batches chunks across concurrent requests.
107
+ chunk_runner: "Callable[[list[PreparedChunk]], list[str]] | None" = None
108
+
109
  def __init__(self, model: ASRModel, **kwargs: Any) -> None:
110
  """Initialize ASR pipeline.
111
 
 
247
  array, sr = audio["array"], audio["sampling_rate"]
248
 
249
  bounds = chunk_bounds(array, sr)
250
+ if not kwargs:
251
+ texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr)
252
+ return {"text": " ".join(t for t in texts if t)}
253
+ # Generate kwargs (e.g. output_scores for eval confidence) need a
254
+ # one-chunk batch: `_forward` refuses per-item scores for more.
255
  if len(bounds) == 1:
256
  return self._transcribe_chunk(array, sr, **kwargs)
257
  result: dict[str, Any] = {}
258
  texts: list[str] = []
259
+ for chunk in audible_chunks(array, bounds, sr):
260
+ out = self._transcribe_chunk(chunk, sr, **kwargs)
261
  texts.append(out["text"])
262
  # Per-step logprobs (output_scores=True) run on across chunks.
263
  for key in ("top1_logprob", "top2_logprob"):
 
266
  result["text"] = " ".join(t for t in texts if t)
267
  return result
268
 
269
+ def _transcribe_chunks(
270
+ self, chunks: Sequence[npt.NDArray[np.float32]], sample_rate: int, **kwargs: Any
271
+ ) -> list[str]:
272
+ """One text per chunk, batched; digital silence is "" without the model.
273
+
274
+ All of a request's chunks go to the model together (`chunk_runner`)
275
+ rather than one call each, so a long recording, or diarization's
276
+ per-speaker streams, costs a few batched `generate` calls instead of
277
+ one per 18 s. Generate kwargs fall back to one call per chunk.
278
+ """
279
+ texts = [""] * len(chunks)
280
+ todo = [i for i, chunk in enumerate(chunks) if not is_silent(chunk)]
281
+ if kwargs:
282
+ for i in todo:
283
+ texts[i] = self._transcribe_chunk(chunks[i], sample_rate, **kwargs)["text"]
284
+ return texts
285
+ prepared = [self.prepare_chunk(chunks[i], sample_rate) for i in todo]
286
+ run = self.chunk_runner or self._generate_in_batches
287
+ for i, text in zip(todo, run(prepared) if prepared else [], strict=True):
288
+ texts[i] = text
289
+ return texts
290
+
291
+ def prepare_chunk(self, chunk: npt.NDArray[np.float32], sample_rate: int) -> PreparedChunk:
292
+ """A chunk's encoder inputs on CPU: lead-in, then the feature extractor.
293
+
294
+ The same `preprocess` a one-chunk call runs, so batched and one-at-a-time
295
+ decoding see identical features.
296
+ """
297
+ item = next(self.preprocess({"raw": chunk, "sampling_rate": sample_rate}))
298
+ return {"input_features": item["input_features"], "attention_mask": item["attention_mask"]}
299
+
300
+ def _generate_in_batches(self, prepared: list[PreparedChunk]) -> list[str]:
301
+ """`generate_prepared` over `prepared` in batches of `max_batch_size`."""
302
+ size = max(1, self.max_batch_size)
303
+ batches = (prepared[start : start + size] for start in range(0, len(prepared), size))
304
+ return [text for batch in batches for text in self.generate_prepared(batch)]
305
+
306
+ @torch.inference_mode()
307
+ def generate_prepared(
308
+ self, prepared: Sequence[PreparedChunk], **generate_kwargs: Any
309
+ ) -> list[str]:
310
+ """One batched `generate` (kwargs passed on) over prepared chunks; one text each.
311
+
312
+ Rows are left-padded prompts sized to each chunk's own audio tokens, so a
313
+ ragged batch decodes like its rows alone, up to padding numerics.
314
+ """
315
+ batch = collate_chunks(prepared)
316
+ tokens = self.model.generate(
317
+ input_features=batch["input_features"].to(self.model.device),
318
+ audio_attention_mask=batch["attention_mask"].to(self.model.device),
319
+ **generate_kwargs,
320
+ )
321
+ assert torch.is_tensor(tokens) # no output_scores: plain token ids
322
+ return [self.postprocess({"tokens": row})["text"] for row in tokens.cpu()]
323
+
324
  def _transcribe_chunk(
325
  self, chunk: npt.NDArray[np.float32], sample_rate: int, **kwargs: Any
326
  ) -> dict[str, Any]:
 
343
  ) -> dict[str, Any]:
344
  """Transcribe in chunks and time every word; per-speaker streams if requested.
345
 
346
+ Chunks of 8-18 s cut at quiet points are transcribed in batches and each
347
  is aligned against its own transcript (batched), then offset onto the
348
  recording's timeline -- so audio of any length gets every word timed,
349
  and the model never sees a clip longer than it trained on. Speakers
 
363
  ) -> dict[str, Any]:
364
  """`{"text", "words"}` for the whole recording: chunk, transcribe, align, offset."""
365
  bounds = chunk_bounds(array, sr)
366
+ texts = self._transcribe_chunks(audible_chunks(array, bounds, sr), sr, **kwargs)
 
 
367
  result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
368
 
369
  try:
 
422
  {"col": c, "start": s, "audio": masked_audio(array, spans, s, e)}
423
  for s, e in stream_chunks(array, spans, sr)
424
  )
425
+ texts = self._transcribe_chunks([ch["audio"] for ch in chunks], sr, **kwargs)
426
  result = self._align_streams(chunks, texts, sr, masks, keep)
427
  if result["words"]:
428
  result["words"] = NemotronDiarizer.dedupe_words(result["words"], activity, keep)
 
691
  - Repeated phrases: "i am sorry i am sorry i am sorry" -> "i am sorry"
692
  - Repeated characters: "444444" -> "4"
693
 
694
+ Words match ignoring case. A run of `_LOOSE_MIN_REPEATS` also matches
695
+ ignoring punctuation, since the model punctuates inside a loop ("Yeah Yeah
696
+ Yeah. Yeah. Yeah,"); shorter punctuated runs are real speech ("No, no,
697
+ no,"), and collapsing those cost 40 errors over 47 rows of the served eval.
698
+
699
  Args:
700
  text: Input text to process
701
 
 
719
  # to be possible. set(window) == window means all unique → no repetition.
720
  window = words[-_MIN_REPEATS * 2 :]
721
  if len(set(window)) == len(window):
722
+ return _truncate_punctuated_loop(text)
723
 
724
  for phrase_len in range(2, min(21, len(words) // _MIN_REPEATS + 1)):
725
  phrase_escaped = re.escape(" ".join(words[-phrase_len:]))
 
732
  text = (match.group(1) + match.group(2)).strip()
733
  break
734
 
735
+ return _truncate_punctuated_loop(text)
736
+
737
+
738
+ def _truncate_punctuated_loop(text: str) -> str:
739
+ """Collapse a trailing 1-20 word phrase run `_LOOSE_MIN_REPEATS`+ times, punctuation ignored."""
740
+ words = text.split()
741
+ keys = [_NON_WORD_RE.sub("", w).lower() for w in words]
742
+ end = len(words)
743
+ # Shortest phrase first, so "Yeah Yeah. Yeah," stops at "Yeah".
744
+ for n in range(1, min(21, end // _LOOSE_MIN_REPEATS + 1)):
745
+ phrase = keys[end - n : end]
746
+ reps = 1
747
+ while end - (reps + 1) * n >= 0 and keys[end - (reps + 1) * n : end - reps * n] == phrase:
748
+ reps += 1
749
+ if reps >= _LOOSE_MIN_REPEATS:
750
+ return " ".join(words[: end - (reps - 1) * n])
751
  return text
asr_processing.py CHANGED
@@ -1,7 +1,7 @@
1
  """Processor that turns raw audio (and optional text) into model inputs."""
2
 
3
  from collections.abc import Mapping, Sequence
4
- from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast, overload
5
 
6
  import numpy as np
7
  import numpy.typing as npt
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
22
  ConvLayerSpec,
23
  compute_encoder_output_length,
24
  )
25
- from .asr_types import AudioFeatureExtractor, AudioInput, Waveform
26
  from .projectors import MLPAudioProjector
27
  else:
28
  try:
@@ -32,7 +32,7 @@ else:
32
  ConvLayerSpec,
33
  compute_encoder_output_length,
34
  )
35
- from .asr_types import AudioInput
36
  except ImportError: # flat layout on the Hub: sibling modules, no package
37
  from asr_config import (
38
  DEFAULT_ENCODER_CONV_LAYERS,
@@ -40,7 +40,29 @@ else:
40
  ConvLayerSpec,
41
  compute_encoder_output_length,
42
  )
43
- from asr_types import AudioInput
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  # The instruction the model trained on (scripts/train_collator.py); the model
@@ -111,15 +133,12 @@ def left_pad_prompt_rows(
111
  return input_ids, attention_mask
112
 
113
 
114
- ScalarT = TypeVar("ScalarT", bound=np.generic)
115
-
116
-
117
  @overload
118
- def prepend_lead_in(
119
  audio: npt.NDArray[ScalarT], sampling_rate: int, seconds: float | None
120
  ) -> npt.NDArray[ScalarT]: ...
121
  @overload
122
- def prepend_lead_in(
123
  audio: list[npt.NDArray[ScalarT]], sampling_rate: int, seconds: float | None
124
  ) -> list[npt.NDArray[ScalarT]]: ...
125
  @overload
@@ -156,6 +175,81 @@ def prepend_lead_in(audio: AudioInput, sampling_rate: int, seconds: float | None
156
  return padded
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  class ASRProcessor(ProcessorMixin):
160
  """Processor for Whisper-based ASR models."""
161
 
 
1
  """Processor that turns raw audio (and optional text) into model inputs."""
2
 
3
  from collections.abc import Mapping, Sequence
4
+ from typing import TYPE_CHECKING, Any, ClassVar, cast, overload
5
 
6
  import numpy as np
7
  import numpy.typing as npt
 
22
  ConvLayerSpec,
23
  compute_encoder_output_length,
24
  )
25
+ from .asr_types import AudioFeatureExtractor, AudioInput, PreparedChunk, Waveform
26
  from .projectors import MLPAudioProjector
27
  else:
28
  try:
 
32
  ConvLayerSpec,
33
  compute_encoder_output_length,
34
  )
35
+ from .asr_types import AudioInput, PreparedChunk
36
  except ImportError: # flat layout on the Hub: sibling modules, no package
37
  from asr_config import (
38
  DEFAULT_ENCODER_CONV_LAYERS,
 
40
  ConvLayerSpec,
41
  compute_encoder_output_length,
42
  )
43
+ from asr_types import AudioInput, PreparedChunk
44
+
45
+
46
+ def collate_chunks(prepared: Sequence[PreparedChunk]) -> PreparedChunk:
47
+ """Pad prepared chunks to the longest and stack them into one batch.
48
+
49
+ The time axis is whichever feature axis matches the mask's length (Granite's
50
+ features are `(1, T, D)`, Whisper's `(1, D, T)`); padded frames are zeros
51
+ with a 0 in the mask, which the encoder honours (`encoder_attention_mask`).
52
+ """
53
+ longest = max(int(p["attention_mask"].shape[-1]) for p in prepared)
54
+ features: list[torch.Tensor] = []
55
+ masks: list[torch.Tensor] = []
56
+ for p in prepared:
57
+ feats, mask = p["input_features"], p["attention_mask"]
58
+ length = int(mask.shape[-1])
59
+ time_axis = 1 if feats.shape[1] == length else feats.dim() - 1
60
+ pad = longest - length
61
+ # F.pad lists (left, right) pairs from the LAST axis backwards.
62
+ spec = [0, 0] * (feats.dim() - 1 - time_axis) + [0, pad]
63
+ features.append(torch.nn.functional.pad(feats, spec))
64
+ masks.append(torch.nn.functional.pad(mask, (0, pad)))
65
+ return {"input_features": torch.cat(features), "attention_mask": torch.cat(masks)}
66
 
67
 
68
  # The instruction the model trained on (scripts/train_collator.py); the model
 
133
  return input_ids, attention_mask
134
 
135
 
 
 
 
136
  @overload
137
+ def prepend_lead_in[ScalarT: np.generic](
138
  audio: npt.NDArray[ScalarT], sampling_rate: int, seconds: float | None
139
  ) -> npt.NDArray[ScalarT]: ...
140
  @overload
141
+ def prepend_lead_in[ScalarT: np.generic](
142
  audio: list[npt.NDArray[ScalarT]], sampling_rate: int, seconds: float | None
143
  ) -> list[npt.NDArray[ScalarT]]: ...
144
  @overload
 
175
  return padded
176
 
177
 
178
+ # Audio is transcribed in chunks cut at the quietest point between
179
+ # these lengths. The model trained on clips of at most 19 s; 18 leaves room for
180
+ # the inference lead-in. Short clips are one chunk, so their text is unchanged.
181
+ CHUNK_MAX_S = 18.0
182
+ CHUNK_MIN_S = 8.0
183
+
184
+
185
+ def chunk_bounds(
186
+ audio: npt.NDArray[np.float32],
187
+ sample_rate: int,
188
+ max_s: float = CHUNK_MAX_S,
189
+ min_s: float = CHUNK_MIN_S,
190
+ ) -> list[tuple[int, int]]:
191
+ """Sample ranges of at most `max_s`, each cut at the quietest 100 ms frame after `min_s`."""
192
+ frame = int(0.1 * sample_rate)
193
+ bounds: list[tuple[int, int]] = []
194
+ start, n = 0, len(audio)
195
+ while n - start > max_s * sample_rate:
196
+ lo = start + int(min_s * sample_rate)
197
+ hi = start + int(max_s * sample_rate)
198
+ cut = lo + int(np.argmin(_frame_rms(audio[lo:hi], frame))) * frame + frame // 2
199
+ bounds.append((start, cut))
200
+ start = cut
201
+ bounds.append((start, n))
202
+ return bounds
203
+
204
+
205
+ def _frame_rms(audio: npt.NDArray[np.float32], frame: int) -> npt.NDArray[np.float32]:
206
+ """RMS of each whole `frame`-sample frame of `audio` (a trailing partial frame is dropped)."""
207
+ k = len(audio) // frame
208
+ return np.sqrt(np.mean(np.square(audio[: k * frame].reshape(k, frame)), axis=1))
209
+
210
+
211
+ # A chunk whose loudest 100 ms frame is this far below the recording's speech
212
+ # level (its 95th-percentile frame) holds no speech, only the room tone after
213
+ # the talker stopped. Decoded, such a tail comes back as a memorized sentence
214
+ # ("The film was directed by the director of the same name.", 0.7 WER on
215
+ # CommonVoice) or a stray "the"/"ok". On the cached eval clips over 18 s every
216
+ # noise-only chunk sat at -36 dB or below and every chunk with speech at
217
+ # -17.5 dB or above; -30 keeps the wider margin on the speech side.
218
+ QUIET_CHUNK_DB = -30.0
219
+
220
+
221
+ def audible_chunks(
222
+ audio: npt.NDArray[np.float32], bounds: list[tuple[int, int]], sample_rate: int
223
+ ) -> list[npt.NDArray[np.float32]]:
224
+ """The chunks of `audio` at `bounds`, those quieter than `QUIET_CHUNK_DB` emptied.
225
+
226
+ An empty chunk `is_silent`, so it transcribes as "" without the model. A
227
+ one-chunk recording is never emptied: its loudest frame is its own level.
228
+ """
229
+ chunks = [audio[s:e] for s, e in bounds]
230
+ if len(chunks) < 2:
231
+ return chunks
232
+ frame = int(0.1 * sample_rate)
233
+ floor = np.percentile(_frame_rms(audio, frame), 95) * 10 ** (QUIET_CHUNK_DB / 20)
234
+ return [
235
+ chunk if len(chunk) >= frame and _frame_rms(chunk, frame).max() >= floor else chunk[:0]
236
+ for chunk in chunks
237
+ ]
238
+
239
+
240
+ # Below this RMS (-100 dBFS) a chunk is digital silence: exact zeros, as in
241
+ # edited or remixed recordings. Given one, the model answers with a memorized
242
+ # training sentence ("The film was directed by the same director who directed
243
+ # 'The Man with the Moustache'") -- ten such chunks cost 2.3 WER on one AMI
244
+ # meeting -- so it is skipped. Quiet real speech sits near -60 dBFS.
245
+ SILENCE_RMS = 1e-5
246
+
247
+
248
+ def is_silent(audio: npt.NDArray[np.float32]) -> bool:
249
+ """True for digital silence (or an empty array): nothing for the model to hear."""
250
+ return audio.size == 0 or float(np.sqrt(np.mean(np.square(audio)))) < SILENCE_RMS
251
+
252
+
253
  class ASRProcessor(ProcessorMixin):
254
  """Processor for Whisper-based ASR models."""
255
 
asr_types.py CHANGED
@@ -21,6 +21,14 @@ AudioInput = Waveform | Sequence[Waveform]
21
  # A `state_dict(destination=...)` mapping, returned as the caller's own type.
22
  StateDictT = TypeVar("StateDictT", bound=dict[str, Any])
23
 
 
 
 
 
 
 
 
 
24
  if TYPE_CHECKING:
25
 
26
  class GenerativeDecoder(PreTrainedModel, GenerationMixin):
 
21
  # A `state_dict(destination=...)` mapping, returned as the caller's own type.
22
  StateDictT = TypeVar("StateDictT", bound=dict[str, Any])
23
 
24
+
25
+ class PreparedChunk(TypedDict):
26
+ """One chunk's encoder inputs, as `ASRPipeline.prepare_chunk` builds them (batch of 1)."""
27
+
28
+ input_features: torch.Tensor
29
+ attention_mask: torch.Tensor
30
+
31
+
32
  if TYPE_CHECKING:
33
 
34
  class GenerativeDecoder(PreTrainedModel, GenerationMixin):
diarization.py CHANGED
@@ -18,11 +18,14 @@ if TYPE_CHECKING:
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):
@@ -83,6 +86,19 @@ def pack_spans(spans: list[tuple[int, int]], limit: int) -> list[tuple[int, int]
83
  return ranges
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  class StreamChunk(TypedDict):
87
  """One stretch of one speaker's masked audio: kept column, absolute start, samples."""
88
 
 
18
  )
19
 
20
  from .alignment import AlignedWord, get_device, to_16k
21
+ from .asr_processing import CHUNK_MAX_S, chunk_bounds
22
  else:
23
  try:
24
  from .alignment import get_device, to_16k
25
+ from .asr_processing import CHUNK_MAX_S, chunk_bounds
26
  except ImportError: # flat layout on the Hub: sibling modules, no package
27
  from alignment import get_device, to_16k
28
+ from asr_processing import CHUNK_MAX_S, chunk_bounds
29
 
30
 
31
  class SpeakerSegment(TypedDict):
 
86
  return ranges
87
 
88
 
89
+ def stream_chunks(
90
+ audio: npt.NDArray[np.float32],
91
+ spans: list[tuple[int, int]],
92
+ sample_rate: int,
93
+ max_s: float = CHUNK_MAX_S,
94
+ ) -> list[tuple[int, int]]:
95
+ """Ranges of at most `max_s` over one speaker's `spans`; long turns cut by `chunk_bounds`."""
96
+ pieces: list[tuple[int, int]] = []
97
+ for s, e in spans:
98
+ pieces.extend((s + a, s + b) for a, b in chunk_bounds(audio[s:e], sample_rate, max_s))
99
+ return pack_spans(pieces, int(max_s * sample_rate))
100
+
101
+
102
  class StreamChunk(TypedDict):
103
  """One stretch of one speaker's masked audio: kept column, absolute start, samples."""
104
 
requirements.txt CHANGED
@@ -5,19 +5,6 @@
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
- # The Inference Endpoints image ships an older torch, torchvision and torchaudio.
9
- # Pin all three as one matching set (torch 2.8, as in poetry.lock): a bare
10
- # "torch" keeps the image's version, which this transformers rejects (< 2.5),
11
- # and upgrading torch alone leaves the image's torchvision built for the old
12
- # one, so transformers' torchvision import fails ("operator torchvision::nms
13
- # does not exist", surfacing as "Could not import module
14
- # 'WhisperForConditionalGeneration'").
15
- torch==2.8.0
16
- torchvision==0.23.0
17
- torchaudio==2.8.0
18
  peft
19
- safetensors
20
- numpy
21
- # Fast path for Qwen3.5's gated-delta-rule layers (transformers falls back to a
22
- # slower reference implementation without it). Pure Triton, nothing to compile.
23
- flash-linear-attention>=0.2.2
 
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