main
py 857 lines 30.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Inference-only MOSS-Transcribe-Diarize ASR model.
5
6 The checkpoint layout is:
7
8 * ``model.whisper_encoder.*``: Whisper-medium encoder weights.
9 * ``model.vq_adaptor.*``: 4x time-merge projector.
10 * ``model.language_model.*``: Qwen3-0.6B decoder weights.
11 """
12
13 import math
14 from collections.abc import Iterable, Mapping, Sequence
15 from typing import Annotated, Any, Literal, TypeAlias
16
17 import regex as re
18 import torch
19 from torch import nn
20 from transformers import BatchFeature
21
22 from vllm.config import ModelConfig, SpeechToTextConfig, VllmConfig
23 from vllm.config.multimodal import MultiModalDummyOptions
24 from vllm.config.speech_to_text import SpeechToTextParams
25 from vllm.inputs import ModalityData, MultiModalDataDict, PromptType, TextPrompt
26 from vllm.model_executor.models.interfaces import (
27 DiarizedTranscriptionSegment,
28 MultiModalEmbeddings,
29 SupportsMultiModal,
30 SupportsPP,
31 SupportsTranscription,
32 _require_is_multimodal,
33 )
34 from vllm.model_executor.models.utils import (
35 AutoWeightsLoader,
36 WeightsMapper,
37 _merge_multimodal_embeddings,
38 init_vllm_registered_model,
39 maybe_prefix,
40 parse_diarized_speaker,
41 parse_diarized_timestamp,
42 )
43 from vllm.model_executor.models.whisper import (
44 WhisperEncoder,
45 _create_fake_bias_for_k_proj,
46 )
47 from vllm.model_executor.models.whisper_utils import ISO639_1_SUPPORTED_LANGS
48 from vllm.multimodal import MULTIMODAL_REGISTRY
49 from vllm.multimodal.inputs import (
50 AudioItem,
51 MultiModalFieldConfig,
52 MultiModalKwargsItems,
53 )
54 from vllm.multimodal.parse import (
55 DictEmbeddingItems,
56 ModalityDataItems,
57 MultiModalDataItems,
58 MultiModalDataParser,
59 )
60 from vllm.multimodal.processing import (
61 BaseDummyInputsBuilder,
62 BaseMultiModalProcessor,
63 BaseProcessingInfo,
64 PromptReplacement,
65 PromptUpdate,
66 PromptUpdateDetails,
67 cached_encode,
68 )
69 from vllm.multimodal.processing.processor import HFMultiModalInputs, ProcessorInputs
70 from vllm.sequence import IntermediateTensors
71 from vllm.transformers_utils.processor import cached_processor_from_config
72 from vllm.utils.tensor_schema import TensorSchema, TensorShape
73
74 WHISPER_ENCODER_STRIDE = 2
75 MAX_AUDIO_DURATION_S = 90 * 60
76
77 AUDIO_PLACEHOLDER = "<|audio_start|><|audio_pad|><|audio_end|>"
78
79 DEFAULT_MOSS_TRANSCRIBE_DIARIZE_PROMPT = (
80 "请将音频转写为文本,每一段需以起始时间戳和说话人编号"
81 "([S01]、[S02]、[S03]…)开头,正文为对应的语音内容,"
82 "并在段末标注结束时间戳,以清晰标明该段语音范围。"
83 )
84
85 _MOSS_DIARIZED_HEADER_RE = re.compile(
86 r"\[(?P<start>[0-9.]{1,32})\]\s*\[(?P<speaker>S[0-9]{1,15})\]"
87 )
88 _MOSS_DIARIZED_END_RE = re.compile(r"\[(?P<end>[0-9.]{1,32})\]\s*\Z")
89
90
91 class MossTranscribeDiarizeAudioInputs(TensorSchema):
92 """Dimensions:
93 - c: Audio chunks
94 - m: Mel bins
95 - f: Mel frames
96 - n: Number of audio items
97 """
98
99 type: Literal["audio_features"] = "audio_features"
100
101 input_features: Annotated[
102 torch.Tensor | None,
103 TensorShape("c", "m", "f"),
104 ]
105 audio_feature_lengths: Annotated[
106 torch.Tensor | None,
107 TensorShape("c"),
108 ]
109 audio_chunk_counts: Annotated[
110 torch.Tensor | None,
111 TensorShape("n"),
112 ]
113
114
115 class MossTranscribeDiarizeEmbeddingInputs(TensorSchema):
116 """Dimensions:
117 - n: Number of audio items
118 - t: Number of audio tokens
119 - h: Hidden size
120 """
121
122 type: Literal["audio_embeds"] = "audio_embeds"
123
124 audio_embeds: Annotated[
125 list[torch.Tensor],
126 TensorShape("n", "t", "h", dynamic_dims={"t"}),
127 ]
128
129
130 MossTranscribeDiarizeInputs: TypeAlias = (
131 MossTranscribeDiarizeAudioInputs | MossTranscribeDiarizeEmbeddingInputs
132 )
133
134
135 def _compute_total_audio_tokens(
136 num_samples: int,
137 feature_extractor: Any,
138 audio_merge_size: int,
139 ) -> int:
140 if num_samples <= 0:
141 return 0
142
143 n_samples = int(feature_extractor.n_samples)
144 stride = (
145 int(feature_extractor.hop_length)
146 * WHISPER_ENCODER_STRIDE
147 * int(audio_merge_size)
148 )
149 total = 0
150 for start in range(0, num_samples, n_samples):
151 chunk_samples = min(n_samples, num_samples - start)
152 total += (chunk_samples - 1) // stride + 1
153 return total
154
155
156 def _get_max_audio_samples(feature_extractor: Any) -> int:
157 return int(MAX_AUDIO_DURATION_S * feature_extractor.sampling_rate)
158
159
160 def _as_audio_embedding_list(audio_embeds: object) -> list[torch.Tensor]:
161 if isinstance(audio_embeds, torch.Tensor):
162 if audio_embeds.ndim == 2:
163 return [audio_embeds]
164 if audio_embeds.ndim == 3:
165 return list(audio_embeds.unbind(dim=0))
166 raise ValueError(
167 f"`audio_embeds` must be a 2D or 3D tensor, got {audio_embeds.ndim}D."
168 )
169
170 if isinstance(audio_embeds, (list, tuple)) and all(
171 isinstance(audio_embed, torch.Tensor) for audio_embed in audio_embeds
172 ):
173 return list(audio_embeds)
174
175 raise TypeError(
176 "`audio_embeds` must be a torch.Tensor or a list of torch.Tensor "
177 f"objects, got {type(audio_embeds)!r}."
178 )
179
180
181 def _get_required_token_id(tokenizer: Any, token: str) -> int:
182 token_id = tokenizer.convert_tokens_to_ids(token)
183 if token_id is None or (isinstance(token_id, int) and token_id < 0):
184 raise ValueError(f"Tokenizer is missing required token {token!r}.")
185 return int(token_id)
186
187
188 def _get_audios_from_mm_data(mm_data: Mapping[str, object]) -> list[Any]:
189 mm_data_dict = dict(mm_data)
190 audio_data = mm_data_dict.pop("audios", mm_data_dict.pop("audio", []))
191 if isinstance(audio_data, list):
192 audios = audio_data
193 elif isinstance(audio_data, tuple) and not (
194 len(audio_data) == 2 and isinstance(audio_data[1], (int, float))
195 ):
196 audios = list(audio_data)
197 elif audio_data is None:
198 audios = []
199 else:
200 audios = [audio_data]
201
202 audio_arrays: list[Any] = []
203 for audio in audios:
204 if isinstance(audio, (tuple, list)) and len(audio) == 2:
205 audio = audio[0]
206 audio_arrays.append(audio)
207 return audio_arrays
208
209
210 def _add_vllm_audio_metadata(
211 processed: BatchFeature,
212 num_audios: int,
213 ) -> BatchFeature:
214 audio_feature_lengths = processed["audio_feature_lengths"]
215 if not isinstance(audio_feature_lengths, torch.Tensor):
216 audio_feature_lengths = torch.tensor(audio_feature_lengths, dtype=torch.long)
217 audio_feature_lengths = audio_feature_lengths.to(dtype=torch.long)
218
219 audio_chunk_mapping = processed.get("audio_chunk_mapping")
220 if audio_chunk_mapping is None:
221 if num_audios == 1:
222 audio_chunk_mapping = torch.zeros_like(audio_feature_lengths)
223 elif audio_feature_lengths.numel() == num_audios:
224 audio_chunk_mapping = torch.arange(num_audios, dtype=torch.long)
225 else:
226 raise ValueError(
227 "The MOSS processor did not return `audio_chunk_mapping`, and "
228 "the chunk-to-audio mapping cannot be inferred."
229 )
230 elif not isinstance(audio_chunk_mapping, torch.Tensor):
231 audio_chunk_mapping = torch.tensor(audio_chunk_mapping, dtype=torch.long)
232 audio_chunk_mapping = audio_chunk_mapping.to(dtype=torch.long)
233 if audio_chunk_mapping.numel() != audio_feature_lengths.numel():
234 raise ValueError(
235 "`audio_chunk_mapping` must contain one item per audio chunk: got "
236 f"{audio_chunk_mapping.numel()} mappings for "
237 f"{audio_feature_lengths.numel()} chunks."
238 )
239 if audio_chunk_mapping.numel() > 0 and (
240 audio_chunk_mapping.min().item() < 0
241 or audio_chunk_mapping.max().item() >= num_audios
242 ):
243 raise ValueError("`audio_chunk_mapping` contains an out-of-range audio index.")
244
245 audio_chunk_counts = torch.bincount(
246 audio_chunk_mapping.cpu(),
247 minlength=num_audios,
248 ).to(dtype=torch.long)
249 audio_token_lengths = torch.zeros(
250 num_audios,
251 dtype=torch.long,
252 device=audio_feature_lengths.device,
253 )
254 audio_token_lengths.scatter_add_(
255 0,
256 audio_chunk_mapping.to(device=audio_feature_lengths.device),
257 audio_feature_lengths,
258 )
259
260 processed["audio_feature_lengths"] = audio_feature_lengths
261 processed["audio_chunk_counts"] = audio_chunk_counts
262 processed["audio_token_lengths"] = audio_token_lengths
263 return processed
264
265
266 class MossTranscribeDiarizeWhisperEncoder(WhisperEncoder):
267 packed_modules_mapping = {"qkv_proj": ["q_proj", "k_proj", "v_proj"]}
268 hf_to_vllm_mapper = WeightsMapper(
269 orig_to_new_substr={".fc1.": ".mlp.fc1.", ".fc2.": ".mlp.fc2."},
270 orig_to_new_stacked={
271 ".self_attn.q_proj": (".self_attn.qkv_proj", "q"),
272 ".self_attn.k_proj": (".self_attn.qkv_proj", "k"),
273 ".self_attn.v_proj": (".self_attn.qkv_proj", "v"),
274 },
275 )
276
277 def __init__(
278 self,
279 *,
280 vllm_config: VllmConfig,
281 prefix: str = "",
282 ) -> None:
283 super().__init__(
284 vllm_config=vllm_config.with_hf_config(
285 vllm_config.model_config.hf_config.audio_config
286 ),
287 prefix=prefix,
288 )
289 self.audio_merge_size = int(vllm_config.model_config.hf_config.audio_merge_size)
290 self.max_encoder_batch: int | None = None
291
292 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
293 weights = _create_fake_bias_for_k_proj(weights, ".k_proj.weight")
294 loader = AutoWeightsLoader(self)
295 return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
296
297 def forward(
298 self,
299 input_features: torch.Tensor,
300 audio_feature_lengths: torch.Tensor | None = None,
301 ) -> torch.Tensor:
302 assert audio_feature_lengths is not None
303 if input_features.numel() == 0:
304 return input_features.new_empty((1, 0, self.conv1.out_channels))
305 device = self.conv1.weight.device
306 dtype = self.conv1.weight.dtype
307 input_features = input_features.to(device=device, dtype=dtype)
308 audio_feature_lengths = audio_feature_lengths.to(device=device)
309 batch_size = self.max_encoder_batch or input_features.shape[0]
310 encoded_parts: list[torch.Tensor] = []
311
312 for start in range(0, input_features.shape[0], batch_size):
313 encoded_parts.append(
314 super().forward([input_features[start : start + batch_size]])
315 )
316
317 hidden = torch.cat(encoded_parts, dim=0)
318 chunks = [
319 hidden[idx : idx + 1, : int(token_len.item()) * self.audio_merge_size]
320 for idx, token_len in enumerate(audio_feature_lengths)
321 ]
322 return torch.cat(chunks, dim=1)
323
324
325 class MossTranscribeDiarizeVQAdaptor(nn.Module):
326 def __init__(self, input_dim: int, hidden_size: int, eps: float) -> None:
327 super().__init__()
328 self.layers = nn.Sequential(
329 nn.Linear(input_dim, hidden_size, bias=True),
330 nn.SiLU(),
331 nn.Linear(hidden_size, hidden_size, bias=True),
332 nn.LayerNorm(hidden_size, eps=eps, bias=True),
333 )
334
335 def forward(self, x: torch.Tensor) -> torch.Tensor:
336 return self.layers(x)
337
338
339 def _mtd_field_config(
340 hf_inputs: Mapping[str, torch.Tensor],
341 ) -> Mapping[str, MultiModalFieldConfig]:
342 fields: dict[str, MultiModalFieldConfig] = {}
343 if "audio_embeds" in hf_inputs:
344 fields["audio_embeds"] = MultiModalFieldConfig.batched("audio")
345 if "audio_chunk_counts" in hf_inputs:
346 audio_chunk_counts = hf_inputs["audio_chunk_counts"]
347 fields.update(
348 input_features=MultiModalFieldConfig.flat_from_sizes(
349 "audio",
350 audio_chunk_counts,
351 ),
352 audio_feature_lengths=MultiModalFieldConfig.flat_from_sizes(
353 "audio",
354 audio_chunk_counts,
355 ),
356 audio_chunk_counts=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
357 audio_token_lengths=MultiModalFieldConfig.batched(
358 "audio", keep_on_cpu=True
359 ),
360 )
361 return fields
362
363
364 class MossTranscribeDiarizeMultiModalDataParser(MultiModalDataParser):
365 def _parse_audio_data(
366 self,
367 data: dict[str, torch.Tensor] | ModalityData[AudioItem],
368 ) -> ModalityDataItems[Any, Any] | None:
369 if isinstance(data, dict):
370 return DictEmbeddingItems(
371 data,
372 modality="audio",
373 required_fields={"audio_embeds"},
374 fields_factory=_mtd_field_config,
375 )
376
377 return super()._parse_audio_data(data)
378
379
380 class MossTranscribeDiarizeProcessingInfo(BaseProcessingInfo):
381 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
382 return {"audio": 1}
383
384 def get_hf_processor(self, **kwargs: object) -> Any:
385 return self.ctx.get_hf_processor(**kwargs)
386
387 def get_feature_extractor(self, **kwargs: object) -> Any:
388 return self.get_hf_processor(**kwargs).feature_extractor
389
390 def get_data_parser(self) -> MultiModalDataParser:
391 feature_extractor = self.get_feature_extractor()
392 return MossTranscribeDiarizeMultiModalDataParser(
393 target_sr=feature_extractor.sampling_rate,
394 target_channels=1,
395 expected_hidden_size=self._get_expected_hidden_size(),
396 )
397
398 def get_mm_max_tokens_per_item(
399 self,
400 seq_len: int,
401 mm_counts: Mapping[str, int],
402 ) -> Mapping[str, int]:
403 if mm_counts.get("audio", 0) <= 0:
404 return {}
405
406 feature_extractor = self.get_feature_extractor()
407 max_audio_samples = _get_max_audio_samples(feature_extractor)
408 max_audio_tokens = _compute_total_audio_tokens(
409 max_audio_samples,
410 feature_extractor,
411 self.get_hf_processor().audio_merge_size,
412 )
413 return {"audio": min(seq_len, max_audio_tokens)}
414
415
416 class MossTranscribeDiarizeDummyInputsBuilder(
417 BaseDummyInputsBuilder[MossTranscribeDiarizeProcessingInfo]
418 ):
419 def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
420 return AUDIO_PLACEHOLDER * mm_counts.get("audio", 0)
421
422 def get_dummy_mm_data(
423 self,
424 seq_len: int,
425 mm_counts: Mapping[str, int],
426 mm_options: MultiModalDummyOptions,
427 ) -> MultiModalDataDict:
428 num_audios = mm_counts.get("audio", 0)
429 if num_audios == 0:
430 return {}
431
432 feature_extractor = self.info.get_feature_extractor()
433 return {
434 "audio": self._get_dummy_audios(
435 length=_get_max_audio_samples(feature_extractor),
436 num_audios=num_audios,
437 overrides=mm_options.get("audio"),
438 )
439 }
440
441
442 class MossTranscribeDiarizeMultiModalProcessor(
443 BaseMultiModalProcessor[MossTranscribeDiarizeProcessingInfo]
444 ):
445 def get_dummy_inputs(
446 self,
447 seq_len: int,
448 mm_counts: Mapping[str, int],
449 mm_options: MultiModalDummyOptions,
450 ) -> ProcessorInputs:
451 builder = self.dummy_inputs
452 tokenizer = self.info.get_tokenizer()
453
454 dummy_mm_data = builder.get_dummy_mm_data(seq_len, mm_counts, mm_options)
455 dummy_mm_items = self.info.parse_mm_data(dummy_mm_data)
456
457 num_audios = mm_counts.get("audio", 0)
458 if num_audios > 0:
459 prompt = cached_encode(
460 tokenizer,
461 AUDIO_PLACEHOLDER * num_audios,
462 add_special_tokens=False,
463 )
464 else:
465 prompt = cached_encode(
466 tokenizer,
467 "\n",
468 add_special_tokens=False,
469 )
470
471 return ProcessorInputs(prompt=prompt, mm_data_items=dummy_mm_items)
472
473 def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str:
474 return self.dummy_inputs.get_dummy_text(mm_counts)
475
476 def _get_hf_mm_inputs(
477 self,
478 mm_items: MultiModalDataItems,
479 hf_kwargs: Mapping[str, object],
480 ) -> HFMultiModalInputs:
481 hf_inputs = super()._get_hf_mm_inputs(mm_items, hf_kwargs)
482 if hf_inputs.hf_data:
483 hf_inputs.hf_data["audio"] = _get_audios_from_mm_data(hf_inputs.hf_data)
484
485 return hf_inputs
486
487 def _postprocess_hf_mm_data(
488 self,
489 mm_data: Mapping[str, object],
490 hf_processor_mm_kwargs: Mapping[str, object],
491 processed_data: BatchFeature,
492 ) -> BatchFeature:
493 audios = _get_audios_from_mm_data(mm_data)
494 if audios:
495 processed_data = _add_vllm_audio_metadata(processed_data, len(audios))
496
497 return processed_data
498
499 def _get_mm_fields_config(
500 self,
501 hf_inputs: BatchFeature,
502 hf_processor_mm_kwargs: Mapping[str, object],
503 ) -> Mapping[str, MultiModalFieldConfig]:
504 return _mtd_field_config(hf_inputs)
505
506 def _get_prompt_updates(
507 self,
508 mm_items: MultiModalDataItems,
509 hf_processor_mm_kwargs: Mapping[str, object],
510 out_mm_kwargs: MultiModalKwargsItems,
511 ) -> Sequence[PromptUpdate]:
512 out_mm_data = out_mm_kwargs.get_data()
513 audio_token_lengths_tensor = out_mm_data.get("audio_token_lengths")
514 if audio_token_lengths_tensor is None:
515 audio_embeds = out_mm_data.get("audio_embeds")
516 if audio_embeds is None:
517 audio_token_lengths: list[int] = []
518 else:
519 audio_token_lengths = [
520 int(audio_embed.shape[0])
521 for audio_embed in _as_audio_embedding_list(audio_embeds)
522 ]
523 else:
524 if not isinstance(audio_token_lengths_tensor, torch.Tensor):
525 raise TypeError(
526 "`audio_token_lengths` must be a torch.Tensor, got "
527 f"{type(audio_token_lengths_tensor)!r}."
528 )
529 audio_token_lengths = [
530 int(length) for length in audio_token_lengths_tensor.tolist()
531 ]
532 processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
533 tokenizer = self.info.get_tokenizer()
534 audio_start_id = _get_required_token_id(tokenizer, processor.audio_start_token)
535 audio_token_id = int(processor.audio_token_id)
536 audio_end_id = _get_required_token_id(tokenizer, processor.audio_end_token)
537
538 def get_num_tokens(item_idx: int) -> int:
539 if item_idx >= len(audio_token_lengths):
540 raise ValueError(
541 "Cannot determine the number of audio tokens for audio item "
542 f"{item_idx}."
543 )
544 num_tokens = audio_token_lengths[item_idx]
545 if num_tokens <= 0:
546 raise ValueError("Audio input is too short to produce any tokens.")
547 return num_tokens
548
549 def get_replacement(item_idx: int) -> PromptUpdateDetails:
550 num_tokens = get_num_tokens(item_idx)
551 audio_tokens = processor._audio_span_ids(num_tokens)
552 return PromptUpdateDetails.select_token_id(
553 [audio_start_id] + audio_tokens + [audio_end_id],
554 embed_token_id=audio_token_id,
555 )
556
557 return [
558 PromptReplacement(
559 modality="audio",
560 target=cached_encode(
561 tokenizer, AUDIO_PLACEHOLDER, add_special_tokens=False
562 ),
563 replacement=get_replacement,
564 ),
565 ]
566
567
568 @MULTIMODAL_REGISTRY.register_processor(
569 MossTranscribeDiarizeMultiModalProcessor,
570 info=MossTranscribeDiarizeProcessingInfo,
571 dummy_inputs=MossTranscribeDiarizeDummyInputsBuilder,
572 )
573 class MossTranscribeDiarizeForConditionalGeneration(
574 nn.Module,
575 SupportsMultiModal,
576 SupportsPP,
577 SupportsTranscription,
578 ):
579 supports_transcription = True
580 supports_transcription_only = True
581 supports_segment_timestamp = False
582 supports_diarized_transcription = True
583 supported_languages = ISO639_1_SUPPORTED_LANGS
584 hf_to_vllm_mapper = WeightsMapper(
585 orig_to_new_prefix={
586 "language_model.layers.": "language_model.model.layers.",
587 "language_model.embed_tokens.": "language_model.model.embed_tokens.",
588 "language_model.norm.": "language_model.model.norm.",
589 "model.language_model.model.": "language_model.model.",
590 "model.language_model.lm_head.": "language_model.lm_head.",
591 "model.language_model.": "language_model.model.",
592 "model.whisper_encoder.": "whisper_encoder.",
593 "model.vq_adaptor.": "vq_adaptor.",
594 "model.": None,
595 },
596 )
597
598 @classmethod
599 def get_placeholder_str(cls, modality: str, i: int) -> str | None:
600 return AUDIO_PLACEHOLDER if modality.startswith("audio") else None
601
602 @classmethod
603 def get_speech_to_text_config(
604 cls,
605 model_config: ModelConfig,
606 task_type: str,
607 ) -> SpeechToTextConfig:
608 processor = cached_processor_from_config(model_config)
609 return SpeechToTextConfig(
610 max_audio_clip_s=None,
611 sample_rate=processor.feature_extractor.sampling_rate,
612 min_energy_split_window_size=None,
613 )
614
615 @classmethod
616 def get_num_audio_tokens(
617 cls,
618 audio_duration_s: float,
619 stt_config: SpeechToTextConfig,
620 model_config: ModelConfig,
621 ) -> int | None:
622 processor = cached_processor_from_config(model_config)
623 num_samples = math.ceil(audio_duration_s * stt_config.sample_rate)
624 return _compute_total_audio_tokens(
625 num_samples,
626 processor.feature_extractor,
627 processor.audio_merge_size,
628 )
629
630 @classmethod
631 def get_generation_prompt(cls, stt_params: SpeechToTextParams) -> PromptType:
632 stt_config = stt_params.stt_config
633 question = stt_params.request_prompt or DEFAULT_MOSS_TRANSCRIBE_DIARIZE_PROMPT
634 question = question.strip() or DEFAULT_MOSS_TRANSCRIBE_DIARIZE_PROMPT
635 prompt = (
636 "<|im_start|>system\n"
637 "You are a helpful assistant.<|im_end|>\n"
638 f"<|im_start|>user\n{AUDIO_PLACEHOLDER}\n"
639 f"{question}<|im_end|>\n"
640 "<|im_start|>assistant\n"
641 )
642 return TextPrompt(
643 prompt=prompt,
644 multi_modal_data={"audio": (stt_params.audio, stt_config.sample_rate)},
645 )
646
647 @classmethod
648 def post_process_output(cls, text: str) -> str:
649 return text.strip()
650
651 @classmethod
652 def parse_diarized_transcript(cls, text: str) -> list[DiarizedTranscriptionSegment]:
653 """Parse MOSS's canonical ``[start][Sxx]text[end]`` transcript."""
654 headers: list[tuple[re.Match[str], float, str]] = []
655 for match in _MOSS_DIARIZED_HEADER_RE.finditer(text):
656 start = parse_diarized_timestamp(match["start"])
657 speaker = parse_diarized_speaker(match["speaker"])
658 if start is not None and speaker is not None:
659 headers.append((match, start, speaker))
660
661 if not headers:
662 return []
663
664 segments: list[DiarizedTranscriptionSegment] = []
665 for index, (header, start, speaker) in enumerate(headers):
666 next_header_start = (
667 headers[index + 1][0].start() if index + 1 < len(headers) else len(text)
668 )
669 body = text[header.end() : next_header_start]
670 end_match = _MOSS_DIARIZED_END_RE.search(body)
671 if end_match is None:
672 return []
673
674 end = parse_diarized_timestamp(end_match["end"])
675 if end is None or end < start:
676 return []
677
678 segment_text = body[: end_match.start()].strip()
679 if segment_text:
680 segments.append(
681 DiarizedTranscriptionSegment(
682 start=start,
683 end=end,
684 speaker=speaker,
685 text=segment_text,
686 )
687 )
688
689 return segments
690
691 def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
692 super().__init__()
693 self.config = vllm_config.model_config.hf_config
694 self.dtype = vllm_config.model_config.dtype
695
696 with self._mark_tower_model(vllm_config, "audio"):
697 self.whisper_encoder = MossTranscribeDiarizeWhisperEncoder(
698 vllm_config=vllm_config,
699 prefix=maybe_prefix(prefix, "whisper_encoder"),
700 )
701 self.vq_adaptor = MossTranscribeDiarizeVQAdaptor(
702 input_dim=int(self.config.adaptor_input_dim),
703 hidden_size=int(self.config.text_config.hidden_size),
704 eps=float(self.config.text_config.rms_norm_eps),
705 )
706
707 with self._mark_language_model(vllm_config):
708 self.language_model = init_vllm_registered_model(
709 vllm_config=vllm_config,
710 hf_config=self.config.text_config,
711 prefix=maybe_prefix(prefix, "language_model"),
712 architectures=["Qwen3ForCausalLM"],
713 )
714
715 self.make_empty_intermediate_tensors = (
716 self.language_model.make_empty_intermediate_tensors
717 )
718
719 def _time_merge(self, features: torch.Tensor) -> torch.Tensor:
720 batch, seq_len, dim = features.shape
721 merge_size = int(self.config.audio_merge_size)
722 seq_len_trim = (seq_len // merge_size) * merge_size
723 return features[:, :seq_len_trim, :].reshape(
724 batch,
725 seq_len_trim // merge_size,
726 dim * merge_size,
727 )
728
729 def _parse_and_validate_audio_input(
730 self,
731 **kwargs: object,
732 ) -> MossTranscribeDiarizeInputs | None:
733 input_features = kwargs.pop("input_features", None)
734 audio_embeds = kwargs.pop("audio_embeds", None)
735 audio_feature_lengths = kwargs.pop("audio_feature_lengths", None)
736 audio_chunk_counts = kwargs.pop("audio_chunk_counts", None)
737 if input_features is None and audio_embeds is None:
738 return None
739 if audio_embeds is not None:
740 return MossTranscribeDiarizeEmbeddingInputs(
741 type="audio_embeds",
742 audio_embeds=_as_audio_embedding_list(audio_embeds),
743 )
744 return MossTranscribeDiarizeAudioInputs(
745 type="audio_features",
746 input_features=input_features,
747 audio_feature_lengths=audio_feature_lengths,
748 audio_chunk_counts=audio_chunk_counts,
749 )
750
751 def _process_audio_input(
752 self, audio_input: MossTranscribeDiarizeInputs
753 ) -> list[torch.Tensor]:
754 if audio_input["type"] == "audio_embeds":
755 return list(audio_input["audio_embeds"])
756
757 input_features = audio_input["input_features"]
758 audio_feature_lengths = audio_input["audio_feature_lengths"]
759 audio_chunk_counts = audio_input["audio_chunk_counts"]
760 if input_features is None or audio_feature_lengths is None:
761 raise ValueError(
762 "MOSS-Transcribe-Diarize audio inputs require both "
763 "`input_features` and `audio_feature_lengths`."
764 )
765 if audio_feature_lengths.numel() != input_features.shape[0]:
766 raise ValueError(
767 "`audio_feature_lengths` must contain one length per "
768 "`input_features` chunk: got "
769 f"{audio_feature_lengths.numel()} lengths for "
770 f"{input_features.shape[0]} chunks."
771 )
772 if audio_chunk_counts is None:
773 audio_chunk_counts = audio_feature_lengths.new_tensor(
774 [input_features.shape[0]],
775 dtype=torch.long,
776 )
777 else:
778 audio_chunk_counts = audio_chunk_counts.to(dtype=torch.long)
779 if audio_chunk_counts.numel() == 0:
780 raise ValueError("`audio_chunk_counts` must contain at least one item.")
781 if torch.any(audio_chunk_counts <= 0):
782 raise ValueError("`audio_chunk_counts` must contain positive counts.")
783 num_audio_chunks = int(audio_chunk_counts.sum().item())
784 if num_audio_chunks != input_features.shape[0]:
785 raise ValueError(
786 "`audio_chunk_counts` must sum to the number of input feature chunks: "
787 f"got {num_audio_chunks} chunks for "
788 f"{input_features.shape[0]} input chunks."
789 )
790
791 features = self.whisper_encoder(input_features, audio_feature_lengths)
792 merged = self._time_merge(features.to(dtype=self.dtype))
793 projected = self.vq_adaptor(merged).squeeze(0)
794
795 audio_chunk_offsets = torch.cumsum(audio_chunk_counts, dim=0)
796 audio_chunk_offsets = torch.cat(
797 [audio_chunk_offsets.new_zeros(1), audio_chunk_offsets]
798 )
799 tokens_per_item = [
800 int(audio_feature_lengths[start:end].sum().item())
801 for start, end in zip(
802 audio_chunk_offsets[:-1].tolist(),
803 audio_chunk_offsets[1:].tolist(),
804 )
805 ]
806 if any(num_tokens <= 0 for num_tokens in tokens_per_item):
807 raise ValueError("Audio input is too short to produce any tokens.")
808 return list(projected.split(tokens_per_item, dim=0))
809
810 def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
811 audio_input = self._parse_and_validate_audio_input(**kwargs)
812 if audio_input is None:
813 return []
814 return self._process_audio_input(audio_input)
815
816 def embed_input_ids(
817 self,
818 input_ids: torch.Tensor,
819 multimodal_embeddings: MultiModalEmbeddings | None = None,
820 *,
821 is_multimodal: torch.Tensor | None = None,
822 ) -> torch.Tensor:
823 inputs_embeds = self.language_model.embed_input_ids(input_ids)
824 if not multimodal_embeddings:
825 return inputs_embeds
826 return _merge_multimodal_embeddings(
827 inputs_embeds=inputs_embeds,
828 multimodal_embeddings=multimodal_embeddings,
829 is_multimodal=_require_is_multimodal(is_multimodal),
830 )
831
832 def forward(
833 self,
834 input_ids: torch.Tensor | None,
835 positions: torch.Tensor,
836 intermediate_tensors: IntermediateTensors | None = None,
837 inputs_embeds: torch.Tensor | None = None,
838 **kwargs: object,
839 ) -> torch.Tensor | IntermediateTensors:
840 if intermediate_tensors is not None:
841 inputs_embeds = None
842 return self.language_model(
843 input_ids=input_ids,
844 positions=positions,
845 intermediate_tensors=intermediate_tensors,
846 inputs_embeds=inputs_embeds,
847 )
848
849 def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
850 return self.language_model.compute_logits(hidden_states)
851
852 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
853 loader = AutoWeightsLoader(self)
854 return loader.load_weights(
855 weights,
856 mapper=self.hf_to_vllm_mapper,
857 )