# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import asyncio import io import math import time import zlib from collections.abc import AsyncGenerator, Callable, Set from concurrent.futures import ThreadPoolExecutor from functools import cached_property from typing import Final, Literal, TypeAlias, TypeVar, cast import numpy as np from fastapi import Request from transformers import PreTrainedTokenizerBase import vllm.envs as envs from vllm.engine.protocol import EngineClient from vllm.entrypoints.generate.base.serving import GenerateBaseServing from vllm.entrypoints.openai.engine.protocol import ( DeltaMessage, ErrorResponse, RequestResponseMetadata, UsageInfo, ) from vllm.entrypoints.openai.models.serving import OpenAIServingModels from vllm.entrypoints.serve.engine.typing import SpeechToTextRequest from vllm.entrypoints.serve.utils.api_utils import get_max_tokens from vllm.entrypoints.serve.utils.request_logger import RequestLogger from vllm.exceptions import VLLMValidationError from vllm.inputs import EncoderDecoderInput, EngineInput from vllm.logger import init_logger from vllm.logprobs import FlatLogprobs, Logprob from vllm.model_executor.models import SupportsTranscription from vllm.multimodal.audio import get_audio_duration, split_audio from vllm.multimodal.media.audio import load_audio from vllm.outputs import RequestOutput from vllm.renderers.inputs import DictPrompt, EncoderDecoderDictPrompt from vllm.renderers.inputs.preprocess import parse_enc_dec_prompt, parse_model_prompt from vllm.sampling_params import BeamSearchParams, SamplingParams from vllm.tokenizers import get_tokenizer from vllm.utils.async_utils import make_async_with_semaphore, merge_async_iterators from ..transcription.protocol import ( TranscriptionResponse, TranscriptionResponseStreamChoice, TranscriptionResponseVerbose, TranscriptionSegment, TranscriptionStreamResponse, ) from ..translation.protocol import ( TranslationResponse, TranslationResponseStreamChoice, TranslationResponseVerbose, TranslationSegment, TranslationStreamResponse, ) SpeechToTextResponse: TypeAlias = TranscriptionResponse | TranslationResponse SpeechToTextResponseVerbose: TypeAlias = ( TranscriptionResponseVerbose | TranslationResponseVerbose ) SpeechToTextSegment: TypeAlias = TranscriptionSegment | TranslationSegment T = TypeVar("T", bound=SpeechToTextResponse) V = TypeVar("V", bound=SpeechToTextResponseVerbose) S = TypeVar("S", bound=SpeechToTextSegment) ResponseType: TypeAlias = ( TranscriptionResponse | TranslationResponse | TranscriptionResponseVerbose | TranslationResponseVerbose ) logger = init_logger(__name__) def asr_inter_chunk_separator( language: str | None, no_space_languages: Set[str] ) -> str: """Space to insert between ASR text chunks for streaming and non-streaming join. Languages in ``no_space_languages`` (e.g. Chinese, Japanese) use an empty separator; others use a single ASCII space. """ return "" if language and language.lower() in no_space_languages else " " class SpeechToTextBaseServing(GenerateBaseServing): """Base class for speech-to-text operations like transcription and translation.""" def __init__( self, engine_client: EngineClient, models: OpenAIServingModels, *, request_logger: RequestLogger | None, return_tokens_as_token_ids: bool = False, task_type: Literal["transcribe", "translate"] = "transcribe", enable_force_include_usage: bool = False, ): super().__init__( engine_client=engine_client, models=models, request_logger=request_logger, return_tokens_as_token_ids=return_tokens_as_token_ids, ) self.default_sampling_params = self.model_config.get_diff_sampling_param() self.task_type: Final = task_type self.asr_config = self.model_cls.get_speech_to_text_config( self.model_config, task_type ) self.streaming_post_processor_cls = ( self.model_cls.get_streaming_post_processor_cls() ) self.enable_force_include_usage = enable_force_include_usage self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB self.max_audio_decode_duration_s: int = envs.VLLM_MAX_AUDIO_DECODE_DURATION_S if self.model_cls.supports_segment_timestamp: self.tokenizer = cast( PreTrainedTokenizerBase, get_tokenizer( tokenizer_name=self.model_config.tokenizer, tokenizer_mode=self.model_config.tokenizer_mode, ), ) if self.default_sampling_params: logger.info( "Overwriting default completion sampling param with: %s", self.default_sampling_params, ) # setup preprocess resources # we keep separate thread pool for frontend preprocessing instead # of reusing the one from Renderer which showed lower throughput # https://github.com/vllm-project/vllm/pull/44612#issuecomment-4662757781 num_audio_preprocess_workers = envs.VLLM_MAX_AUDIO_PREPROCESS_WORKERS self._preprocess_executor = ThreadPoolExecutor( max_workers=num_audio_preprocess_workers, thread_name_prefix="stt-preprocess", ) self._decode_and_chunk_speech_async = make_async_with_semaphore( self._decode_and_chunk_speech, executor=self._preprocess_executor ) @cached_property def model_cls(self) -> type[SupportsTranscription]: from vllm.model_executor.model_loader import get_model_cls model_cls = get_model_cls(self.model_config) return cast(type[SupportsTranscription], model_cls) def shutdown(self) -> None: self._preprocess_executor.shutdown(wait=False) def _decode_and_chunk_speech( self, audio_data: bytes, ) -> tuple[list[np.ndarray], float]: # Decode audio bytes. For container formats (MP4, M4A, WebM) that # soundfile cannot detect from a BytesIO stream, _load_audio_bytes # transparently falls back to ffmpeg via an in-memory fd. # NOTE resample to model SR here for efficiency. This is also a # pre-requisite for chunking, as it assumes Whisper SR. try: with io.BytesIO(audio_data) as buf: y, sr = load_audio( buf, sr=self.asr_config.sample_rate, max_duration_s=self.max_audio_decode_duration_s, ) except ValueError: raise except Exception as exc: raise ValueError("Invalid or unsupported audio file.") from exc duration = get_audio_duration(y=y, sr=sr) do_split_audio = self.asr_config.allow_audio_chunking and ( self.asr_config.max_audio_clip_s is not None and duration > self.asr_config.max_audio_clip_s ) if not do_split_audio: chunks = [y] else: assert self.asr_config.max_audio_clip_s is not None assert self.asr_config.min_energy_split_window_size is not None chunks = split_audio( audio_data=y, sample_rate=int(sr), max_clip_duration_s=self.asr_config.max_audio_clip_s, overlap_duration_s=self.asr_config.overlap_chunk_second, min_energy_window_size=self.asr_config.min_energy_split_window_size, ) return chunks, duration async def _detect_language( self, audio_chunk: np.ndarray, request_id: str, ) -> str: """Auto-detect the spoken language from an audio chunk. Delegates prompt construction and output parsing to the model class via ``get_language_detection_prompt`` and ``parse_language_detection_output``. """ prompt = self.model_cls.get_language_detection_prompt( audio_chunk, self.asr_config, ) allowed_token_ids = self.model_cls.get_language_token_ids( self.tokenizer, ) sampling_params = SamplingParams( max_tokens=1, temperature=0.0, allowed_token_ids=allowed_token_ids, ) result_generator = self.engine_client.generate( prompt, sampling_params, request_id, ) try: final_output: RequestOutput async for final_output in result_generator: if final_output.finished: break except asyncio.CancelledError: await asyncio.gather( self.engine_client.abort(request_id), return_exceptions=True, ) raise token_ids = list(final_output.outputs[0].token_ids) lang = self.model_cls.parse_language_detection_output( token_ids, self.tokenizer, ) logger.info("Auto-detected language: '%s'", lang) return lang async def _preprocess_speech_to_text( self, request: SpeechToTextRequest, audio_data: bytes, request_id: str, ) -> tuple[list[EngineInput], float]: # Validate request request.language = self.model_cls.validate_language(request.language) request.to_language = ( self.model_cls.validate_language(request.to_language) if request.to_language else None ) if len(audio_data) / 1024**2 > self.max_audio_filesize_mb: raise VLLMValidationError( "Maximum file size exceeded", parameter="audio_filesize_mb", value=len(audio_data) / 1024**2, ) # Run cpu intensive preprocess step in a separate thread pool executor. chunks, duration = await self._decode_and_chunk_speech_async(audio_data) if request.language is None and getattr( self.model_cls, "supports_explicit_language_detection", False ): # Auto-detect language from the first chunk. request.language = await self._detect_language( chunks[0], f"{request_id}-lang_detect" ) parsed_prompts: list[DictPrompt] = [] for chunk in chunks: stt_params = request.build_stt_params( audio=chunk, stt_config=self.asr_config, model_config=self.model_config, task_type=self.task_type, ) prompt = self.model_cls.get_generation_prompt(stt_params) parsed_prompt: DictPrompt if request.response_format == "verbose_json": parsed_prompt = parse_enc_dec_prompt(prompt) parsed_prompt = self._preprocess_verbose_prompt(parsed_prompt) else: parsed_prompt = parse_model_prompt(self.model_config, prompt) parsed_prompts.append(parsed_prompt) engine_inputs = await self.renderer.render_cmpl_async(parsed_prompts) return engine_inputs, duration def _preprocess_verbose_prompt(self, prompt: EncoderDecoderDictPrompt): dec_prompt = prompt["decoder_prompt"] if not (isinstance(dec_prompt, dict) and "prompt" in dec_prompt): raise VLLMValidationError( "Expected decoder_prompt to contain text", parameter="decoder_prompt", value=type(dec_prompt).__name__, ) dec_prompt["prompt"] = dec_prompt["prompt"].replace( "<|notimestamps|>", "<|0.00|>" ) return prompt @staticmethod def _get_decoder_prompt_len(engine_inputs: list[EngineInput]) -> int: """Get the length of the decoder prompt. Currently we need to offset by the decoder prompt length when running beam search because the mm encoder is not currently cached and runs on decode calls; because of this, we need to make sure the redundant encoder calls won't exceed the context :( FIXME (Alex) - this will be removed in the very near future once the encoder/decoder caching is implemented. """ input_len = 0 assert len(engine_inputs) > 0 first_input = engine_inputs[0] if first_input.get("type") == "enc_dec": first_input = cast(EncoderDecoderInput, first_input) input_len = len(first_input["decoder_prompt"]["prompt_token_ids"]) return input_len def _get_verbose_segments( self, tokens: tuple, log_probs: FlatLogprobs | list[dict[int, Logprob]], request: SpeechToTextRequest, segment_class: type[SpeechToTextSegment], start_time: float = 0, ) -> list[SpeechToTextSegment]: """ Convert tokens to verbose segments. This method expects the model to produce timestamps as tokens (similar to Whisper). If the tokens do not include timestamp information, the segments may not be generated correctly. Note: No_speech_prob field is not supported in this implementation and will be None. See docs for details. """ BASE_OFFSET = 0.02 init_token = self.tokenizer.encode("<|0.00|>", add_special_tokens=False)[0] if tokens[-1] == self.tokenizer.eos_token_id: tokens = tokens[:-1] tokens_with_start = (init_token,) + tokens segments: list[SpeechToTextSegment] = [] last_timestamp_start = 0 if tokens_with_start[-2] < init_token and tokens_with_start[-1] >= init_token: tokens_with_start = tokens_with_start + (tokens_with_start[-1],) avg_logprob = 0.0 for idx in range(1, len(tokens_with_start)): # Timestamp tokens (e.g., <|0.00|>) are assumed to be sorted. # If the ordering is violated, this slicing may produce incorrect results. token = tokens_with_start[idx] if token >= init_token and tokens_with_start[idx - 1] >= init_token: sliced_timestamp_tokens = tokens_with_start[last_timestamp_start:idx] start_timestamp = sliced_timestamp_tokens[0] - init_token end_timestamp = sliced_timestamp_tokens[-1] - init_token text = self.tokenizer.decode(sliced_timestamp_tokens[1:-1]) text_bytes = text.encode("utf-8") casting_segment = cast( SpeechToTextSegment, segment_class( id=len(segments), seek=start_time, start=start_time + BASE_OFFSET * start_timestamp, end=start_time + BASE_OFFSET * end_timestamp, temperature=request.temperature, text=text, # The compression ratio measures # how compressible the generated text is. # A higher ratio indicates more repetitive content, # which is a strong sign of hallucination in outputs. compression_ratio=len(text_bytes) / len(zlib.compress(text_bytes)), tokens=sliced_timestamp_tokens[1:-1], avg_logprob=avg_logprob / (idx - last_timestamp_start), ), ) segments.append(casting_segment) last_timestamp_start = idx avg_logprob = 0 else: avg_logprob += log_probs[idx - 1][token].logprob return segments async def _create_speech_to_text( self, audio_data: bytes, request: SpeechToTextRequest, raw_request: Request, response_class: type[ResponseType], stream_generator_method: Callable[..., AsyncGenerator[str, None]], ) -> T | V | AsyncGenerator[str, None] | ErrorResponse: """Base method for speech-to-text operations like transcription and translation.""" if request.stream and request.use_beam_search: return self.create_error_response( "Streaming is not currently supported with beam search" ) error_check_ret = await self._check_model(request) if error_check_ret is not None: return error_check_ret if not request.model: request.model = self.models.model_name() # If the engine is dead, raise the engine's DEAD_ERROR. # This is required for the streaming case, where we return a # success status before we actually start generating text :). if self.engine_client.errored: raise self.engine_client.dead_error if request.response_format not in ["text", "json", "verbose_json"]: return self.create_error_response( "Currently only support response_format: " "`text`, `json` or `verbose_json`" ) if ( request.response_format == "verbose_json" and not self.model_cls.supports_segment_timestamp ): return self.create_error_response( f"Currently do not support verbose_json for {request.model}" ) if request.response_format == "verbose_json" and request.stream: return self.create_error_response( "verbose_json format doesn't support streaming case" ) request_id = f"{self.task_type}-{self._base_request_id(raw_request)}" request_metadata = RequestResponseMetadata(request_id=request_id) if raw_request: raw_request.state.request_metadata = request_metadata lora_request = self._maybe_get_adapters(request) engine_inputs, duration_s = await self._preprocess_speech_to_text( request=request, audio_data=audio_data, request_id=request_id, ) # Schedule the request and get the result generator. max_model_len = self.model_config.max_model_len list_result_generator: list[AsyncGenerator[RequestOutput, None]] | None = None input_len = ( SpeechToTextBaseServing._get_decoder_prompt_len(engine_inputs) if request.use_beam_search else 0 ) # Unlike most decoder-only models, whisper generation length is not # constrained by the size of the input audio, which is mapped to a # fixed-size log-mel-spectogram. Still, allow for fewer tokens to be # generated by respecting the extra completion tokens arg. max_tokens = get_max_tokens( max_model_len, request.max_completion_tokens, input_len, self.default_sampling_params, ) if request.use_beam_search: sampling_params = request.to_beam_search_params( max_tokens, self.default_sampling_params ) else: sampling_params = request.to_sampling_params( max_tokens, self.default_sampling_params, ) if request.response_format == "verbose_json": sampling_params.logprobs = 1 engine_request_ids = [ request_id if len(engine_inputs) == 1 else f"{request_id}-{idx}" for idx in range(len(engine_inputs)) ] list_result_generator = [] try: for request_id_item, engine_input in zip(engine_request_ids, engine_inputs): self._log_inputs( request_id_item, engine_input, params=sampling_params, lora_request=lora_request, ) trace_headers = ( None if raw_request is None else await self._get_trace_headers(raw_request.headers) ) if isinstance(sampling_params, BeamSearchParams): generator = self.beam_search( prompt=engine_input, params=sampling_params, request_id=request_id_item, lora_request=lora_request, trace_headers=trace_headers, ) else: generator = self.engine_client.generate( engine_input, sampling_params, request_id_item, lora_request=lora_request, trace_headers=trace_headers, ) list_result_generator.append(generator) except asyncio.CancelledError: logger.info( "Request %s cancelled; aborting %d transcription engine request(s).", request_id, len(engine_request_ids), ) await asyncio.gather( self.engine_client.abort(engine_request_ids), return_exceptions=True, ) raise separator = asr_inter_chunk_separator( request.language, self.model_cls.no_space_languages ) if request.stream: return stream_generator_method( request, list_result_generator, request_id, request_metadata, duration_s, separator, ) # Non-streaming response. try: assert list_result_generator is not None chunk_segment_parts: list[list[SpeechToTextSegment]] = [ [] for _ in list_result_generator ] chunk_text_parts: list[list[str]] = [[] for _ in list_result_generator] segments_types: dict[str, type[SpeechToTextSegment]] = { "transcribe": TranscriptionSegment, "translate": TranslationSegment, } segment_class: type[SpeechToTextSegment] = segments_types[self.task_type] chunk_size_in_s = self.asr_config.max_audio_clip_s if chunk_size_in_s is None: assert len(list_result_generator) == 1, ( "`max_audio_clip_s` is set to None, audio cannot be chunked" ) result_generator = merge_async_iterators(*list_result_generator) async for idx, op in result_generator: start_time = ( float(idx * chunk_size_in_s) if chunk_size_in_s is not None else 0.0 ) if request.response_format == "verbose_json": assert op.outputs[0].logprobs segments: list[SpeechToTextSegment] = self._get_verbose_segments( tokens=tuple(op.outputs[0].token_ids), segment_class=segment_class, request=request, start_time=start_time, log_probs=op.outputs[0].logprobs, ) chunk_segment_parts[idx].extend(segments) chunk_text_parts[idx].extend([seg.text for seg in segments]) else: raw_text = op.outputs[0].text chunk_text_parts[idx].append( self.model_cls.post_process_output(raw_text) ) total_segments = [ segment for segment_parts in chunk_segment_parts for segment in segment_parts ] text_parts = [text for text_part in chunk_text_parts for text in text_part] text = separator.join(text_parts) if self.task_type == "transcribe": final_response: ResponseType # add usage in TranscriptionResponse. usage = { "type": "duration", # rounded up as per openAI specs "seconds": int(math.ceil(duration_s)), } if request.response_format != "verbose_json": final_response = cast( T, TranscriptionResponse(text=text, usage=usage) ) else: final_response = cast( V, TranscriptionResponseVerbose( text=text, language=request.language, duration=str(duration_s), segments=total_segments, ), ) else: # no usage in response for translation task if request.response_format != "verbose_json": final_response = cast(T, TranslationResponse(text=text)) else: final_response = cast( V, TranslationResponseVerbose( text=text, language=request.language, duration=str(duration_s), segments=total_segments, ), ) return final_response except asyncio.CancelledError: logger.info( "Request %s cancelled; aborting %d transcription engine request(s).", request_id, len(engine_request_ids), ) await asyncio.gather( self.engine_client.abort(engine_request_ids), return_exceptions=True, ) raise async def _speech_to_text_stream_generator( self, request: SpeechToTextRequest, list_result_generator: list[AsyncGenerator[RequestOutput, None]], request_id: str, request_metadata: RequestResponseMetadata, audio_duration_s: float, chunk_object_type: Literal["translation.chunk", "transcription.chunk"], response_stream_choice_class: type[TranscriptionResponseStreamChoice] | type[TranslationResponseStreamChoice], stream_response_class: type[TranscriptionStreamResponse] | type[TranslationStreamResponse], separator: str, ) -> AsyncGenerator[str, None]: created_time = int(time.time()) model_name = request.model completion_tokens = 0 num_prompt_tokens = 0 include_usage = self.enable_force_include_usage or request.stream_include_usage include_continuous_usage = ( request.stream_continuous_usage_stats if include_usage and request.stream_continuous_usage_stats else False ) try: for result_generator in list_result_generator: beginning_of_chunk = True post_processor = self.streaming_post_processor_cls() async for res in result_generator: # On first result. if res.prompt_token_ids is not None: num_prompt_tokens = len(res.prompt_token_ids) if audio_tokens := self.model_cls.get_num_audio_tokens( audio_duration_s, self.asr_config, self.model_config ): num_prompt_tokens += audio_tokens # We need to do it here, because if there are exceptions in # the result_generator, it needs to be sent as the FIRST # response (by the try...catch). # Just one output (n=1) supported. assert len(res.outputs) == 1 output = res.outputs[0] output_text = post_processor.process_delta( output.text, output.finish_reason is not None ) # dont add separator to the first chunk if ( result_generator is not list_result_generator[0] and beginning_of_chunk and output_text ): output_text = separator + output_text beginning_of_chunk = False if output.finish_reason is None and not output_text: completion_tokens += len(output.token_ids) continue delta_message = DeltaMessage(content=output_text) completion_tokens += len(output.token_ids) if output.finish_reason is None: # Still generating, send delta update. choice_data = response_stream_choice_class(delta=delta_message) else: # Model is finished generating. choice_data = response_stream_choice_class( delta=delta_message, finish_reason=output.finish_reason, stop_reason=output.stop_reason, ) chunk = stream_response_class( id=request_id, object=chunk_object_type, created=created_time, choices=[choice_data], model=model_name, ) # handle usage stats if requested & if continuous if include_continuous_usage: chunk.usage = UsageInfo( prompt_tokens=num_prompt_tokens, completion_tokens=completion_tokens, total_tokens=num_prompt_tokens + completion_tokens, ) data = chunk.model_dump_json(exclude_unset=True) yield f"data: {data}\n\n" # Once the final token is handled, if stream_options.include_usage # is sent, send the usage. if include_usage: final_usage = UsageInfo( prompt_tokens=num_prompt_tokens, completion_tokens=completion_tokens, total_tokens=num_prompt_tokens + completion_tokens, ) final_usage_chunk = stream_response_class( id=request_id, object=chunk_object_type, created=created_time, choices=[], model=model_name, usage=final_usage, ) final_usage_data = final_usage_chunk.model_dump_json( exclude_unset=True, exclude_none=True ) yield f"data: {final_usage_data}\n\n" # report to FastAPI middleware aggregate usage across all choices request_metadata.final_usage_info = UsageInfo( prompt_tokens=num_prompt_tokens, completion_tokens=completion_tokens, total_tokens=num_prompt_tokens + completion_tokens, ) except Exception as e: logger.exception("Error in %s stream generator.", self.task_type) data = self.create_streaming_error_response(e) yield f"data: {data}\n\n" # Send the final done message after all response.n are finished yield "data: [DONE]\n\n"