Automatic Speech Recognition
Transformers
Safetensors
English
asr_model
asr
speech-recognition
speech-to-text
audio
speech-llm
word-timestamps
speaker-diarization
qwen
granite-speech
lora
custom_code
Eval Results (legacy)
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 AutoModelForSpeechSeq2Seq model = AutoModelForSpeechSeq2Seq.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 +2 -3
- asr_config.py +7 -4
- asr_layers.py +4 -5
- asr_modeling.py +42 -45
- asr_pipeline.py +126 -69
- asr_processing.py +103 -9
- asr_types.py +8 -0
- diarization.py +16 -0
- 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 |
-
|
| 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 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 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 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 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
|
| 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 |
-
#
|
| 1856 |
-
#
|
| 1857 |
-
#
|
| 1858 |
-
#
|
| 1859 |
-
|
| 1860 |
-
|
| 1861 |
-
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 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":
|
| 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 |
-
|
| 2046 |
-
|
| 2047 |
-
#
|
| 2048 |
-
#
|
| 2049 |
-
#
|
| 2050 |
-
#
|
| 2051 |
-
|
| 2052 |
-
|
| 2053 |
-
|
| 2054 |
-
|
| 2055 |
-
|
| 2056 |
-
|
| 2057 |
-
|
| 2058 |
-
|
| 2059 |
-
|
| 2060 |
-
|
| 2061 |
-
|
| 2062 |
-
|
| 2063 |
-
|
| 2064 |
-
|
| 2065 |
-
|
| 2066 |
-
|
| 2067 |
-
|
| 2068 |
-
|
|
|
|
| 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
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
else:
|
| 22 |
try:
|
| 23 |
from .alignment import QwenForcedAligner
|
| 24 |
from .asr_modeling import ASRModel
|
| 25 |
-
from .asr_processing import
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 277 |
-
out = self._transcribe_chunk(
|
| 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
|
| 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
|
| 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 =
|
| 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,
|
| 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 |
-
|
| 9 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|