mazesmazes commited on
Commit
cf1aff8
·
verified ·
1 Parent(s): 3adc10a

Update custom model files, README, and requirements

Browse files
Files changed (7) hide show
  1. .gitattributes +2 -35
  2. README.md +10 -11
  3. alignment.py +110 -1
  4. asr_pipeline.py +92 -58
  5. diarization.py +123 -1
  6. handler.py +85 -0
  7. requirements.txt +10 -0
.gitattributes CHANGED
@@ -1,36 +1,3 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- tokenizer.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.safetensors filter=lfs diff=lfs merge=lfs -text
2
+ *.bin filter=lfs diff=lfs merge=lfs -text
3
+ tokenizer_config.json -filter -diff -merge text
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -75,52 +75,51 @@ for r in results:
75
 
76
  ### Word-Level Timestamps
77
 
78
- Timestamps come from forced alignment of the transcript against the audio and are returned under a `words` key:
79
 
80
  ```python
81
  result = pipe("audio.wav", return_timestamps=True)
82
  print(result["text"])
83
  for word in result["words"]:
84
  print(word)
 
85
  ```
86
 
87
  ### Speaker Diarization
88
 
89
- `return_speakers=True` adds speaker labels. It first transcribes and aligns words (it implies `return_timestamps=True`). It then splits the audio into speech regions with [TEN VAD](https://github.com/TEN-framework/ten-vad), embeds each region with [ECAPA-TDNN](https://huggingface.co/speechbrain/spkrec-ecapa-voxceleb), and clusters the embeddings into speakers. Each word is assigned to the speaker whose segment it overlaps.
90
 
91
- Diarization needs a few extra packages:
92
 
93
  ```bash
94
- pip install speechbrain ten-vad scikit-learn scipy
95
  ```
96
 
97
  ```python
98
- result = pipe("meeting.wav", return_speakers=True, num_speakers=2)
99
 
100
  print(result["text"])
101
 
102
- # Speaker turns
103
  for seg in result["speaker_segments"]:
104
  print(f"{seg['start']:6.2f}-{seg['end']:6.2f} {seg['speaker']}")
105
  # 0.00- 2.90 SPEAKER_0
106
  # 3.36- 6.47 SPEAKER_1
107
- # 6.93- 9.81 SPEAKER_0
108
 
109
  # Words with timestamps and speakers
110
  for w in result["words"]:
111
  print(f"{w['start']:6.2f}-{w['end']:6.2f} {w['speaker']} {w['word']}")
112
  # 0.00- 0.19 SPEAKER_0 Hi,
113
  # 0.22- 0.69 SPEAKER_0 Daniel.
114
- # ...
115
  ```
116
 
117
- **Setting the speaker count.** Pass `num_speakers` when you know it. Otherwise the number of speakers is estimated from the audio, and on short clips that estimate tends to split one voice into several speakers. You can also bound the estimate with `min_speakers` / `max_speakers`:
118
 
119
  ```python
120
- result = pipe("panel.wav", return_speakers=True, min_speakers=2, max_speakers=4)
121
  ```
122
 
123
- If diarization fails (for example, a missing package), transcription still succeeds. The error is returned under `result["diarization_error"]` instead of raising.
124
 
125
  ### GPU Inference
126
 
 
75
 
76
  ### Word-Level Timestamps
77
 
78
+ `return_timestamps=True` times every word with [Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf) and returns them under a `words` key. Audio of any length works: the pipeline transcribes it in 8-18 s chunks cut at pauses (the model trained on clips up to 19 s), aligns each chunk against its own transcript in batches, and places every word on the recording's timeline.
79
 
80
  ```python
81
  result = pipe("audio.wav", return_timestamps=True)
82
  print(result["text"])
83
  for word in result["words"]:
84
  print(word)
85
+ # {'word': 'Hi,', 'start': 0.0, 'end': 0.24}
86
  ```
87
 
88
  ### Speaker Diarization
89
 
90
+ `return_speakers=True` labels every word with a speaker (it implies `return_timestamps=True`). Speakers come from [Nemotron-3-Diarization](https://huggingface.co/nvidia/Nemotron-3-Diarization), run once over the whole recording, so labels stay consistent across hour-long meetings; it handles overlapping speech and up to 8 speakers. Each word goes to the speaker most active while it was spoken.
91
 
92
+ Nemotron-3-Diarization is in transformers `main`, not yet a release:
93
 
94
  ```bash
95
+ pip install git+https://github.com/huggingface/transformers
96
  ```
97
 
98
  ```python
99
+ result = pipe("meeting.wav", return_speakers=True)
100
 
101
  print(result["text"])
102
 
103
+ # Speaker turns (may overlap)
104
  for seg in result["speaker_segments"]:
105
  print(f"{seg['start']:6.2f}-{seg['end']:6.2f} {seg['speaker']}")
106
  # 0.00- 2.90 SPEAKER_0
107
  # 3.36- 6.47 SPEAKER_1
 
108
 
109
  # Words with timestamps and speakers
110
  for w in result["words"]:
111
  print(f"{w['start']:6.2f}-{w['end']:6.2f} {w['speaker']} {w['word']}")
112
  # 0.00- 0.19 SPEAKER_0 Hi,
113
  # 0.22- 0.69 SPEAKER_0 Daniel.
 
114
  ```
115
 
116
+ Speakers are numbered by when they first speak. The number of speakers is detected automatically. If you know it, pass `num_speakers` (exact) or `max_speakers` (an upper bound); any extra voices' words go to the closest kept speaker:
117
 
118
  ```python
119
+ result = pipe("call.wav", return_speakers=True, num_speakers=2)
120
  ```
121
 
122
+ If alignment or diarization fails (for example, transformers without Nemotron), the transcript is still returned and the error is reported under `result["timestamp_error"]` or `result["diarization_error"]`.
123
 
124
  ### GPU Inference
125
 
alignment.py CHANGED
@@ -1,4 +1,4 @@
1
- """Forced alignment for word-level timestamps using Wav2Vec2."""
2
 
3
  import numpy as np
4
  import torch
@@ -240,3 +240,112 @@ class ForcedAligner:
240
  )
241
 
242
  return word_timestamps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forced alignment for word-level timestamps: Qwen3-ForcedAligner, wav2vec2 as fallback."""
2
 
3
  import numpy as np
4
  import torch
 
240
  )
241
 
242
  return word_timestamps
243
+
244
+
245
+ class QwenForcedAligner:
246
+ """Word timestamps from Qwen3-ForcedAligner-0.6B (transformers >= 5.17).
247
+
248
+ Trained for timestamping rather than repurposed from CTC ASR, it aligns
249
+ cased, punctuated text with numbers directly. One forward pass covers up to
250
+ 5 minutes of speech, so long recordings are aligned as chunks, each against
251
+ its own transcript (`align_chunks`), batched.
252
+
253
+ Returns `[{"word", "start", "end"}]` for every word it can align, in order,
254
+ with the ORIGINAL word (punctuation, casing) as "word". The aligner itself
255
+ sees words with punctuation stripped and drops any that strip to nothing
256
+ ("--"); the split is mirrored word by word so the pairing stays exact.
257
+ """
258
+
259
+ MODEL_ID = "Qwen/Qwen3-ForcedAligner-0.6B-hf"
260
+ MAX_SECONDS = 300.0
261
+ BATCH_SIZE = 8
262
+ _model = None
263
+ _processor = None
264
+
265
+ @classmethod
266
+ def get_instance(cls):
267
+ if cls._model is None:
268
+ from transformers import AutoProcessor, Qwen3ASRForTokenClassification
269
+
270
+ device = _get_device()
271
+ # Batches are padded, and Metal sdpa returns NaN for fully masked rows.
272
+ attn = "eager" if device == "mps" else "sdpa"
273
+ model = Qwen3ASRForTokenClassification.from_pretrained(
274
+ cls.MODEL_ID, dtype=torch.bfloat16, attn_implementation=attn
275
+ )
276
+ cls._model = model.to(device).eval()
277
+ cls._processor = AutoProcessor.from_pretrained(cls.MODEL_ID)
278
+ return cls._model, cls._processor
279
+
280
+ @staticmethod
281
+ def _to_16k(audio, sample_rate: int) -> np.ndarray:
282
+ if isinstance(audio, torch.Tensor):
283
+ audio = audio.cpu().numpy()
284
+ audio = np.asarray(audio, dtype=np.float32).reshape(-1)
285
+ if sample_rate != 16000:
286
+ import torchaudio
287
+
288
+ audio = torchaudio.functional.resample(
289
+ torch.from_numpy(audio), sample_rate, 16000
290
+ ).numpy()
291
+ return audio
292
+
293
+ @classmethod
294
+ @torch.inference_mode()
295
+ def align_chunks(
296
+ cls,
297
+ chunks: list[tuple[np.ndarray, str]],
298
+ sample_rate: int = 16000,
299
+ language: str = "English",
300
+ ) -> list[list[dict]]:
301
+ """Align each `(audio, text)` pair (each <= 5 min); one word list per pair."""
302
+ from transformers.models.qwen3_asr.processing_qwen3_asr import _clean_tokens
303
+
304
+ results: list[list[dict]] = [[] for _ in chunks]
305
+ todo = []
306
+ for i, (raw, text) in enumerate(chunks):
307
+ kept = [w for w in text.split() if _clean_tokens([w])]
308
+ if not kept:
309
+ continue
310
+ audio = cls._to_16k(raw, sample_rate)
311
+ if len(audio) / 16000 > cls.MAX_SECONDS:
312
+ raise ValueError(
313
+ f"chunk {i} is {len(audio) / 16000:.0f} s; Qwen3-ForcedAligner takes at most "
314
+ f"{cls.MAX_SECONDS:.0f} s -- cut the audio first"
315
+ )
316
+ todo.append((i, audio, kept))
317
+ if not todo: # nothing to time: don't load the model
318
+ return results
319
+ model, processor = cls.get_instance()
320
+ for b in range(0, len(todo), cls.BATCH_SIZE):
321
+ batch = todo[b : b + cls.BATCH_SIZE]
322
+ inputs, word_lists = processor.prepare_forced_aligner_inputs(
323
+ [audio for _, audio, _ in batch],
324
+ [" ".join(kept) for _, _, kept in batch],
325
+ language=language,
326
+ return_tensors="pt",
327
+ processor_kwargs={"padding": True},
328
+ )
329
+ inputs = {
330
+ k: (
331
+ v.to(model.device, dtype=model.dtype)
332
+ if v.is_floating_point()
333
+ else v.to(model.device)
334
+ )
335
+ for k, v in inputs.items()
336
+ }
337
+ logits = model(**inputs).logits
338
+ timed = processor.decode_forced_alignment(
339
+ logits, inputs["input_ids"], word_lists, model.config.timestamp_token_id
340
+ )
341
+ for (i, _, kept), words in zip(batch, timed, strict=True):
342
+ results[i] = [
343
+ {"word": w, "start": float(t["start_time"]), "end": float(t["end_time"])}
344
+ for w, t in zip(kept, words, strict=True)
345
+ ]
346
+ return results
347
+
348
+ @classmethod
349
+ def align(cls, audio, text: str, sample_rate: int = 16000, language: str = "English"):
350
+ """ForcedAligner.align's interface for one clip of at most 5 minutes."""
351
+ return cls.align_chunks([(audio, text)], sample_rate, language)[0]
asr_pipeline.py CHANGED
@@ -10,18 +10,49 @@ import transformers
10
  from transformers.pipelines.audio_utils import ffmpeg_read
11
 
12
  try:
13
- from .alignment import ForcedAligner
14
  from .asr_modeling import ASRModel
15
  from .asr_processing import prepend_lead_in
16
- from .diarization import SpeakerDiarizer
17
  except ImportError:
18
- from alignment import ForcedAligner # type: ignore[no-redef]
19
  from asr_modeling import ASRModel # type: ignore[no-redef]
20
  from asr_processing import prepend_lead_in # type: ignore[no-redef]
21
- from diarization import SpeakerDiarizer # type: ignore[no-redef]
22
 
23
  # Re-export for backwards compatibility
24
- __all__ = ["ASRPipeline", "ForcedAligner", "SpeakerDiarizer"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  _THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
27
  _MIN_REPEATS = 3
@@ -73,25 +104,28 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
73
 
74
  Args:
75
  inputs: Audio input (file path, dict with array/sampling_rate, etc.)
76
- return_timestamps: If True, return word-level timestamps using forced alignment
77
- return_speakers: If True, return speaker labels for each word
78
  user_prompt: Custom transcription prompt (default: "Transcribe: ")
79
- num_speakers: Exact number of speakers (if known, for diarization)
80
- min_speakers: Minimum number of speakers (for diarization)
81
- max_speakers: Maximum number of speakers (for diarization)
82
  **kwargs: Additional arguments passed to the pipeline
83
 
84
  Returns:
85
  Dict with 'text' key, 'words' key if return_timestamps=True,
86
- and speaker labels on words if return_speakers=True
87
  """
88
  # Extract our params before super().__call__ (which will also call _sanitize_parameters)
89
  return_timestamps = kwargs.pop("return_timestamps", False)
90
  return_speakers = kwargs.pop("return_speakers", False)
91
  user_prompt = kwargs.pop("user_prompt", None)
 
 
 
 
 
92
  diarization_params = {
93
  "num_speakers": kwargs.pop("num_speakers", None),
94
- "min_speakers": kwargs.pop("min_speakers", None),
95
  "max_speakers": kwargs.pop("max_speakers", None),
96
  }
97
 
@@ -109,9 +143,10 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
109
  self.model.TRANSCRIBE_PROMPT = user_prompt
110
 
111
  try:
112
- return self._transcribe(
 
 
113
  inputs,
114
- return_timestamps=return_timestamps,
115
  return_speakers=return_speakers,
116
  diarization_params=diarization_params,
117
  **kwargs,
@@ -120,60 +155,59 @@ class ASRPipeline(transformers.AutomaticSpeechRecognitionPipeline):
120
  if original_prompt is not None:
121
  self.model.TRANSCRIBE_PROMPT = original_prompt
122
 
123
- def _transcribe(
124
  self,
125
  inputs,
126
  *,
127
- return_timestamps: bool,
128
  return_speakers: bool,
129
  diarization_params: dict,
130
  **kwargs,
131
  ):
132
- """Transcribe, then attach timestamps and speakers if requested."""
133
- # Decode once for timestamp alignment and diarization, then hand the
134
- # decoded array to the parent so it doesn't run ffmpeg over the same
135
- # input a second time.
136
- current_audio = (
137
- self._extract_audio(inputs) if (return_timestamps or return_speakers) else None
138
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- # Run standard transcription
141
- result = super().__call__(current_audio if current_audio is not None else inputs, **kwargs)
142
-
143
- # Add timestamps if requested
144
- if return_timestamps and current_audio is not None:
145
- text = result.get("text", "")
146
- if text:
147
- try:
148
- words = ForcedAligner.align(
149
- current_audio["array"],
150
- text,
151
- sample_rate=current_audio.get("sampling_rate", 16000),
152
- )
153
- result["words"] = words
154
- except Exception as e:
155
- result["words"] = []
156
- result["timestamp_error"] = str(e)
157
- else:
158
- result["words"] = []
159
-
160
- # Add speaker diarization if requested
161
- if return_speakers and current_audio is not None:
162
  try:
163
- # Run diarization
164
- speaker_segments = SpeakerDiarizer.diarize(
165
- current_audio["array"],
166
- sample_rate=current_audio.get("sampling_rate", 16000),
167
- **{k: v for k, v in diarization_params.items() if v is not None},
168
  )
169
- result["speaker_segments"] = speaker_segments
170
-
171
- # Assign speakers to words
172
- if result.get("words"):
173
- result["words"] = SpeakerDiarizer.assign_speakers_to_words(
174
- result["words"],
175
- speaker_segments,
176
- )
177
  except Exception as e:
178
  result["speaker_segments"] = []
179
  result["diarization_error"] = str(e)
 
10
  from transformers.pipelines.audio_utils import ffmpeg_read
11
 
12
  try:
13
+ from .alignment import ForcedAligner, QwenForcedAligner
14
  from .asr_modeling import ASRModel
15
  from .asr_processing import prepend_lead_in
16
+ from .diarization import NemotronDiarizer, SpeakerDiarizer
17
  except ImportError:
18
+ from alignment import ForcedAligner, QwenForcedAligner # type: ignore[no-redef]
19
  from asr_modeling import ASRModel # type: ignore[no-redef]
20
  from asr_processing import prepend_lead_in # type: ignore[no-redef]
21
+ from diarization import NemotronDiarizer, SpeakerDiarizer # type: ignore[no-redef]
22
 
23
  # Re-export for backwards compatibility
24
+ __all__ = [
25
+ "ASRPipeline",
26
+ "ForcedAligner",
27
+ "NemotronDiarizer",
28
+ "QwenForcedAligner",
29
+ "SpeakerDiarizer",
30
+ ]
31
+
32
+ # Timestamps and speakers transcribe in chunks cut at the quietest point between
33
+ # these lengths. The model trained on clips of at most 19 s; 18 leaves room for
34
+ # the inference lead-in. Short clips are one chunk, so their text is unchanged.
35
+ CHUNK_MAX_S = 18.0
36
+ CHUNK_MIN_S = 8.0
37
+
38
+
39
+ def chunk_bounds(
40
+ audio: np.ndarray, sample_rate: int, max_s: float = CHUNK_MAX_S, min_s: float = CHUNK_MIN_S
41
+ ) -> list[tuple[int, int]]:
42
+ """Sample ranges of at most `max_s`, each cut at the quietest 100 ms frame after `min_s`."""
43
+ frame = int(0.1 * sample_rate)
44
+ bounds, start, n = [], 0, len(audio)
45
+ while n - start > max_s * sample_rate:
46
+ lo = start + int(min_s * sample_rate)
47
+ hi = start + int(max_s * sample_rate)
48
+ k = (hi - lo) // frame
49
+ rms = np.sqrt(np.mean(np.square(audio[lo : lo + k * frame].reshape(k, frame)), axis=1))
50
+ cut = lo + int(np.argmin(rms)) * frame + frame // 2
51
+ bounds.append((start, cut))
52
+ start = cut
53
+ bounds.append((start, n))
54
+ return bounds
55
+
56
 
57
  _THINK_TAG_RE = re.compile(r"<think>.*?</think>\s*", flags=re.DOTALL)
58
  _MIN_REPEATS = 3
 
104
 
105
  Args:
106
  inputs: Audio input (file path, dict with array/sampling_rate, etc.)
107
+ return_timestamps: If True, return word-level timestamps (Qwen3-ForcedAligner)
108
+ return_speakers: If True, label every word with a speaker (Nemotron-3-Diarization)
109
  user_prompt: Custom transcription prompt (default: "Transcribe: ")
110
+ num_speakers: Exact number of speakers, if known
111
+ max_speakers: Upper bound on the number of speakers
 
112
  **kwargs: Additional arguments passed to the pipeline
113
 
114
  Returns:
115
  Dict with 'text' key, 'words' key if return_timestamps=True,
116
+ and speaker labels on words plus 'speaker_segments' if return_speakers=True
117
  """
118
  # Extract our params before super().__call__ (which will also call _sanitize_parameters)
119
  return_timestamps = kwargs.pop("return_timestamps", False)
120
  return_speakers = kwargs.pop("return_speakers", False)
121
  user_prompt = kwargs.pop("user_prompt", None)
122
+ if kwargs.pop("min_speakers", None) is not None:
123
+ raise ValueError(
124
+ "min_speakers is not supported: Nemotron-3-Diarization decides how many "
125
+ "speakers it hears. Pass num_speakers (exact) or max_speakers instead."
126
+ )
127
  diarization_params = {
128
  "num_speakers": kwargs.pop("num_speakers", None),
 
129
  "max_speakers": kwargs.pop("max_speakers", None),
130
  }
131
 
 
143
  self.model.TRANSCRIBE_PROMPT = user_prompt
144
 
145
  try:
146
+ if not return_timestamps:
147
+ return super().__call__(inputs, **kwargs)
148
+ return self._transcribe_timed(
149
  inputs,
 
150
  return_speakers=return_speakers,
151
  diarization_params=diarization_params,
152
  **kwargs,
 
155
  if original_prompt is not None:
156
  self.model.TRANSCRIBE_PROMPT = original_prompt
157
 
158
+ def _transcribe_timed(
159
  self,
160
  inputs,
161
  *,
 
162
  return_speakers: bool,
163
  diarization_params: dict,
164
  **kwargs,
165
  ):
166
+ """Transcribe in chunks, time every word, and label speakers if requested.
167
+
168
+ Chunks of 8-18 s cut at quiet points are transcribed one by one and each
169
+ is aligned against its own transcript (batched), then offset onto the
170
+ recording's timeline -- so audio of any length gets every word timed,
171
+ and the model never sees a clip longer than it trained on. Speakers come
172
+ from ONE Nemotron pass over the whole recording, so labels are
173
+ recording-level: chunking never touches speaker identity.
174
+ """
175
+ audio = self._extract_audio(inputs)
176
+ if audio is None:
177
+ raise ValueError(f"Cannot read audio from {type(inputs).__name__} for timestamps")
178
+ array = np.asarray(audio["array"], dtype=np.float32)
179
+ sr = audio.get("sampling_rate", 16000)
180
+
181
+ bounds = chunk_bounds(array, sr)
182
+ texts = [
183
+ super(ASRPipeline, self).__call__({"raw": array[s:e], "sampling_rate": sr}, **kwargs)[
184
+ "text"
185
+ ]
186
+ for s, e in bounds
187
+ ]
188
+ result: dict[str, Any] = {"text": " ".join(t for t in texts if t)}
189
 
190
+ try:
191
+ aligned = QwenForcedAligner.align_chunks(
192
+ [(array[s:e], text) for (s, e), text in zip(bounds, texts)], sample_rate=sr
193
+ )
194
+ result["words"] = [
195
+ {**w, "start": w["start"] + s / sr, "end": w["end"] + s / sr}
196
+ for (s, _), chunk_words in zip(bounds, aligned)
197
+ for w in chunk_words
198
+ ]
199
+ except Exception as e:
200
+ result["words"] = []
201
+ result["timestamp_error"] = str(e)
202
+
203
+ if return_speakers:
 
 
 
 
 
 
 
 
204
  try:
205
+ activity = NemotronDiarizer.activity(array, sample_rate=sr)
206
+ keep = NemotronDiarizer.top_speakers(activity, **diarization_params)
207
+ result["speaker_segments"] = NemotronDiarizer.segments(activity, keep)
208
+ result["words"] = NemotronDiarizer.assign_speakers_to_words(
209
+ result["words"], activity, keep
210
  )
 
 
 
 
 
 
 
 
211
  except Exception as e:
212
  result["speaker_segments"] = []
213
  result["diarization_error"] = str(e)
diarization.py CHANGED
@@ -1,4 +1,4 @@
1
- """Speaker diarization using TEN-VAD + ECAPA-TDNN + spectral clustering.
2
 
3
  Spectral clustering implementation adapted from FunASR/3D-Speaker:
4
  https://github.com/alibaba-damo-academy/FunASR
@@ -663,3 +663,125 @@ class SpeakerDiarizer:
663
  word["speaker"] = best_speaker
664
 
665
  return words
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker diarization: Nemotron-3-Diarization, and TEN-VAD + ECAPA-TDNN + spectral clustering.
2
 
3
  Spectral clustering implementation adapted from FunASR/3D-Speaker:
4
  https://github.com/alibaba-damo-academy/FunASR
 
663
  word["speaker"] = best_speaker
664
 
665
  return words
666
+
667
+
668
+ class NemotronDiarizer:
669
+ """Speaker activity from NVIDIA Nemotron-3-Diarization, per 10 ms frame.
670
+
671
+ One offline pass over the whole recording: the model chunks internally
672
+ (27.2 s + 3.2 s look-ahead) and carries a speaker cache across chunks, so
673
+ speaker ids are recording-level for audio of any length, with no
674
+ embedding or clustering step. Overlap is native (several speakers can be
675
+ active in one frame). Tracks at most 8 speakers, numbered by arrival.
676
+
677
+ Needs a transformers build with `nemotron3_diarization` (main as of
678
+ 2026-10, after 5.17).
679
+
680
+ Example:
681
+ >>> activity = NemotronDiarizer.activity(audio) # (frames, 8) probabilities
682
+ >>> keep = NemotronDiarizer.top_speakers(activity, max_speakers=2)
683
+ >>> segments = NemotronDiarizer.segments(activity, keep)
684
+ >>> words = NemotronDiarizer.assign_speakers_to_words(words, activity, keep)
685
+ """
686
+
687
+ MODEL_ID = "nvidia/Nemotron-3-Diarization"
688
+ FRAME_S = 0.01
689
+ SEGMENT_THRESHOLD = 0.5 # a speaker's segment = frames above this
690
+ MIN_WORD_ACTIVITY = 0.1 # below this nobody is heard: the word inherits its neighbour
691
+ _model = None
692
+ _processor = None
693
+
694
+ @classmethod
695
+ def get_instance(cls):
696
+ if cls._model is None:
697
+ import importlib.util
698
+
699
+ if importlib.util.find_spec("transformers.models.nemotron3_diarization") is None:
700
+ raise ImportError(
701
+ "Speaker diarization uses Nemotron-3-Diarization, which needs a transformers "
702
+ "build with `nemotron3_diarization`: "
703
+ "pip install git+https://github.com/huggingface/transformers"
704
+ )
705
+ from transformers import AutoModelForAudioFrameClassification, AutoProcessor
706
+
707
+ model = AutoModelForAudioFrameClassification.from_pretrained(cls.MODEL_ID)
708
+ cls._model = model.to(_get_device()).eval()
709
+ cls._processor = AutoProcessor.from_pretrained(cls.MODEL_ID)
710
+ return cls._model, cls._processor
711
+
712
+ @classmethod
713
+ @torch.inference_mode()
714
+ def activity(cls, audio: np.ndarray, sample_rate: int = 16000) -> np.ndarray:
715
+ """(frames, 8) speech probabilities at 10 ms; columns in arrival order."""
716
+ if sample_rate != 16000:
717
+ import torchaudio
718
+
719
+ audio = torchaudio.functional.resample(
720
+ torch.as_tensor(audio, dtype=torch.float32), sample_rate, 16000
721
+ ).numpy()
722
+ model, processor = cls.get_instance()
723
+ inputs = processor(np.asarray(audio, dtype=np.float32), sampling_rate=16000)
724
+ inputs = inputs.to(model.device, dtype=model.dtype)
725
+ return model(**inputs).logits[0].float().sigmoid().cpu().numpy()
726
+
727
+ @classmethod
728
+ def top_speakers(
729
+ cls,
730
+ activity: np.ndarray,
731
+ num_speakers: int | None = None,
732
+ max_speakers: int | None = None,
733
+ ) -> list[int]:
734
+ """Columns that count as speakers, in arrival order.
735
+
736
+ Every column that is ever above SEGMENT_THRESHOLD, capped by
737
+ `num_speakers` (exact, when known) or `max_speakers` to the columns
738
+ with the most speech. Capping is how a known count is applied:
739
+ a dropped column's words go to the most active remaining speaker.
740
+ """
741
+ active = [
742
+ c for c in range(activity.shape[1]) if (activity[:, c] > cls.SEGMENT_THRESHOLD).any()
743
+ ]
744
+ cap = num_speakers or max_speakers
745
+ if cap is not None and len(active) > cap:
746
+ mass = activity.sum(axis=0)
747
+ active = sorted(sorted(active, key=lambda c: -mass[c])[:cap])
748
+ return active or [0]
749
+
750
+ @classmethod
751
+ def segments(cls, activity: np.ndarray, keep: list[int]) -> list[dict]:
752
+ """Speaker turns `{"speaker", "start", "end"}` by start time; overlaps allowed."""
753
+ names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
754
+ out = []
755
+ for c in keep:
756
+ for on, s, e in _label_runs(activity[:, c] > cls.SEGMENT_THRESHOLD):
757
+ if on:
758
+ out.append(
759
+ {"speaker": names[c], "start": s * cls.FRAME_S, "end": e * cls.FRAME_S}
760
+ )
761
+ return sorted(out, key=lambda seg: (seg["start"], seg["end"]))
762
+
763
+ @classmethod
764
+ def assign_speakers_to_words(
765
+ cls, words: list[dict], activity: np.ndarray, keep: list[int]
766
+ ) -> list[dict]:
767
+ """Give each word the kept speaker most active over its span.
768
+
769
+ A word where no kept speaker reaches MIN_WORD_ACTIVITY (aligner put it
770
+ in a pause) takes the previous word's speaker -- the next word's at
771
+ the start of the recording.
772
+ """
773
+ names = {c: f"SPEAKER_{i}" for i, c in enumerate(keep)}
774
+ cols = np.asarray(keep)
775
+ picked: list[str | None] = []
776
+ for word in words:
777
+ lo = min(int(word["start"] / cls.FRAME_S), len(activity) - 1)
778
+ hi = max(lo + 1, min(int(np.ceil(word["end"] / cls.FRAME_S)), len(activity)))
779
+ mean = activity[lo:hi, cols].mean(axis=0)
780
+ best = int(mean.argmax())
781
+ picked.append(names[keep[best]] if mean[best] >= cls.MIN_WORD_ACTIVITY else None)
782
+ known = [p for p in picked if p is not None]
783
+ last = known[0] if known else names[keep[0]]
784
+ for word, p in zip(words, picked):
785
+ last = p if p is not None else last
786
+ word["speaker"] = last
787
+ return words
handler.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom inference handler for HuggingFace Inference Endpoints."""
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+
7
+ try:
8
+ # For remote execution, imports are relative
9
+ from .asr_modeling import ASRModel
10
+ from .asr_pipeline import ASRPipeline
11
+ except ImportError:
12
+ # For local execution, imports are not relative
13
+ from asr_modeling import ASRModel # type: ignore[no-redef]
14
+ from asr_pipeline import ASRPipeline # type: ignore[no-redef]
15
+
16
+
17
+ def _best_device() -> torch.device:
18
+ """Best available inference device (cuda > mps > cpu)."""
19
+ if torch.cuda.is_available():
20
+ return torch.device("cuda")
21
+ if torch.backends.mps.is_available():
22
+ return torch.device("mps")
23
+ return torch.device("cpu")
24
+
25
+
26
+ class EndpointHandler:
27
+ """HuggingFace Inference Endpoints handler for ASR model.
28
+
29
+ Handles model loading, warmup, and inference requests for deployment
30
+ on HuggingFace Inference Endpoints or similar services.
31
+ """
32
+
33
+ def __init__(self, path: str = ""):
34
+ """Initialize the endpoint handler.
35
+
36
+ Args:
37
+ path: Path to model directory or HuggingFace model ID
38
+ """
39
+ import os
40
+
41
+ import nltk
42
+
43
+ nltk.download("punkt_tab", quiet=True)
44
+
45
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
46
+
47
+ # `ASRModel.from_pretrained` constructs its own submodules and forwards
48
+ # **kwargs to `ASRModel.__init__`, which discards them -- no loader
49
+ # reads `device_map`, `torch_dtype` or `low_cpu_mem_usage`, and it sets
50
+ # `_is_loading_from_pretrained` precisely to keep `device_map="auto"`
51
+ # out of the sub-model loaders. Passing them here did nothing, so the
52
+ # model stayed on CPU and a GPU Inference Endpoint silently decoded on
53
+ # CPU. Place it explicitly instead. dtype comes from
54
+ # `config.model_dtype` and the attention backend from
55
+ # `config.attn_implementation`, which already downgrades FA2 when
56
+ # flash_attn is missing.
57
+ self.model = ASRModel.from_pretrained(path)
58
+ self.device = _best_device()
59
+ self.model.to(self.device)
60
+ self.model.eval()
61
+
62
+ self.pipe = ASRPipeline(
63
+ model=self.model,
64
+ feature_extractor=self.model.feature_extractor,
65
+ tokenizer=self.model.tokenizer,
66
+ device=self.device,
67
+ )
68
+
69
+ def __call__(self, data: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
70
+ """Process an inference request.
71
+
72
+ Args:
73
+ data: Request data containing 'inputs' (audio path/bytes) and optional 'parameters'
74
+
75
+ Returns:
76
+ Transcription result with 'text' key
77
+ """
78
+ inputs = data.get("inputs")
79
+ if inputs is None:
80
+ raise ValueError("Missing 'inputs' in request data")
81
+
82
+ # Pass through any parameters from request, let model config provide defaults
83
+ params = data.get("parameters", {})
84
+
85
+ return self.pipe(inputs, **params)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies for tiny-audio model inference
2
+ # This file is pushed to HuggingFace for model repository
3
+
4
+ # Transcription and word timestamps (Qwen3-ForcedAligner) need transformers >= 5.17.
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