File size: 18,720 Bytes
1323995
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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 -----------------
    @property
    def audio_token_ids(self):
        return [self.audio_token_id]

    @property
    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"]