add openai create speech endpoint (#305)

Signed-off-by: Bhanu068 <voutharoja.bhanu06@gmail.com>
Signed-off-by: Bhanu Prakash Voutharoja <59905694+Bhanu068@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-12-27 13:56:51 +08:00
1 parent fb57a6162f
commit 2f69748ee4
6 files changed
+527 -16

No files matched your search

@@ -0,0 +1,267 @@
# tests/entrypoints/openai/test_serving_speech.py
import logging
from inspect import Signature, signature
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
import torch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.outputs import OmniRequestOutput
logger = logging.getLogger(__name__)
class TestAudioMixin:
@pytest.fixture
def audio_mixin(self):
return AudioMixin()
def test_stereo_to_mono_conversion(self, audio_mixin):
stereo_tensor = np.random.rand(24000, 2).astype(np.float32)
audio_obj = CreateAudio(audio_tensor=stereo_tensor)
with (
patch.object(
audio_mixin, "_apply_speed_adjustment", side_effect=lambda tensor, speed, sr: (tensor, sr)
) as mock_speed,
patch("soundfile.write") as _,
):
audio_mixin.create_audio(audio_obj)
# Check that the tensor passed to speed adjustment is mono
mock_speed.assert_called_once()
adjusted_tensor = mock_speed.call_args[0][0]
assert len(adjusted_tensor) == 24000
@patch("librosa.effects.time_stretch")
def test_speed_adjustment(self, mock_time_stretch, audio_mixin):
mock_time_stretch.return_value = np.zeros(12000)
audio_tensor = np.random.rand(24000).astype(np.float32)
adjusted_audio, _ = audio_mixin._apply_speed_adjustment(audio_tensor, speed=2.0, sample_rate=24000)
mock_time_stretch.assert_called_with(y=audio_tensor, rate=2.0)
assert adjusted_audio.shape == (12000,)
@patch("soundfile.write")
def test_unsupported_format_fallback(self, mock_write, audio_mixin, caplog):
audio_tensor = np.random.rand(24000).astype(np.float32)
# Use a format that is not in the list of supported formats
audio_obj = CreateAudio(audio_tensor=audio_tensor, response_format="vorbis")
audio_mixin.create_audio(audio_obj)
# Should fall back to 'wav'
mock_write.assert_called_once()
write_kwargs = mock_write.call_args.kwargs
assert write_kwargs["format"] == "WAV"
def test_mono_audio_preservation(self, audio_mixin):
"""Test that mono (1D) audio tensors are processed correctly and passed to writer."""
mono_tensor = np.random.rand(24000).astype(np.float32)
audio_obj = CreateAudio(audio_tensor=mono_tensor)
with patch("soundfile.write") as mock_write:
audio_mixin.create_audio(audio_obj)
mock_write.assert_called_once()
# Verify the tensor passed to soundfile.write is the exact 1D tensor
output_tensor = mock_write.call_args[0][1]
assert output_tensor.ndim == 1
assert output_tensor.shape == (24000,)
assert np.array_equal(output_tensor, mono_tensor)
def test_stereo_audio_preservation(self, audio_mixin):
"""Test that stereo (2D) audio tensors are processed correctly and preserved."""
stereo_tensor = np.random.rand(24000, 2).astype(np.float32)
audio_obj = CreateAudio(audio_tensor=stereo_tensor)
with patch("soundfile.write") as mock_write:
audio_mixin.create_audio(audio_obj)
mock_write.assert_called_once()
# Verify the tensor passed to soundfile.write is the exact 2D tensor
output_tensor = mock_write.call_args[0][1]
assert output_tensor.ndim == 2
assert output_tensor.shape == (24000, 2)
assert np.array_equal(output_tensor, stereo_tensor)
def test_speed_adjustment_bypass(self, audio_mixin):
"""Test that speed=1.0 bypasses the expensive librosa time stretching."""
audio_tensor = np.random.rand(24000).astype(np.float32)
with patch("librosa.effects.time_stretch") as mock_time_stretch:
# speed=1.0 should return immediately without calling librosa
result, _ = audio_mixin._apply_speed_adjustment(audio_tensor, speed=1.0, sample_rate=24000)
mock_time_stretch.assert_not_called()
assert np.array_equal(result, audio_tensor)
@patch("librosa.effects.time_stretch")
def test_speed_adjustment_stereo_handling(self, mock_time_stretch, audio_mixin):
"""Test that speed adjustment is attempted on stereo inputs."""
stereo_tensor = np.random.rand(24000, 2).astype(np.float32)
# Mock return value representing a sped-up version (half length)
mock_time_stretch.return_value = np.zeros((12000, 2), dtype=np.float32)
result, _ = audio_mixin._apply_speed_adjustment(stereo_tensor, speed=2.0, sample_rate=24000)
mock_time_stretch.assert_called_once()
# Ensure the stereo tensor was passed to librosa
call_args = mock_time_stretch.call_args
assert np.array_equal(call_args.kwargs["y"], stereo_tensor)
assert call_args.kwargs["rate"] == 2.0
assert result.shape == (12000, 2)
# Helper to create mock model output for endpoint tests
def create_mock_audio_output_for_test(
request_id: str = "speech-mock-123",
) -> OmniRequestOutput:
class MockCompletionOutput:
def __init__(self, index: int = 0):
self.index = index
self.text = ""
self.token_ids = []
self.finish_reason = "stop"
self.stop_reason = None
self.logprobs = None
class MockRequestOutput:
def __init__(self, request_id: str, audio_tensor: torch.Tensor):
self.request_id = request_id
self.outputs = [MockCompletionOutput(index=0)]
self.multimodal_output = {"audio": audio_tensor}
self.finished = True
self.prompt_token_ids = None
self.encoder_prompt_token_ids = None
self.num_cached_tokens = None
self.prompt_logprobs = None
self.kv_transfer_params = None
num_samples = 24000
audio_tensor = torch.sin(torch.linspace(0, 440 * 2 * torch.pi, num_samples))
mock_request_output = MockRequestOutput(request_id=request_id, audio_tensor=audio_tensor)
return OmniRequestOutput(
stage_id=0,
final_output_type="audio",
request_output=mock_request_output,
)
@pytest.fixture
def test_app():
# Mock the engine client
mock_engine_client = MagicMock()
mock_engine_client.errored = False
async def mock_generate_fn(*args, **kwargs):
yield create_mock_audio_output_for_test(request_id=kwargs.get("request_id"))
mock_engine_client.generate = MagicMock(side_effect=mock_generate_fn)
mock_engine_client.default_sampling_params_list = [{}]
# Mock models to have an is_base_model method
mock_models = MagicMock()
mock_models.is_base_model.return_value = True
mock_request_logger = MagicMock()
speech_server = OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
models=mock_models,
request_logger=mock_request_logger,
)
# Patch the signature of create_speech to remove 'raw_request' for FastAPI route introspection
original_create_speech = speech_server.create_speech
_ = MagicMock(side_effect=original_create_speech)
sig = signature(original_create_speech)
new_parameters = [param for name, param in sig.parameters.items() if name != "raw_request"]
new_sig = Signature(parameters=new_parameters, return_annotation=sig.return_annotation)
async def awaitable_patched_create_speech(*args, **kwargs):
return await original_create_speech(*args, **kwargs)
awaitable_patched_create_speech.__signature__ = new_sig
speech_server.create_speech = awaitable_patched_create_speech
app = FastAPI()
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
return app
@pytest.fixture
def client(test_app):
return TestClient(test_app)
class TestSpeechAPI:
def test_create_speech_success(self, client):
payload = {
"input": "Hello world",
"model": "tts-model",
"voice": "alloy",
"response_format": "wav",
}
response = client.post("/v1/audio/speech", json=payload)
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert len(response.content) > 0
def test_create_speech_mp3_format(self, client):
payload = {
"input": "Hello world",
"model": "tts-model",
"voice": "alloy",
"response_format": "mp3",
}
response = client.post("/v1/audio/speech", json=payload)
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
assert len(response.content) > 0
def test_create_speech_invalid_format(self, client):
payload = {
"input": "Hello world",
"model": "tts-model",
"voice": "alloy",
"response_format": "invalid_format",
}
response = client.post("/v1/audio/speech", json=payload)
assert response.status_code == 422 # Unprocessable Entity
@patch("vllm_omni.entrypoints.openai.serving_speech.OmniOpenAIServingSpeech.create_audio")
def test_speed_parameter_is_used(self, mock_create_audio, test_app):
client = TestClient(test_app)
mock_audio_response = MagicMock()
mock_audio_response.audio_data = b"dummy_audio"
mock_audio_response.media_type = "audio/wav"
mock_create_audio.return_value = mock_audio_response
payload = {
"input": "This should be fast.",
"model": "tts-model",
"voice": "alloy",
"response_format": "wav",
"speed": 2.5,
}
client.post("/v1/audio/speech", json=payload)
mock_create_audio.assert_called_once()
call_args = mock_create_audio.call_args[0]
audio_obj = call_args[0]
assert isinstance(audio_obj, CreateAudio)
assert audio_obj.speed == 2.5
@@ -46,12 +46,14 @@ from vllm_omni.entrypoints.openai.image_api_utils import (
encode_image_base64,
parse_size,
)
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
from vllm_omni.entrypoints.openai.protocol.images import (
ImageData,
ImageGenerationRequest,
ImageGenerationResponse,
)
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
logger = init_logger(__name__)
@@ -432,6 +434,10 @@ async def omni_init_app_state(
log_error_stack=args.log_error_stack,
)
state.openai_serving_speech = OmniOpenAIServingSpeech(
engine_client, state.openai_serving_models, request_logger=request_logger
)
state.enable_server_load_tracking = args.enable_server_load_tracking
state.server_load_metrics = 0
@@ -440,6 +446,10 @@ def Omnichat(request: Request) -> OmniOpenAIServingChat | None:
return request.app.state.openai_serving_chat
def Omnispeech(request: Request) -> OmniOpenAIServingSpeech | None:
return request.app.state.openai_serving_speech
@router.post(
"/v1/chat/completions",
dependencies=[Depends(validate_json_request)],
@@ -496,6 +506,28 @@ async def create_chat_completion(request: ChatCompletionRequest, raw_request: Re
return StreamingResponse(content=generator, media_type="text/event-stream")
@router.post(
"/v1/audio/speech",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {"content": {"audio/*": {}}},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
@with_cancellation
@load_aware_call
async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request):
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
return await handler.create_speech(request, raw_request)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
# Image generation API endpoints
@@ -0,0 +1,93 @@
from io import BytesIO
import numpy as np
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio
try:
import soundfile
except ImportError:
soundfile = None
try:
import librosa
except ImportError:
librosa = None
logger = init_logger(__name__)
class AudioMixin:
"""Mixin class to add audio-related utilities."""
def create_audio(self, audio_obj: CreateAudio) -> AudioResponse:
"""Convert audio tensor to bytes in the specified format."""
audio_tensor = audio_obj.audio_tensor
sample_rate = audio_obj.sample_rate
response_format = audio_obj.response_format.lower()
stream_format = audio_obj.stream_format
base64_encode = audio_obj.base64_encode
speed = audio_obj.speed
if stream_format != "audio":
raise ValueError(f"Unsupported stream format: {stream_format}")
if soundfile is None:
raise ImportError(
"soundfile is required for audio generation. Please install it with: pip install soundfile"
)
if audio_tensor.ndim > 2:
raise ValueError(
f"Unsupported audio tensor dimension: {audio_tensor.ndim}. "
"Only mono (1D) and stereo (2D) are supported."
)
audio_tensor, sample_rate = self._apply_speed_adjustment(audio_tensor, speed, sample_rate)
supported_formats = {
"wav": ("WAV", "audio/wav", {}),
"pcm": ("RAW", "audio/pcm", {"subtype": "PCM_16"}),
"flac": ("FLAC", "audio/flac", {}),
"mp3": ("MP3", "audio/mpeg", {}),
"aac": ("AAC", "audio/aac", {}),
"opus": ("OGG", "audio/ogg", {"subtype": "OPUS"}),
}
if response_format not in supported_formats:
logger.warning(f"Unsupported response format '{response_format}', defaulting to 'wav'.")
response_format = "wav"
soundfile_format, media_type, kwargs = supported_formats[response_format]
with BytesIO() as buffer:
soundfile.write(buffer, audio_tensor, sample_rate, format=soundfile_format, **kwargs)
audio_data = buffer.getvalue()
if base64_encode:
import base64
audio_data = base64.b64encode(audio_data).decode("utf-8")
return AudioResponse(audio_data=audio_data, media_type=media_type)
def _apply_speed_adjustment(self, audio_tensor: np.ndarray, speed: float, sample_rate: int):
"""Apply speed adjustment to the audio tensor while preserving pitch."""
if speed == 1.0:
return audio_tensor, sample_rate
if librosa is None:
raise ImportError("librosa is required for speed adjustment. Please install it with: pip install librosa")
try:
# librosa.effects.time_stretch requires a float audio tensor.
if not np.issubdtype(audio_tensor.dtype, np.floating):
audio_tensor = audio_tensor.astype(np.float32)
stretched_audio = librosa.effects.time_stretch(y=audio_tensor, rate=speed)
return stretched_audio, sample_rate
except Exception as e:
logger.error(f"An error occurred during speed adjustment: {e}")
raise ValueError("Failed to apply speed adjustment.") from e
@@ -0,0 +1,42 @@
from typing import Literal
import numpy as np
from pydantic import BaseModel, Field, field_validator
class OpenAICreateSpeechRequest(BaseModel):
input: str
model: str | None = None
voice: Literal["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"]
instructions: str | None = None
response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"]
speed: float | None = Field(
default=1.0,
ge=0.25,
le=4.0,
)
stream_format: Literal["sse", "audio"] | None = "audio"
@field_validator("stream_format")
@classmethod
def validate_stream_format(cls, v: str) -> str:
if v == "sse":
raise ValueError("'sse' is not a supported stream_format yet. Please use 'audio'.")
return v
class CreateAudio(BaseModel):
audio_tensor: np.ndarray
sample_rate: int = 24000
response_format: str = "wav"
speed: float = 1.0
stream_format: Literal["sse", "audio"] | None = "audio"
base64_encode: bool = True
class Config:
arbitrary_types_allowed = True
class AudioResponse(BaseModel):
audio_data: bytes | str
media_type: str
+13 -16
View File
@@ -71,6 +71,8 @@ from vllm.tokenizers.mistral import (
from vllm.utils.collection_utils import as_list
from vllm_omni.entrypoints.chat_utils import parse_chat_messages_futures
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio
from vllm_omni.outputs import OmniRequestOutput
if TYPE_CHECKING:
@@ -79,7 +81,7 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
class OmniOpenAIServingChat(OpenAIServingChat):
class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
"""OpenAI-compatible chat serving for both LLM and Diffusion models.
This class extends OpenAIServingChat to support:
@@ -860,26 +862,21 @@ class OmniOpenAIServingChat(OpenAIServingChat):
final_res = omni_outputs.request_output
audio_tensor = final_res.multimodal_output["audio"].float().detach().cpu().numpy()
# Convert numpy array to WAV bytes and encode as base64
if soundfile is None:
raise ImportError(
"soundfile is required for audio generation. Please install it with: pip install soundfile"
)
# Default sample rate for TTS models (typically 24000 Hz)
# You may need to adjust this based on your model's configuration
sample_rate = 24000
# Ensure audio is 1D (flatten if needed)
if audio_tensor.ndim > 1:
audio_tensor = audio_tensor.flatten()
# Convert to WAV format and encode as base64
with BytesIO() as buffer:
soundfile.write(buffer, audio_tensor, sample_rate, format="WAV")
wav_bytes = buffer.getvalue()
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format="wav",
speed=1.0,
stream_format="audio",
base64_encode=True,
)
audio_base64 = base64.b64encode(wav_bytes).decode("utf-8")
audio_response: AudioResponse = self.create_audio(audio_obj)
audio_base64 = audio_response.audio_data
# Generate unique ID for the audio
audio_id = f"audio-{uuid.uuid4().hex[:16]}"
@@ -0,0 +1,80 @@
import asyncio
from fastapi import Request
from fastapi.responses import Response
from vllm.entrypoints.openai.serving_engine import OpenAIServing
from vllm.logger import init_logger
from vllm.utils import random_uuid
from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin
from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio, OpenAICreateSpeechRequest
from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
async def create_speech(
self,
request: OpenAICreateSpeechRequest,
raw_request: Request | None = None,
):
"""
Create Speech API similar to OpenAI's API.
See https://platform.openai.com/docs/api-reference/audio/createSpeech
for the API specification. This API mimics the OpenAI
Create Speech API.
NOTE: This implementation does not currently support streaming audio
generation. The full audio is generated before the response is sent.
"""
error_check_ret = await self._check_model(request)
if error_check_ret is not None:
logger.error("Error with model %s", error_check_ret)
return error_check_ret
if self.engine_client.errored:
raise self.engine_client.dead_error
request_id = f"speech-{random_uuid()}"
try:
prompt = {"prompt": request.input}
sampling_params_list = self.engine_client.default_sampling_params_list
generator = self.engine_client.generate(
prompt=prompt, request_id=request_id, sampling_params_list=sampling_params_list
)
final_output: OmniRequestOutput | None = None
async for res in generator:
final_output = res
if final_output is None:
return self.create_error_response("No output generated from the model.")
if final_output.final_output_type != "audio":
return self.create_error_response(f"Unexpected final output type: {final_output.final_output_type}")
audio_tensor = final_output.request_output.multimodal_output["audio"].float().detach().cpu().numpy()
audio_obj = CreateAudio(
audio_tensor=audio_tensor,
sample_rate=24000,
response_format=request.response_format,
speed=request.speed,
stream_format=request.stream_format,
base64_encode=False,
)
audio_response: AudioResponse = self.create_audio(audio_obj)
return Response(content=audio_response.audio_data, media_type=audio_response.media_type)
except asyncio.CancelledError:
return self.create_error_response("Client disconnected")
except ValueError as e:
# TODO: Use a vllm-specific Validation Error
return self.create_error_response(str(e))
except Exception as e:
return self.create_error_response(f"{e} {e.__cause__}")