[Frontend] Add diarized_json support for MOSS-Transcribe-Diarize (#48543)
Signed-off-by: Lucas Christian <lucaschgf7@gmail.com>
christian committed
Jul 29, 2026 at 20:24 UTC
9d1aa4dda85a58c74b23ff79aa4a20594ee6f359
9 files changed
+309
-10
docs/serving/online_serving/speech_to_text.md
+25
-1
@@ -64,7 +64,8 @@ The Transcriptions API supports uploading audio files in various formats includi
64
- `model`: The model to use for transcription (required)
65
- `language`: The language code (e.g., "en", "zh") (optional)
66
- `prompt`: Optional text to guide the transcription style (optional)
67
-- `response_format`: Format of the response ("json", "text") (optional)
67
+- `response_format`: Format of the response ("json", "text", "verbose_json", or
68
+ "diarized_json") (optional)
69
- `temperature`: Sampling temperature between 0 and 1 (optional)
70
71
For the complete list of supported parameters including sampling parameters and vLLM extensions, see the [protocol definitions](https://github.com/vllm-project/vllm/blob/main/vllm/entrypoints/speech_to_text/transcription/protocol.py).
@@ -98,6 +99,29 @@ For `verbose_json` response format:
99
```
100
Currently “verbose_json” response format doesn’t support no_speech_prob.
101
102
+For models with diarization support, `diarized_json` returns OpenAI-compatible
103
+speaker segments. Currently, this is supported by
104
+`OpenMOSS-Team/MOSS-Transcribe-Diarize`.
105
+
106
+```json
107
+{
108
+ "task": "transcribe",
109
+ "duration": 6.1,
110
+ "text": "Hello. Hi, how are you?",
111
+ "segments": [
112
+ {
113
+ "type": "transcript.text.segment",
114
+ "id": "segment_0",
115
+ "start": 0.0,
116
+ "end": 2.8,
117
+ "text": "Hello.",
118
+ "speaker": "S01"
119
+ }
120
+ ],
121
+ "usage": {"type": "duration", "seconds": 7}
122
+}
123
+```
124
+
125
### Extra Parameters
126
127
The following [sampling parameters](../../api/README.md#inference-parameters) are supported.
tests/models/multimodal/processing/test_moss_transcribe_diarize.py
new
+108
@@ -0,0 +1,108 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.model_executor.models.moss_transcribe_diarize import (
5
+ MossTranscribeDiarizeForConditionalGeneration,
6
+)
7
+
8
+
9
+def test_parse_diarized_transcript_preserves_moss_segments():
10
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
11
+ "[0.48][S01]Welcome[1.66][12.26][S02]Ready[13.81]"
12
+ )
13
+
14
+ assert [
15
+ (segment.start, segment.end, segment.speaker, segment.text)
16
+ for segment in segments
17
+ ] == [
18
+ (0.48, 1.66, "S01", "Welcome"),
19
+ (12.26, 13.81, "S02", "Ready"),
20
+ ]
21
+
22
+
23
+def test_parse_diarized_transcript_preserves_overlapping_segments():
24
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
25
+ "[0][S01]First speaker[2][1][S02]Second speaker[3]"
26
+ )
27
+
28
+ assert [
29
+ (segment.start, segment.end, segment.speaker, segment.text)
30
+ for segment in segments
31
+ ] == [
32
+ (0.0, 2.0, "S01", "First speaker"),
33
+ (1.0, 3.0, "S02", "Second speaker"),
34
+ ]
35
+
36
+
37
+def test_parse_diarized_transcript_preserves_numeric_text_markers():
38
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
39
+ "[0][S01]The [2024] report is ready.[4]"
40
+ )
41
+
42
+ assert [segment.text for segment in segments] == ["The [2024] report is ready."]
43
+
44
+
45
+def test_parse_diarized_transcript_ignores_whitespace_between_segments():
46
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
47
+ "[0][S01]Hello[1]\n [2][S02]Hi[3]"
48
+ )
49
+
50
+ assert [(segment.start, segment.end, segment.text) for segment in segments] == [
51
+ (0.0, 1.0, "Hello"),
52
+ (2.0, 3.0, "Hi"),
53
+ ]
54
+
55
+
56
+def test_parse_diarized_transcript_ignores_noise_before_a_segment():
57
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
58
+ "noise [bad][0.1][S01]Hello[0.9]"
59
+ )
60
+
61
+ assert [(segment.start, segment.end, segment.text) for segment in segments] == [
62
+ (0.1, 0.9, "Hello"),
63
+ ]
64
+
65
+
66
+def test_parse_diarized_transcript_preserves_timestamps_before_the_end():
67
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
68
+ "[2][S01]The earlier timestamp is [1] not the end[3]"
69
+ )
70
+
71
+ assert [segment.text for segment in segments] == [
72
+ "The earlier timestamp is [1] not the end",
73
+ ]
74
+
75
+
76
+def test_parse_diarized_transcript_skips_empty_segments():
77
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
78
+ "[0][S01][1][2][S02]Complete[3]"
79
+ )
80
+
81
+ assert [(segment.speaker, segment.text) for segment in segments] == [
82
+ ("S02", "Complete"),
83
+ ]
84
+
85
+
86
+def test_parse_diarized_transcript_fails_closed_for_incomplete_output():
87
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
88
+ "[0][S01]Complete[1][2][S02]Incomplete"
89
+ )
90
+
91
+ assert segments == []
92
+
93
+
94
+def test_parse_diarized_transcript_fails_closed_for_trailing_text():
95
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
96
+ "[0][S01]Complete[1] trailing text"
97
+ )
98
+
99
+ assert segments == []
100
+
101
+
102
+def test_parse_diarized_transcript_preserves_overlong_timestamp_markers():
103
+ marker = f"[{'1' * 33}]"
104
+ segments = MossTranscribeDiarizeForConditionalGeneration.parse_diarized_transcript(
105
+ f"[0][S01]Value {marker}[1]"
106
+ )
107
+
108
+ assert [segment.text for segment in segments] == [f"Value {marker}"]
vllm/entrypoints/speech_to_text/base/protocol.py
+1
@@ -8,4 +8,5 @@ import torch
8
9
## Protocols for Audio
10
AudioResponseFormat: TypeAlias = Literal["json", "text", "srt", "verbose_json", "vtt"]
11
+TranscriptionResponseFormat: TypeAlias = AudioResponseFormat | Literal["diarized_json"]
12
_LONG_INFO = torch.iinfo(torch.long)
vllm/entrypoints/speech_to_text/base/serving.py
+52
-6
@@ -43,6 +43,7 @@ from vllm.utils.async_utils import make_async_with_semaphore, merge_async_iterat
43
44
from ..transcription.protocol import (
45
TranscriptionResponse,
46
+ TranscriptionResponseDiarized,
47
TranscriptionResponseStreamChoice,
48
TranscriptionResponseVerbose,
49
TranscriptionSegment,
@@ -56,7 +57,9 @@ from ..translation.protocol import (
57
TranslationStreamResponse,
58
)
59
59
-SpeechToTextResponse: TypeAlias = TranscriptionResponse | TranslationResponse
60
+SpeechToTextResponse: TypeAlias = (
61
+ TranscriptionResponse | TranslationResponse | TranscriptionResponseDiarized
62
+)
63
SpeechToTextResponseVerbose: TypeAlias = (
64
TranscriptionResponseVerbose | TranslationResponseVerbose
65
)
@@ -70,6 +73,7 @@ ResponseType: TypeAlias = (
73
TranscriptionResponse
74
| TranslationResponse
75
| TranscriptionResponseVerbose
76
+ | TranscriptionResponseDiarized
77
| TranslationResponseVerbose
78
)
79
@@ -448,10 +452,15 @@ class SpeechToTextBaseServing(GenerateBaseServing):
452
if self.engine_client.errored:
453
raise self.engine_client.dead_error
454
451
- if request.response_format not in ["text", "json", "verbose_json"]:
455
+ if request.response_format not in [
456
+ "text",
457
+ "json",
458
+ "verbose_json",
459
+ "diarized_json",
460
+ ]:
461
return self.create_error_response(
462
"Currently only support response_format: "
454
- "`text`, `json` or `verbose_json`"
463
+ "`text`, `json`, `verbose_json` or `diarized_json`"
464
)
465
466
if (
@@ -462,9 +471,20 @@ class SpeechToTextBaseServing(GenerateBaseServing):
471
f"Currently do not support verbose_json for {request.model}"
472
)
473
465
- if request.response_format == "verbose_json" and request.stream:
474
+ if (
475
+ request.response_format == "diarized_json"
476
+ and not self.model_cls.supports_diarized_transcription
477
+ ):
478
+ return self.create_error_response(
479
+ f"Currently do not support diarized_json for {request.model}"
480
+ )
481
+
482
+ if (
483
+ request.response_format in {"verbose_json", "diarized_json"}
484
+ and request.stream
485
+ ):
486
return self.create_error_response(
467
- "verbose_json format doesn't support streaming case"
487
+ f"{request.response_format} format doesn't support streaming case"
488
)
489
request_id = f"{self.task_type}-{self._base_request_id(raw_request)}"
490
@@ -634,7 +654,33 @@ class SpeechToTextBaseServing(GenerateBaseServing):
654
# rounded up as per openAI specs
655
"seconds": int(math.ceil(duration_s)),
656
}
637
- if request.response_format != "verbose_json":
657
+ if request.response_format == "diarized_json":
658
+ diarized_segments = self.model_cls.parse_diarized_transcript(text)
659
+ if not diarized_segments:
660
+ return self.create_error_response(
661
+ "Model output did not contain a valid diarized transcript"
662
+ )
663
+ final_response = cast(
664
+ T,
665
+ TranscriptionResponseDiarized(
666
+ duration=duration_s,
667
+ text=separator.join(
668
+ segment.text for segment in diarized_segments
669
+ ),
670
+ segments=[
671
+ {
672
+ "id": f"seg_{index}",
673
+ "start": segment.start,
674
+ "end": segment.end,
675
+ "text": segment.text,
676
+ "speaker": segment.speaker,
677
+ }
678
+ for index, segment in enumerate(diarized_segments)
679
+ ],
680
+ usage=usage,
681
+ ),
682
+ )
683
+ elif request.response_format != "verbose_json":
684
final_response = cast(
685
T, TranscriptionResponse(text=text, usage=usage)
686
)
vllm/entrypoints/speech_to_text/transcription/protocol.py
+24
-3
@@ -27,7 +27,7 @@ from vllm.sampling_params import (
27
)
28
from vllm.utils import random_uuid
29
30
-from ..base.protocol import _LONG_INFO, AudioResponseFormat
30
+from ..base.protocol import _LONG_INFO, TranscriptionResponseFormat
31
32
if TYPE_CHECKING:
33
import numpy as np
@@ -88,7 +88,7 @@ class TranscriptionRequest(OpenAIBaseModel):
88
should match the audio language.
89
"""
90
91
- response_format: AudioResponseFormat = Field(default="json")
91
+ response_format: TranscriptionResponseFormat = Field(default="json")
92
"""
93
The format of the output, in one of these options: `json`, `text`, `srt`,
94
`verbose_json`, or `vtt`.
@@ -400,6 +400,27 @@ class TranscriptionResponseVerbose(OpenAIBaseModel):
400
"""Extracted words and their corresponding timestamps."""
401
402
403
+class TranscriptionDiarizedSegment(OpenAIBaseModel):
404
+ """A speaker-attributed transcription segment."""
405
+
406
+ type: Literal["transcript.text.segment"] = "transcript.text.segment"
407
+ id: str
408
+ start: float
409
+ end: float
410
+ text: str
411
+ speaker: str
412
+
413
+
414
+class TranscriptionResponseDiarized(OpenAIBaseModel):
415
+ """OpenAI-compatible diarized transcription response."""
416
+
417
+ task: Literal["transcribe"] = "transcribe"
418
+ duration: float
419
+ text: str
420
+ segments: list[TranscriptionDiarizedSegment]
421
+ usage: TranscriptionUsageAudio
422
+
423
+
424
TranscriptionResponseVariant: TypeAlias = (
404
- TranscriptionResponse | TranscriptionResponseVerbose
425
+ TranscriptionResponse | TranscriptionResponseVerbose | TranscriptionResponseDiarized
426
)
vllm/entrypoints/speech_to_text/transcription/serving.py
+4
@@ -18,6 +18,7 @@ from ..base.serving import SpeechToTextBaseServing
18
from .protocol import (
19
TranscriptionRequest,
20
TranscriptionResponse,
21
+ TranscriptionResponseDiarized,
22
TranscriptionResponseStreamChoice,
23
TranscriptionResponseVerbose,
24
TranscriptionStreamResponse,
@@ -55,6 +56,7 @@ class OpenAIServingTranscription(SpeechToTextBaseServing):
56
) -> (
57
TranscriptionResponse
58
| TranscriptionResponseVerbose
59
+ | TranscriptionResponseDiarized
60
| AsyncGenerator[str, None]
61
| ErrorResponse
62
):
@@ -70,6 +72,8 @@ class OpenAIServingTranscription(SpeechToTextBaseServing):
72
response_class=(
73
TranscriptionResponseVerbose
74
if request.response_format == "verbose_json"
75
+ else TranscriptionResponseDiarized
76
+ if request.response_format == "diarized_json"
77
else TranscriptionResponse
78
),
79
stream_generator_method=self.transcription_stream_generator,
vllm/model_executor/models/interfaces.py
+23
@@ -11,6 +11,7 @@ from collections.abc import (
11
Sequence,
12
)
13
from contextlib import ExitStack, contextmanager, nullcontext
14
+from dataclasses import dataclass
15
from typing import (
16
TYPE_CHECKING,
17
Any,
@@ -71,6 +72,16 @@ The output embeddings must be one of the following formats:
72
"""
73
74
75
+@dataclass(frozen=True, slots=True)
76
+class DiarizedTranscriptionSegment:
77
+ """A timestamped, speaker-attributed segment produced by an ASR model."""
78
+
79
+ start: float
80
+ end: float
81
+ speaker: str
82
+ text: str
83
+
84
+
85
class StreamingTranscriptionPostProcessor:
86
"""Stateful streaming post-processor for transcription deltas."""
87
@@ -1133,6 +1144,9 @@ class SupportsTranscription(Protocol):
1144
Enables the segment timestamp option for supported models by setting this to `True`.
1145
"""
1146
1147
+ supports_diarized_transcription: ClassVar[bool] = False
1148
+ """Enables the ``diarized_json`` response format for the model."""
1149
+
1150
supports_explicit_language_detection: ClassVar[bool] = False
1151
"""
1152
Transcription models that require an explicit language detection step
@@ -1238,6 +1252,15 @@ class SupportsTranscription(Protocol):
1252
"""
1253
return text
1254
1255
+ @classmethod
1256
+ def parse_diarized_transcript(cls, text: str) -> list[DiarizedTranscriptionSegment]:
1257
+ """Parse the model-specific diarized transcript format.
1258
+
1259
+ Only models that set ``supports_diarized_transcription`` must override
1260
+ this method.
1261
+ """
1262
+ raise NotImplementedError
1263
+
1264
@classmethod
1265
def get_streaming_post_processor_cls(
1266
cls,
vllm/model_executor/models/moss_transcribe_diarize.py
+50
@@ -14,6 +14,7 @@ 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
@@ -23,6 +24,7 @@ from vllm.config.multimodal import BaseDummyOptions
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,
@@ -35,6 +37,8 @@ from vllm.model_executor.models.utils import (
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,
@@ -77,6 +81,11 @@ DEFAULT_MOSS_TRANSCRIBE_DIARIZE_PROMPT = (
81
"并在段末标注结束时间戳,以清晰标明该段语音范围。"
82
)
83
84
+_MOSS_DIARIZED_HEADER_RE = re.compile(
85
+ r"\[(?P<start>[0-9.]{1,32})\]\s*\[(?P<speaker>S[0-9]{1,15})\]"
86
+)
87
+_MOSS_DIARIZED_END_RE = re.compile(r"\[(?P<end>[0-9.]{1,32})\]\s*\Z")
88
+
89
90
class MossTranscribeDiarizeAudioInputs(TensorSchema):
91
"""
@@ -563,6 +572,7 @@ class MossTranscribeDiarizeForConditionalGeneration(
572
supports_transcription = True
573
supports_transcription_only = True
574
supports_segment_timestamp = False
575
+ supports_diarized_transcription = True
576
supported_languages = ISO639_1_SUPPORTED_LANGS
577
hf_to_vllm_mapper = WeightsMapper(
578
orig_to_new_prefix={
@@ -631,6 +641,46 @@ class MossTranscribeDiarizeForConditionalGeneration(
641
def post_process_output(cls, text: str) -> str:
642
return text.strip()
643
644
+ @classmethod
645
+ def parse_diarized_transcript(cls, text: str) -> list[DiarizedTranscriptionSegment]:
646
+ """Parse MOSS's canonical ``[start][Sxx]text[end]`` transcript."""
647
+ headers: list[tuple[re.Match[str], float, str]] = []
648
+ for match in _MOSS_DIARIZED_HEADER_RE.finditer(text):
649
+ start = parse_diarized_timestamp(match["start"])
650
+ speaker = parse_diarized_speaker(match["speaker"])
651
+ if start is not None and speaker is not None:
652
+ headers.append((match, start, speaker))
653
+
654
+ if not headers:
655
+ return []
656
+
657
+ segments: list[DiarizedTranscriptionSegment] = []
658
+ for index, (header, start, speaker) in enumerate(headers):
659
+ next_header_start = (
660
+ headers[index + 1][0].start() if index + 1 < len(headers) else len(text)
661
+ )
662
+ body = text[header.end() : next_header_start]
663
+ end_match = _MOSS_DIARIZED_END_RE.search(body)
664
+ if end_match is None:
665
+ return []
666
+
667
+ end = parse_diarized_timestamp(end_match["end"])
668
+ if end is None or end < start:
669
+ return []
670
+
671
+ segment_text = body[: end_match.start()].strip()
672
+ if segment_text:
673
+ segments.append(
674
+ DiarizedTranscriptionSegment(
675
+ start=start,
676
+ end=end,
677
+ speaker=speaker,
678
+ text=segment_text,
679
+ )
680
+ )
681
+
682
+ return segments
683
+
684
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
685
super().__init__()
686
self.config = vllm_config.model_config.hf_config
vllm/model_executor/models/utils.py
+22
@@ -1074,3 +1074,25 @@ def scatter_output_slices(
1074
sliced = output[offset : offset + n_tok]
1075
dest[idx] = sliced.clone() if clone else sliced
1076
offset += n_tok
1077
+
1078
+
1079
+def parse_diarized_timestamp(marker: str) -> float | None:
1080
+ if (
1081
+ not marker
1082
+ or not marker.isascii()
1083
+ or marker.count(".") > 1
1084
+ or not marker.replace(".", "").isdigit()
1085
+ ):
1086
+ return None
1087
+ return float(marker)
1088
+
1089
+
1090
+def parse_diarized_speaker(speaker: str) -> str | None:
1091
+ if (
1092
+ len(speaker) < 2
1093
+ or speaker[0] != "S"
1094
+ or not speaker[1:].isascii()
1095
+ or not speaker[1:].isdigit()
1096
+ ):
1097
+ return None
1098
+ return speaker