Automatic Speech Recognition
Transformers
Safetensors
jais_asr
text-generation
jais
ifm
arabic
arabic-dialects
speech-to-text
code-switching
diacritization
language-identification
open-weights
custom_code
Instructions to use IFM/Jais-ASR with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use IFM/Jais-ASR with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("automatic-speech-recognition", model="IFM/Jais-ASR", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("IFM/Jais-ASR", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download processing_jais_asr.py from IFM/Jais-ASR: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/IFM/Jais-ASR/resolve/main/processing_jais_asr.py
- Command line
-
hf download hf://IFM/Jais-ASR/processing_jais_asr.py
-
curl -L -o processing_jais_asr.py https://huggingface.co/IFM/Jais-ASR/resolve/main/processing_jais_asr.py
18.7 kB
| """Processor for Jais-ASR. | |
| `processor(text=..., audio=..., sampling_rate=...)` takes one `<|ifm|audio|>` | |
| placeholder per clip and expands it to `<audio>` + one placeholder per encoder frame | |
| + `</audio>`, so the model's `masked_scatter` lines up exactly with the frames the | |
| audio tower emits. | |
| Audio input is standardized automatically: pass `sampling_rate=` with whatever rate | |
| your file has (a scalar, or one rate per clip) and it is resampled to the model's | |
| 16 kHz; multi-channel audio is downmixed to mono and integer PCM is converted to | |
| float. When `sampling_rate` is omitted the audio is assumed to already be 16 kHz. | |
| A bare 2-D numpy array is always a batch of mono clips (leading dim = batch); pass | |
| a multi-channel clip inside a list to have its channels downmixed. | |
| Audio length is unrestricted at the long end: no maximum duration, no truncation. | |
| The mel is extracted per clip at full length and long audio is chunked inside the | |
| encoder; each clip expands to its own frame count (~13/sec). Clips must be longer | |
| than `n_fft // 2` samples (200 samples = 12.5 ms at 16 kHz), the STFT minimum. | |
| """ | |
| import re | |
| import numpy as np | |
| import transformers | |
| from transformers.feature_extraction_utils import BatchFeature | |
| from transformers.processing_utils import ProcessorMixin | |
| if tuple(int(p) for p in transformers.__version__.split(".")[:2]) < (4, 57): | |
| raise ImportError( | |
| "MawJais-stt requires transformers >= 4.57 (earlier versions lack the " | |
| "WhisperFeatureExtractor attention-mask trim and corrupt the audio features); " | |
| f"found transformers=={transformers.__version__}. " | |
| "Upgrade with `pip install -U 'transformers>=4.57'`.") | |
| def _audio_output_lengths(input_lengths): | |
| """Encoder output-frame count from the mel-frame count. | |
| Three stride-2 stages plus per-100-frame windowing: 100 mel frames/sec -> 13 output | |
| tokens/sec. Works elementwise on a python int or a torch / numpy integer tensor. | |
| """ | |
| input_lengths_leave = input_lengths % 100 | |
| feat_lengths = (input_lengths_leave - 1) // 2 + 1 | |
| return ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 + (input_lengths // 100) * 13 | |
| def _resample(wave, orig_sr, target_sr): | |
| """Resample one mono float32 clip with the best library installed.""" | |
| try: | |
| import soxr | |
| return np.asarray(soxr.resample(wave, orig_sr, target_sr, quality="HQ"), | |
| dtype=np.float32) | |
| except ImportError: | |
| pass | |
| try: | |
| import librosa | |
| return np.asarray(librosa.resample(wave, orig_sr=orig_sr, target_sr=target_sr), | |
| dtype=np.float32) | |
| except ImportError: | |
| pass | |
| try: | |
| from math import gcd | |
| from scipy.signal import resample_poly | |
| g = gcd(int(orig_sr), int(target_sr)) | |
| return np.asarray(resample_poly(wave, target_sr // g, orig_sr // g), | |
| dtype=np.float32) | |
| except ImportError: | |
| pass | |
| try: | |
| import torch | |
| import torchaudio.functional as _taf | |
| return _taf.resample(torch.from_numpy(wave), orig_sr, target_sr).numpy() | |
| except ImportError: | |
| raise ImportError( | |
| f"this audio is {orig_sr} Hz and the model expects {target_sr} Hz; install a " | |
| "resampler (`pip install soxr`, or librosa / scipy / torchaudio) or resample " | |
| "the audio yourself.") | |
| def _standardize_audio(wave, sr, target_sr): | |
| """One clip -> float32 mono at `target_sr`. | |
| Accepts any dtype (integer PCM is rescaled to [-1, 1]), mono or multi-channel | |
| (channels are averaged), any sample rate (`sr=None` means already `target_sr`). | |
| A clip that is already float mono at `target_sr` passes through untouched. | |
| """ | |
| a = np.asarray(wave) | |
| # integer PCM -> [-1, 1] float FIRST: channel averaging below returns float and | |
| # would otherwise hide the integer dtype, leaving the waveform at PCM amplitude | |
| if np.issubdtype(a.dtype, np.unsignedinteger): # e.g. uint8 PCM (offset binary) | |
| half = float(np.iinfo(a.dtype).max + 1) / 2.0 | |
| a = (a.astype(np.float32) - half) / half | |
| elif np.issubdtype(a.dtype, np.integer): # e.g. int16 / int32 PCM | |
| a = a.astype(np.float32) / float(np.iinfo(a.dtype).max + 1) | |
| if a.ndim == 2: | |
| ch_axis = 0 if a.shape[0] <= a.shape[1] else 1 | |
| if a.shape[ch_axis] > 8: | |
| raise ValueError( | |
| f"cannot tell channels from samples in audio of shape {tuple(a.shape)}; " | |
| "pass each clip as 1-D mono or (channels, samples) / (samples, channels)") | |
| a = a.mean(axis=ch_axis) | |
| if a.ndim != 1: | |
| raise ValueError( | |
| f"each audio clip must be 1-D (mono) or 2-D (with a channel axis), " | |
| f"got shape {tuple(a.shape)}") | |
| a = a.astype(np.float32, copy=False) | |
| if sr is not None and int(sr) != int(target_sr): | |
| a = _resample(a, int(sr), int(target_sr)) | |
| return a | |
| class JaisASRProcessor(ProcessorMixin): | |
| attributes = ["feature_extractor", "tokenizer"] | |
| tokenizer_class = "AutoTokenizer" | |
| # transformers>=5 resolves the feature extractor from preprocessor_config.json | |
| # (`feature_extractor_type`) and deprecates the class attribute; <5 still needs it. | |
| if int(transformers.__version__.split(".")[0]) < 5: | |
| feature_extractor_class = "WhisperFeatureExtractor" | |
| valid_kwargs = ["chat_template", "audio_token", "boa_token", "eoa_token"] | |
| #: fields this processor can produce (read by serving backends) | |
| model_input_names = [ | |
| "input_ids", "attention_mask", "input_features", "feature_attention_mask", | |
| ] | |
| def __init__( | |
| self, | |
| feature_extractor=None, | |
| tokenizer=None, | |
| chat_template=None, | |
| audio_token="<|ifm|audio|>", | |
| boa_token="<audio>", | |
| eoa_token="</audio>", | |
| **kwargs, | |
| ): | |
| self.audio_token = audio_token | |
| self.boa_token = boa_token # span delimiters; never scattered onto | |
| self.eoa_token = eoa_token | |
| self.audio_token_id = ( | |
| tokenizer.convert_tokens_to_ids(audio_token) if tokenizer is not None else None | |
| ) | |
| super().__init__(feature_extractor, tokenizer, chat_template=chat_template) | |
| # ---- ids some serving backends look for by these exact names ----------------- | |
| def audio_token_ids(self): | |
| return [self.audio_token_id] | |
| def audio_processor(self): | |
| return self.feature_extractor | |
| def __call__( | |
| self, | |
| text=None, | |
| audio=None, | |
| return_tensors=None, | |
| return_mm_token_type_ids: bool = False, | |
| **kwargs, | |
| ): | |
| if text is None: | |
| raise ValueError("`text` is required.") | |
| # rate of the INCOMING audio; consumed here (audio is standardized to the | |
| # feature extractor's rate below), never forwarded into the tokenizer | |
| sr_kw = kwargs.pop("sampling_rate", None) | |
| # ProcessorMixin.apply_chat_template(tokenize=True) always passes images= and | |
| # videos=; they must not fall through **kwargs into the tokenizer | |
| for key in ("images", "videos"): | |
| if kwargs.pop(key, None) is not None: | |
| raise ValueError(f"`{key}` is not supported: this is an audio-only model.") | |
| if isinstance(text, str): | |
| text = [text] | |
| audio_inputs = {} | |
| if audio is not None: | |
| sr_target = int(self.feature_extractor.sampling_rate) | |
| if isinstance(audio, np.ndarray): | |
| if audio.ndim == 1: | |
| audio = [audio] # one mono clip | |
| else: | |
| audio = list(audio) # leading dim is always the batch of clips | |
| elif not isinstance(audio, (list, tuple)): | |
| audio = [audio] | |
| if len(audio) == 0: | |
| raise ValueError("`audio` is empty; pass at least one clip or omit `audio=`.") | |
| if isinstance(sr_kw, (list, tuple)): | |
| if len(sr_kw) != len(audio): | |
| raise ValueError( | |
| f"sampling_rate has {len(sr_kw)} entries but {len(audio)} audio clips " | |
| "were passed; give one rate per clip (or a single scalar).") | |
| audio = [_standardize_audio(a, s, sr_target) for a, s in zip(audio, sr_kw)] | |
| else: | |
| audio = [_standardize_audio(a, sr_kw, sr_target) for a in audio] | |
| min_samples = int(self.feature_extractor.n_fft) // 2 + 1 | |
| for i, a in enumerate(audio): | |
| if len(a) < min_samples: | |
| raise ValueError( | |
| f"audio clip {i} has {len(a)} samples after standardization; the STFT " | |
| f"needs at least {min_samples} samples " | |
| f"({1000.0 * min_samples / sr_target:.1f} ms at {sr_target} Hz). Note a " | |
| "bare 2-D array is a batch of mono clips; pass a multi-channel clip " | |
| "inside a list.") | |
| n_placeholders = sum(t.count(self.audio_token) for t in text) | |
| if n_placeholders != len(audio): | |
| raise ValueError( | |
| f"text contains {n_placeholders} '{self.audio_token}' placeholders but " | |
| f"{len(audio)} audio clips were passed; they must match one-to-one (in order).") | |
| sr = self.feature_extractor.sampling_rate | |
| # each clip runs through the feature extractor ALONE: its mel values and | |
| # frame count never depend on which other clips share the batch | |
| feats = [ | |
| np.asarray( | |
| self.feature_extractor( | |
| a, | |
| sampling_rate=sr, | |
| return_attention_mask=True, | |
| padding="longest", | |
| truncation=False, | |
| )["input_features"] | |
| )[0] | |
| for a in audio | |
| ] | |
| mel_lens = [f.shape[-1] for f in feats] | |
| t_max = max(mel_lens) | |
| input_features = np.zeros((len(feats), feats[0].shape[0], t_max), dtype=np.float32) | |
| feature_attention_mask = np.zeros((len(feats), t_max), dtype=np.int32) | |
| for i, (f, n) in enumerate(zip(feats, mel_lens)): | |
| input_features[i, :, :n] = f | |
| feature_attention_mask[i, :n] = 1 | |
| audio_inputs["input_features"] = input_features | |
| audio_inputs["feature_attention_mask"] = feature_attention_mask | |
| n_tokens = [int(_audio_output_lengths(int(n))) for n in mel_lens] | |
| repl = iter( | |
| f"{self.boa_token}{self.audio_token * n}{self.eoa_token}" for n in n_tokens | |
| ) | |
| pattern = re.escape(self.audio_token) | |
| text = [re.sub(pattern, lambda _m: next(repl), t) for t in text] | |
| else: | |
| n_placeholders = sum(t.count(self.audio_token) for t in text) | |
| if n_placeholders: | |
| raise ValueError( | |
| f"text contains {n_placeholders} '{self.audio_token}' placeholders but no " | |
| "audio was passed; pass `audio=` with one clip per placeholder.") | |
| kwargs.setdefault("add_special_tokens", False) | |
| text_inputs = self.tokenizer(text=text, return_tensors=return_tensors, **kwargs) | |
| data = {**text_inputs, **audio_inputs} | |
| # PreTrainedTokenizerFast emits `token_type_ids` by default. This model has no | |
| # segment embeddings, and `generate()` rejects unknown kwargs outright, so | |
| # `model.generate(**processor(...))` would raise. Drop it here so the processor's | |
| # output is always directly usable. | |
| data.pop("token_type_ids", None) | |
| if return_mm_token_type_ids: | |
| # 1 wherever the audio embeddings will be scattered, 0 elsewhere. Mirrors | |
| # `input_ids` row for row (lists stay lists, so ragged batches work). | |
| import torch | |
| ids = data["input_ids"] | |
| if isinstance(ids, torch.Tensor): | |
| data["mm_token_type_ids"] = (ids == self.audio_token_id).long() | |
| elif isinstance(ids, np.ndarray): | |
| data["mm_token_type_ids"] = (ids == self.audio_token_id).astype(np.int64) | |
| else: | |
| data["mm_token_type_ids"] = [ | |
| [1 if t == self.audio_token_id else 0 for t in row] for row in ids | |
| ] | |
| return BatchFeature(data=data, tensor_type=return_tensors) | |
| def _get_num_multimodal_tokens(self, audio_lengths=None, **kwargs): | |
| """Placeholder count per clip, without running the feature extractor. | |
| `audio_lengths` are MEL-frame counts (100/sec). Used by serving backends to | |
| size the placeholder span before scheduling. | |
| """ | |
| if audio_lengths is None or len(audio_lengths) == 0: | |
| return {"num_audio_tokens": []} | |
| return {"num_audio_tokens": [int(_audio_output_lengths(int(n))) for n in audio_lengths]} | |
| # ---- tokenizer passthroughs -------------------------------------------------- | |
| def batch_decode(self, *args, **kwargs): | |
| return self.tokenizer.batch_decode(*args, **kwargs) | |
| def decode(self, *args, **kwargs): | |
| return self.tokenizer.decode(*args, **kwargs) | |
| # ---- reading the model's output ---------------------------------------------- | |
| def post_process_transcription(self, generated, *, input_lengths=None, strip_tag=True, | |
| skip_special_tokens=True): | |
| """Decode generations and split off the leading `[...]` decision tag. | |
| `generated` is what `model.generate(...)` returned (ids, incl. the prompt — a | |
| batch or a single row) or already-decoded text. Pass `input_lengths` to trim | |
| the prompt first: either the padded prompt width as a single int, or the true | |
| prompt length per row (leading pad tokens are then skipped automatically, so | |
| per-row lengths stay correct under left padding). | |
| Returns a list of `Transcription(text, language, refused, tag, thinking, | |
| truncated)`; see postprocess.py. `.truncated` is True when a row hit the | |
| generation cap (no end-of-turn token in the generated ids -- raise | |
| `max_new_tokens` for long-form audio). With `strip_tag=False` the tag is | |
| left in `.text`. | |
| """ | |
| try: | |
| from .postprocess import (split_text, BRACKET_OPEN_ID, BRACKET_CLOSE_ID, | |
| LANGUAGE_TAG_IDS, THINK_OPEN_ID, THINK_CLOSE_ID) | |
| except ImportError: | |
| if __package__: | |
| raise # Preserve dependency errors raised inside sibling modules. | |
| from postprocess import (split_text, BRACKET_OPEN_ID, BRACKET_CLOSE_ID, | |
| LANGUAGE_TAG_IDS, THINK_OPEN_ID, THINK_CLOSE_ID) | |
| if isinstance(generated, str): | |
| return [split_text(generated, strip=strip_tag)] | |
| first = generated[0] if len(generated) else None | |
| if isinstance(first, str): | |
| return [split_text(t, strip=strip_tag) for t in generated] | |
| if getattr(generated, "ndim", None) == 1 or ( | |
| first is not None and not hasattr(first, "__iter__")): | |
| generated = [generated] # a single row of ids -> batch of one | |
| pad_id = self.tokenizer.pad_token_id | |
| bos_id = self.tokenizer.bos_token_id | |
| eos_ids = {self.tokenizer.eos_token_id, | |
| self.tokenizer.convert_tokens_to_ids("<|ifm|endoftext|>")} | |
| eos_ids.discard(None) | |
| rows = [] | |
| for i, seq in enumerate(generated): | |
| seq = [int(t) for t in seq] | |
| prefilled = None | |
| thinking_open = False | |
| if input_lengths is not None: | |
| if isinstance(input_lengths, int): | |
| n = input_lengths # padded prompt width (pads included) | |
| else: | |
| n = int(input_lengths[i]) # true per-row prompt length: skip the | |
| if pad_id is not None: # leading pad run first (left padding) | |
| lead = 0 | |
| while lead < len(seq) and seq[lead] == pad_id: | |
| lead += 1 | |
| if lead and pad_id == bos_id: | |
| lead -= 1 # that last one is the prompt's own BOS | |
| n += lead | |
| if (n >= 3 and seq[n - 1] == BRACKET_CLOSE_ID | |
| and seq[n - 3] == BRACKET_OPEN_ID | |
| and seq[n - 2] in LANGUAGE_TAG_IDS): | |
| prefilled = LANGUAGE_TAG_IDS[seq[n - 2]] # tag prefilled into the prompt | |
| for token_id in reversed(seq[:n]): | |
| if token_id == THINK_CLOSE_ID: | |
| break | |
| if token_id == THINK_OPEN_ID: | |
| thinking_open = True | |
| break | |
| seq = seq[n:] | |
| if seq and seq[0] == THINK_OPEN_ID: | |
| thinking_open = True | |
| pending_thinking = thinking_open and THINK_CLOSE_ID not in seq | |
| rows.append((self.tokenizer.decode(seq, skip_special_tokens=skip_special_tokens), | |
| not any(t in eos_ids for t in seq), prefilled, pending_thinking)) | |
| import dataclasses | |
| out = [] | |
| for t, cut, pre, pending_thinking in rows: | |
| # A capped generation still inside <ifm|think> is reasoning, not a | |
| # transcript. The prompt establishes the open block when supplied. | |
| r = split_text(t + "</ifm|think>" if pending_thinking else t, strip=strip_tag) | |
| if r.tag is None and pre is not None: | |
| r = dataclasses.replace(r, tag=f"[{pre}]", language=pre) | |
| out.append(dataclasses.replace(r, truncated=cut)) | |
| return out | |
| def language_pin(self, language, force_bracket=True): | |
| """A `LanguagePin` for this repo: constrain the language tag while keeping the | |
| model's own transcribe-or-abstain decision. `language` is a tag, an ISO code | |
| covering several tags (`"ar"`), or a list of tags. | |
| pin = processor.language_pin("ar") | |
| out = model.generate(**inputs, | |
| logits_processor=[pin.for_transformers(inputs["input_ids"].shape[1])]) | |
| """ | |
| try: | |
| from .language import LanguagePin | |
| except ImportError: | |
| if __package__: | |
| raise # Preserve dependency errors raised inside sibling modules. | |
| from language import LanguagePin | |
| return LanguagePin(language, force_bracket=force_bracket) | |
| __all__ = ["JaisASRProcessor"] | |