| 1 | # Speech-to-Text (Transcription/Translation) Support |
| 2 | |
| 3 | This document walks you through the steps to add support for speech-to-text (ASR) models to vLLM’s transcription and translation APIs by implementing [SupportsTranscription][vllm.model_executor.models.interfaces.SupportsTranscription]. |
| 4 | Please refer to the [supported models](../../models/supported_models.md#transcription) for further guidance. |
| 5 | |
| 6 | ## Update the base vLLM model |
| 7 | |
| 8 | It is assumed you have already implemented your model in vLLM according to the basic model guide. Extend your model with the [SupportsTranscription][vllm.model_executor.models.interfaces.SupportsTranscription] interface and implement the following class attributes and methods. |
| 9 | |
| 10 | ### `supported_languages` and `supports_transcription_only` |
| 11 | |
| 12 | Declare supported languages and capabilities: |
| 13 | |
| 14 | - The `supported_languages` mapping is validated at init time. |
| 15 | - Set `supports_transcription_only=True` if the model should not serve text generation (eg Whisper). |
| 16 | |
| 17 | ??? code "supported_languages and supports_transcription_only" |
| 18 | |
| 19 | ```python |
| 20 | from typing import ClassVar, Mapping, Literal |
| 21 | import numpy as np |
| 22 | import torch |
| 23 | from torch import nn |
| 24 | |
| 25 | from vllm.config import ModelConfig, SpeechToTextConfig |
| 26 | from vllm.inputs import PromptType |
| 27 | from vllm.model_executor.models.interfaces import SupportsTranscription |
| 28 | |
| 29 | class YourASRModel(nn.Module, SupportsTranscription): |
| 30 | # Map of ISO 639-1 language codes to language names |
| 31 | supported_languages: ClassVar[Mapping[str, str]] = { |
| 32 | "en": "English", |
| 33 | "it": "Italian", |
| 34 | # ... add more as needed |
| 35 | } |
| 36 | |
| 37 | # If your model only supports audio-conditioned generation |
| 38 | # (no text-only generation), enable this flag. |
| 39 | supports_transcription_only: ClassVar[bool] = True |
| 40 | ``` |
| 41 | |
| 42 | Provide an ASR configuration via [get_speech_to_text_config][vllm.model_executor.models.interfaces.SupportsTranscription.get_speech_to_text_config]. |
| 43 | |
| 44 | This is for controlling general behavior of the API when serving your model: |
| 45 | |
| 46 | ??? code "get_speech_to_text_config()" |
| 47 | |
| 48 | ```python |
| 49 | class YourASRModel(nn.Module, SupportsTranscription): |
| 50 | ... |
| 51 | |
| 52 | @classmethod |
| 53 | def get_speech_to_text_config( |
| 54 | cls, |
| 55 | model_config: ModelConfig, |
| 56 | task_type: Literal["transcribe", "translate"], |
| 57 | ) -> SpeechToTextConfig: |
| 58 | return SpeechToTextConfig( |
| 59 | sample_rate=16_000, |
| 60 | max_audio_clip_s=30, |
| 61 | # Set to None to disable server-side chunking if your |
| 62 | # model/processor handles it already |
| 63 | min_energy_split_window_size=None, |
| 64 | ) |
| 65 | ``` |
| 66 | |
| 67 | See [Audio preprocessing and chunking](#audio-preprocessing-and-chunking) for what each field controls. |
| 68 | |
| 69 | Implement the prompt construction via [get_generation_prompt][vllm.model_executor.models.interfaces.SupportsTranscription.get_generation_prompt]. The server builds a [SpeechToTextParams][vllm.config.speech_to_text.SpeechToTextParams] object that bundles the resampled waveform, task parameters, and request-specific options. Your model receives this single object and returns a valid [PromptType][vllm.inputs.llm.PromptType]. There are two common patterns: |
| 70 | |
| 71 | #### Multimodal LLM with audio embeddings (e.g., Voxtral, Gemma3n) |
| 72 | |
| 73 | Return a dict containing `multi_modal_data` with the audio, and either a `prompt` string or `prompt_token_ids`: |
| 74 | |
| 75 | ??? code "get_generation_prompt()" |
| 76 | |
| 77 | ```python |
| 78 | from vllm.config.speech_to_text import SpeechToTextParams |
| 79 | |
| 80 | class YourASRModel(nn.Module, SupportsTranscription): |
| 81 | ... |
| 82 | |
| 83 | @classmethod |
| 84 | def get_generation_prompt( |
| 85 | cls, |
| 86 | stt_params: SpeechToTextParams, |
| 87 | ) -> PromptType: |
| 88 | audio = stt_params.audio |
| 89 | stt_config = stt_params.stt_config |
| 90 | task_type = stt_params.task_type |
| 91 | |
| 92 | task_word = "Transcribe" if task_type == "transcribe" else "Translate" |
| 93 | prompt = ( |
| 94 | "<start_of_turn>user\n" |
| 95 | f"{task_word} this audio: <audio_soft_token>" |
| 96 | "<end_of_turn>\n<start_of_turn>model\n" |
| 97 | ) |
| 98 | |
| 99 | return { |
| 100 | "multi_modal_data": {"audio": (audio, stt_config.sample_rate)}, |
| 101 | "prompt": prompt, |
| 102 | } |
| 103 | ``` |
| 104 | |
| 105 | For further clarification on multi modal inputs, please refer to [Multi-Modal Inputs](../../features/multimodal_inputs.md). |
| 106 | |
| 107 | #### Encoder–decoder audio-only (e.g., Whisper) |
| 108 | |
| 109 | Return a dict with separate `encoder_prompt` and `decoder_prompt` entries: |
| 110 | |
| 111 | ??? code "get_generation_prompt()" |
| 112 | |
| 113 | ```python |
| 114 | from vllm.config.speech_to_text import SpeechToTextParams |
| 115 | |
| 116 | class YourASRModel(nn.Module, SupportsTranscription): |
| 117 | ... |
| 118 | |
| 119 | @classmethod |
| 120 | def get_generation_prompt( |
| 121 | cls, |
| 122 | stt_params: SpeechToTextParams, |
| 123 | ) -> PromptType: |
| 124 | audio = stt_params.audio |
| 125 | stt_config = stt_params.stt_config |
| 126 | language = stt_params.language |
| 127 | task_type = stt_params.task_type |
| 128 | request_prompt = stt_params.request_prompt |
| 129 | |
| 130 | if language is None: |
| 131 | raise ValueError("Language must be specified") |
| 132 | |
| 133 | prompt = { |
| 134 | "encoder_prompt": { |
| 135 | "prompt": "", |
| 136 | "multi_modal_data": { |
| 137 | "audio": (audio, stt_config.sample_rate), |
| 138 | }, |
| 139 | }, |
| 140 | "decoder_prompt": ( |
| 141 | (f"<|prev|>{request_prompt}" if request_prompt else "") |
| 142 | + f"<|startoftranscript|><|{language}|>" |
| 143 | + f"<|{task_type}|><|notimestamps|>" |
| 144 | ), |
| 145 | } |
| 146 | return cast(PromptType, prompt) |
| 147 | ``` |
| 148 | |
| 149 | ### `validate_language` (optional) |
| 150 | |
| 151 | Language validation via [validate_language][vllm.model_executor.models.interfaces.SupportsTranscription.validate_language] |
| 152 | |
| 153 | If your model requires a language and you want a default, override this method (see Whisper): |
| 154 | |
| 155 | ??? code "validate_language()" |
| 156 | |
| 157 | ```python |
| 158 | @classmethod |
| 159 | def validate_language(cls, language: str | None) -> str | None: |
| 160 | if language is None: |
| 161 | logger.warning( |
| 162 | "Defaulting to language='en'. If you wish to transcribe " |
| 163 | "audio in a different language, pass the `language` field " |
| 164 | "in the TranscriptionRequest." |
| 165 | ) |
| 166 | language = "en" |
| 167 | return super().validate_language(language) |
| 168 | ``` |
| 169 | |
| 170 | ### `get_num_audio_tokens` (optional) |
| 171 | |
| 172 | Token accounting for streaming via [get_num_audio_tokens][vllm.model_executor.models.interfaces.SupportsTranscription.get_num_audio_tokens] |
| 173 | |
| 174 | Provide a fast duration→token estimate to improve streaming usage statistics: |
| 175 | |
| 176 | ??? code "get_num_audio_tokens()" |
| 177 | |
| 178 | ```python |
| 179 | class YourASRModel(nn.Module, SupportsTranscription): |
| 180 | ... |
| 181 | |
| 182 | @classmethod |
| 183 | def get_num_audio_tokens( |
| 184 | cls, |
| 185 | audio_duration_s: float, |
| 186 | stt_config: SpeechToTextConfig, |
| 187 | model_config: ModelConfig, |
| 188 | ) -> int | None: |
| 189 | # Return None if unknown; otherwise return an estimate. |
| 190 | return int(audio_duration_s * stt_config.sample_rate // 320) # example |
| 191 | ``` |
| 192 | |
| 193 | ## Audio preprocessing and chunking |
| 194 | |
| 195 | The API server takes care of basic audio I/O and optional chunking before building prompts: |
| 196 | |
| 197 | - Resampling: Input audio is resampled to `SpeechToTextConfig.sample_rate` using `AudioResampler`. |
| 198 | - Chunking: If `SpeechToTextConfig.allow_audio_chunking` is True and the duration exceeds `max_audio_clip_s`, the server splits the audio into chunks and generates a prompt per chunk. There is no overlap between chunks, overlap_chunk_second controls the size of the search window used to find the split point. |
| 199 | - Energy-aware splitting: When `min_energy_split_window_size` is set, the server finds low-energy regions to minimize cutting within words. |
| 200 | |
| 201 | Relevant server logic: |
| 202 | |
| 203 | ??? code "_preprocess_speech_to_text()" |
| 204 | |
| 205 | ```python |
| 206 | # vllm/entrypoints/openai/speech_to_text.py |
| 207 | async def _preprocess_speech_to_text(...): |
| 208 | language = self.model_cls.validate_language(request.language) |
| 209 | ... |
| 210 | y, sr = load_audio(bytes_, sr=self.asr_config.sample_rate) |
| 211 | duration = get_audio_duration(y=y, sr=sr) |
| 212 | do_split_audio = (self.asr_config.allow_audio_chunking |
| 213 | and duration > self.asr_config.max_audio_clip_s) |
| 214 | chunks = [y] if not do_split_audio else self._split_audio(y, int(sr)) |
| 215 | prompts = [] |
| 216 | for chunk in chunks: |
| 217 | stt_params = request.build_stt_params( |
| 218 | audio=chunk, |
| 219 | stt_config=self.asr_config, |
| 220 | model_config=self.model_config, |
| 221 | task_type=self.task_type, |
| 222 | ) |
| 223 | prompt = self.model_cls.get_generation_prompt(stt_params) |
| 224 | prompts.append(prompt) |
| 225 | return prompts, duration |
| 226 | ``` |
| 227 | |
| 228 | ## Exposing tasks automatically |
| 229 | |
| 230 | vLLM automatically advertises transcription support if your model implements the interface: |
| 231 | |
| 232 | ```python |
| 233 | if supports_transcription(model): |
| 234 | if model.supports_transcription_only: |
| 235 | return ["transcription"] |
| 236 | supported_tasks.append("transcription") |
| 237 | ``` |
| 238 | |
| 239 | When enabled, the server initializes the transcription and translation handlers: |
| 240 | |
| 241 | ```python |
| 242 | state.openai_serving_transcription = OpenAIServingTranscription(...) if "transcription" in supported_tasks else None |
| 243 | state.openai_serving_translation = OpenAIServingTranslation(...) if "transcription" in supported_tasks else None |
| 244 | ``` |
| 245 | |
| 246 | No extra registration is required beyond having your model class available via the model registry and implementing `SupportsTranscription`. |
| 247 | |
| 248 | ## Examples in-tree |
| 249 | |
| 250 | - Whisper encoder–decoder (audio-only): [vllm/model_executor/models/whisper.py](../../../vllm/model_executor/models/whisper.py) |
| 251 | - Voxtral decoder-only (audio embeddings + LLM): [vllm/model_executor/models/voxtral.py](../../../vllm/model_executor/models/voxtral.py). Make sure to have installed `mistral-common[audio]`. |
| 252 | - Gemma3n decoder-only with fixed instruction prompt: [vllm/model_executor/models/gemma3n_mm.py](../../../vllm/model_executor/models/gemma3n_mm.py) |
| 253 | - Qwen3-Omni multimodal with audio embeddings: [vllm/model_executor/models/qwen3_omni_moe_thinker.py](../../../vllm/model_executor/models/qwen3_omni_moe_thinker.py) |
| 254 | |
| 255 | ## Test with the API |
| 256 | |
| 257 | Once your model implements `SupportsTranscription`, you can test the endpoints (API mimics OpenAI): |
| 258 | |
| 259 | - Transcription (ASR): |
| 260 | |
| 261 | ```bash |
| 262 | curl -s -X POST \ |
| 263 | -H "Authorization: Bearer $VLLM_API_KEY" \ |
| 264 | -H "Content-Type: multipart/form-data" \ |
| 265 | -F "file=@/path/to/audio.wav" \ |
| 266 | -F "model=$MODEL_ID" \ |
| 267 | http://localhost:8000/v1/audio/transcriptions |
| 268 | ``` |
| 269 | |
| 270 | - Translation (source → English unless otherwise supported): |
| 271 | |
| 272 | ```bash |
| 273 | curl -s -X POST \ |
| 274 | -H "Authorization: Bearer $VLLM_API_KEY" \ |
| 275 | -H "Content-Type: multipart/form-data" \ |
| 276 | -F "file=@/path/to/audio.wav" \ |
| 277 | -F "model=$MODEL_ID" \ |
| 278 | http://localhost:8000/v1/audio/translations |
| 279 | ``` |
| 280 | |
| 281 | Or check out more examples in [examples/speech_to_text](../../../examples/speech_to_text). |
| 282 | |
| 283 | !!! note |
| 284 | - If your model handles chunking internally (e.g., via its processor or encoder), set `min_energy_split_window_size=None` in the returned `SpeechToTextConfig` to disable server-side chunking. |
| 285 | - Implementing `get_num_audio_tokens` improves accuracy of streaming usage metrics (`prompt_tokens`) without an extra forward pass. |
| 286 | - For multilingual behavior, keep `supported_languages` aligned with actual model capabilities. |