Automatic Speech Recognition
MLX
Safetensors
jais_asr
jais
ifm
arabic
arabic-dialects
speech-to-text
code-switching
diacritization
language-identification
open-weights
custom_code
mlx-vlm
8-bit precision
Instructions to use IFM/Jais-ASR-MLX-8bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use IFM/Jais-ASR-MLX-8bit with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] hf download IFM/Jais-ASR-MLX-8bit --local-dir Jais-ASR-MLX-8bit
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
File size: 15,323 Bytes
47eeea9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 | """Reading Jais-ASR output: the leading `[...]` decision tag.
Every generation starts with a bracketed decision:
"[EG] النص هنا" -> a transcript, in Egyptian Arabic
"[msa] النص هنا" -> a transcript, in Modern Standard Arabic
"[IFM-NoTranscript]" -> a deliberate abstention: no transcribable speech
The rule is general: the transcript never begins with a bracketed span. Anything the
model writes inside leading `[...]` spans is decision metadata, routed to `.tag` (and
to the API's `reasoning` field), never into `.text`:
* a span whose body opens with 'IF' (any case) is the refusal branch: `.refused`
is True and `.text` is empty -- nothing after a refusal is a transcript.
* otherwise the first alphabetic component that case-folds onto a known tag sets
`.language`; a span naming no supported language leaves `.language` as None.
* no leading span -> `.tag` is None and `.text` is the whole output, untouched.
from postprocess import split_text
r = split_text(raw)
r.text # the transcript
r.language # canonical tag ("EG", "msa", ...) or None
r.refused # True when the model abstained
r.tag # the leading tag as emitted, e.g. "[EG]", or None
For well-formed output the exact token-id fast path (`split_token_ids`) gives the same
answer with no string work; the text rule is the authority otherwise.
This module is pure python: no torch, no transformers, no vllm at import time.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
__all__ = [
"LANGUAGE_TAGS",
"REFUSAL_TAG",
"Transcription",
"split_text",
"split_token_ids",
"classify_tag_body",
"stream_split",
"verify_against_tokenizer",
]
# ---------------------------------------------------------------------------------
# The decision inventory. Token ids are those of this repo's tokenizer;
# `verify_against_tokenizer` below re-derives every one of them.
# ---------------------------------------------------------------------------------
BRACKET_OPEN_ID = 60 # '['
BRACKET_CLOSE_ID = 62 # ']'
THINK_OPEN_ID = 250029 # <ifm|think>
THINK_CLOSE_ID = 250030 # '</ifm|think>', the last token of every rendered prompt
REFUSAL_TAG = "[IFM-NoTranscript]"
REFUSAL_TOKEN_IDS = (
60,
5126,
46,
131126,
195862,
62,
) # '[' 'IF' 'M' '-No' 'Transcript' ']'
#: decision-token id -> tag surface. 24 tags, all distinct.
LANGUAGE_TAG_IDS = {
226606: "msa", # Modern Standard Arabic
272: "en",
2075: "fr",
273: "es",
286: "it",
7646: "EG",
8048: "SA",
6101: "MA",
29354: "IQ",
23129: "JO",
20056: "SY",
27526: "QA",
64489: "DZ",
23852: "LB",
5594: "OM",
28472: "YE",
43852: "TN",
17604: "MR",
25893: "KW",
9373: "SD",
12933: "LY",
5402: "AE",
4295: "PS",
41905: "BH",
}
LANGUAGE_TAGS = tuple(LANGUAGE_TAG_IDS.values())
#: longest well-formed prefix, in tokens -- the id fast path never looks further
MAX_TAG_TOKENS = len(REFUSAL_TOKEN_IDS)
#: a decision span is SHORT. 64 chars is far beyond any real tag; anything longer
#: is not tag-shaped and stays in the transcript untouched.
TAG_SPAN_MAX_CHARS = 64
#: total leading-metadata budget (a few stacked spans at most)
TAG_ZONE_MAX_CHARS = 160
# one bracketed span (no newline inside; the first ']' closes it); used with .match(pos)
_SPAN_RE = re.compile(r"[ \t]*\[([^\]\n]{0,%d})\]" % TAG_SPAN_MAX_CHARS)
_CANONICAL = {t.lower(): t for t in LANGUAGE_TAGS}
@dataclass(frozen=True)
class Transcription:
"""What the model decided, and what it transcribed."""
text: str
language: str | None = None
refused: bool = False
tag: str | None = None
thinking: str = ""
truncated: bool = False
def __bool__(self) -> bool: # `if result:` is False on an abstention
return not self.refused and bool(self.text)
def has_prefilled_tag(prompt_ids):
"""Only a complete known [language] suffix counts as a prompt prefill."""
return (
len(prompt_ids) >= 3
and int(prompt_ids[-3]) == BRACKET_OPEN_ID
and int(prompt_ids[-2]) in LANGUAGE_TAG_IDS
and int(prompt_ids[-1]) == BRACKET_CLOSE_ID
)
def classify_tag_body(body: str):
"""(language | None, refused) for one span body. See the module contract."""
b = body.strip()
if b[:2].upper() == "IF":
return None, True
for comp in re.split(r"[^A-Za-z]+", b):
if comp and comp.lower() in _CANONICAL:
return _CANONICAL[comp.lower()], False
return None, False
def _leading_spans(text: str):
"""Parse the leading metadata zone -> (spans, refused, language, end_pos)."""
spans, refused, language, pos = [], False, None, 0
while pos <= TAG_ZONE_MAX_CHARS:
m = _SPAN_RE.match(text, pos)
if not m:
break
body = m.group(1)
spans.append(f"[{body}]")
lang, ref = classify_tag_body(body)
refused = refused or ref
if language is None and lang is not None:
language = lang
pos = m.end()
return spans, refused, language, pos
# ---------------------------------------------------------------------------------
# token-id path -- exact fast path for WELL-FORMED output
# ---------------------------------------------------------------------------------
def tag_span_len(token_ids) -> int:
"""Tokens spanned by a WELL-FORMED leading tag, else 0 (the text rule then decides)."""
ids = list(token_ids[:MAX_TAG_TOKENS])
if not ids or ids[0] != BRACKET_OPEN_ID:
return 0
if tuple(ids[: len(REFUSAL_TOKEN_IDS)]) == REFUSAL_TOKEN_IDS:
return len(REFUSAL_TOKEN_IDS)
if len(ids) >= 3 and ids[1] in LANGUAGE_TAG_IDS and ids[2] == BRACKET_CLOSE_ID:
return 3
return 0
def split_token_ids(token_ids, *, strip: bool = True):
"""-> (tag or None, refused, remaining_token_ids). Exact-form fast path only;
anything else returns (None, False, ids) and callers fall back to `split_text`."""
ids = list(token_ids)
n = tag_span_len(ids)
if n == 0:
return None, False, ids
if n == len(REFUSAL_TOKEN_IDS):
return REFUSAL_TAG, True, ([] if strip else ids)
return f"[{LANGUAGE_TAG_IDS[ids[1]]}]", False, (ids[n:] if strip else ids)
# ---------------------------------------------------------------------------------
# text path -- the authority
# ---------------------------------------------------------------------------------
def _split_think(text: str):
"""Generated-side think block (enable_thinking=True): the prompt opens
'<ifm|think>', so the generation carries thought text up to the first
'</ifm|think>'. Without that marker this is a no-op."""
head = text.lstrip()
if head.startswith("<ifm|think>"):
text = head[len("<ifm|think>") :]
idx = text.find("</ifm|think>")
if idx < 0:
return "", text
return text[:idx].strip(), text[idx + len("</ifm|think>") :]
def split_text(text: str, *, strip: bool = True) -> Transcription:
"""Split a decoded generation per the general contract (module docstring)."""
thinking, text = _split_think(text)
spans, refused, language, pos = _leading_spans(text)
if not spans:
return Transcription(text=text, thinking=thinking)
tag = " ".join(spans)
if refused:
return Transcription(
text="" if strip else text, refused=True, tag=tag, thinking=thinking
)
rest = text[pos:].lstrip() if strip else text
return Transcription(text=rest, language=language, tag=tag, thinking=thinking)
def stream_split(text: str):
"""Streaming view of the contract over the text-so-far.
-> (reasoning_so_far, content_so_far, holding)
* while the head could still be inside the metadata zone (only whitespace yet, or
a short unclosed '[' span) the undecided tail is HELD (both strings frozen);
* a refusal-branch output puts its closed leading span(s) in reasoning; nothing
after a refusal is ever content;
* otherwise reasoning is the closed leading span(s), content starts at the first
non-metadata character. Both returned strings only ever grow (delta-safe).
"""
spans, refused, _lang, pos = _leading_spans(text)
if refused:
return text[:pos], "", False
rest = text[pos:]
head = rest.lstrip(" \t")
ws = len(rest) - len(head)
if not spans:
if head == "" and len(text) <= TAG_ZONE_MAX_CHARS:
return "", "", True # nothing decisive yet
if (
head.startswith("[")
and "]" not in head
and "\n" not in head
and len(head) <= TAG_SPAN_MAX_CHARS + 2
):
return "", "", True # a span may be forming
return "", text, False # no metadata: all content
if (
head.startswith("[")
and "]" not in head
and "\n" not in head
and len(head) <= TAG_SPAN_MAX_CHARS + 2
and pos + ws <= TAG_ZONE_MAX_CHARS
):
return text[:pos], "", True # another span may be forming
return text[:pos], text[pos + ws :], False
def verify_against_tokenizer(tokenizer) -> None:
"""Assert the id table matches this tokenizer. Cheap; call it in your tests."""
enc = lambda s: tokenizer(s, add_special_tokens=False).input_ids # noqa: E731
assert (
tuple(enc(REFUSAL_TAG)) == REFUSAL_TOKEN_IDS
), f"{REFUSAL_TAG} -> {enc(REFUSAL_TAG)}, expected {list(REFUSAL_TOKEN_IDS)}"
assert tuple(enc("</ifm|think>")) == (
THINK_CLOSE_ID,
), f"</ifm|think> -> {enc('</ifm|think>')}, expected [{THINK_CLOSE_ID}]"
for tid, tag in LANGUAGE_TAG_IDS.items():
ids = enc(f"[{tag}] ")
assert (
ids[0] == BRACKET_OPEN_ID and ids[1] == tid
), f"[{tag}] -> {ids[:3]}, expected decision id {tid} at position 1"
assert len(set(LANGUAGE_TAG_IDS)) == 24
# =================================================================================
# vLLM integration -- metadata becomes `message.reasoning`, transcript is `content`
#
# vllm serve <model> --trust-remote-code --reasoning-parser jais_asr_tag
#
# Registration happens on import inside a vLLM process; the vllm_plugin/ package in
# this repo does that automatically at server start.
# =================================================================================
try: # pragma: no cover - only meaningful inside a vLLM process
from vllm.reasoning.abs_reasoning_parsers import (
ReasoningParser,
ReasoningParserManager,
)
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
@ReasoningParserManager.register_module("jais_asr_tag")
class JaisASRTagParser(ReasoningParser):
"""reasoning = the leading decision metadata; content = the transcript only.
Same general contract as split_text: any leading bracketed span(s) go to
`reasoning`; a refusal-branch output puts its leading tag in `reasoning`
(the same value as `.tag`) with empty content; no leading span -> empty
reasoning.
"""
def __init__(self, tokenizer, *args, **kwargs):
template = kwargs.get("chat_template_kwargs") or {}
self.language_prefill = template.get("language_prefill")
self._prefill_emitted = False
super().__init__(tokenizer, *args, **kwargs)
# ReasoningConfig auto-initialization tokenizes these at server start;
# they are the prompt's think-block delimiters, already closed in every
# rendered prompt, so the thinking-budget machinery stays inert.
@property
def reasoning_start_str(self) -> str:
return "<ifm|think>"
@property
def reasoning_end_str(self) -> str:
return "</ifm|think>"
def is_reasoning_end(self, input_ids) -> bool:
ids = list(input_ids)
# vLLM also calls this with PROMPT ids (seeding per-request state) and
# with prompt+output ids; every rendered prompt ends with '</ifm|think>'
# (+ an optional prefill), so only what follows the LAST one is output.
for i in range(len(ids) - 1, -1, -1):
if ids[i] == THINK_CLOSE_ID:
ids = ids[i + 1 :]
break
else:
if ids and ids[0] == 0: # BOS-led, no anchor: a bare prompt
return False
text = self.model_tokenizer.decode(ids[:48])
_r, content, holding = stream_split(text)
return (not holding) and content != ""
def extract_content_ids(self, input_ids):
ids = list(input_ids)
text = self.model_tokenizer.decode(ids)
r = split_text(text)
if r.refused:
return []
if r.tag is None:
return ids
# the space after ']' merges into the first content token, so keep the
# boundary token: cut after the last prefix that fits INSIDE the tag zone
cut = len(text) - len(r.text)
keep = 0
for k in range(1, len(ids) + 1):
if len(self.model_tokenizer.decode(ids[:k])) > cut:
break
keep = k
return ids[keep:]
def extract_reasoning(self, model_output, request):
r = split_text(model_output)
tag = r.tag
if tag is None and not r.refused:
kw = getattr(request, "chat_template_kwargs", None) or {}
if kw.get("language_prefill"):
tag = f"[{kw['language_prefill']}]" # tag was prefilled into the prompt
reasoning = "\n".join(p for p in (r.thinking, tag or "") if p) or None
if r.refused:
return reasoning, ""
if tag is None and not r.thinking:
return None, model_output
return reasoning, r.text
def extract_reasoning_streaming(
self,
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
):
pr, pc, _ = stream_split(previous_text)
cr, cc, _holding = stream_split(current_text)
dr = cr[len(pr) :] if cr.startswith(pr) else cr
dc = cc[len(pc) :] if cc.startswith(pc) else cc
if self.language_prefill and not self._prefill_emitted:
dr = f"[{self.language_prefill}]" + ("\n" + dr if dr else "")
self._prefill_emitted = True
if not dr and not dc:
return None
return DeltaMessage(reasoning=dr or None, content=dc or None)
__all__.append("JaisASRTagParser")
except (
Exception
) as exc: # vLLM absent, or an API shift -- the pure-python half still works
import importlib.util
if importlib.util.find_spec("vllm") is not None:
import logging
logging.getLogger(__name__).warning(
"jais_asr_tag reasoning parser NOT registered (%s: %s); "
"`--reasoning-parser jais_asr_tag` will fail with a KeyError",
type(exc).__name__,
exc,
)
|