[Frontend] Loosen Validation for TTS Models with No Uploaded Speakers (#5878)

Signed-off-by: Alex Brooks <albrooks@redhat.com>
This commit is contained in:
Alex Brooks authored and GitHub committed 2026-08-14 17:05:11 -06:00
1 parent 4b6d6d5cf7
commit fe54ecc388
4 files changed
+172 -13

No files matched your search

@@ -14,9 +14,10 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path, load_test_audio_data_url
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.entrypoints.openai.serving_speech import _DEFAULT_VOICE_NAME
try:
from transformers import HiggsAudioV2TokenizerModel # noqa: F401
@@ -140,3 +141,121 @@ class TestOmniVoiceVoiceCloning:
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
openai_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
@pytest.mark.parametrize("voice", [_DEFAULT_VOICE_NAME, {"id": _DEFAULT_VOICE_NAME}])
def test_speech_with_voice_param_accepted(omni_server, openai_client, voice) -> None:
"""The default voice should be accepted as both a plain string and a
VoiceID dict, even when the model has no uploaded speakers."""
request_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"voice": voice,
"response_format": "wav",
"timeout": 180.0,
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
openai_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_unknown_voice_rejected(omni_server, openai_client) -> None:
"""An unrecognized voice name should be rejected with a 400."""
request_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"voice": "nonexistent_voice",
"status_code": 400,
"err_message": "Invalid voice",
}
openai_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_voice_request_before_and_after_clone_registration(omni_server, openai_client) -> None:
"""The default voice should always be listed and usable, even after
a different cloned voice is registered."""
speech_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"voice": _DEFAULT_VOICE_NAME,
}
# 1) "default" should appear in the voices list before any uploads
voices = openai_client.send_audio_voices_list_http_request()[0]
assert _DEFAULT_VOICE_NAME in voices.json_body["voices"]
# 2) Speech with the default voice should succeed
openai_client.send_audio_speech_request(speech_config)
# 3) Register a cloned voice under a different name
register_config = {
"data": {"name": "bar", "consent": "test-consent"},
"files": {
"audio_sample": (
"clone_2.wav",
get_asset_path("qwen3_tts/clone_2.wav").read_bytes(),
"audio/wav",
),
},
}
openai_client.send_audio_voices_create_http_request(register_config)
# 4) "default" should still be listed alongside the new voice
voices = openai_client.send_audio_voices_list_http_request()[0]
assert _DEFAULT_VOICE_NAME in voices.json_body["voices"]
assert "bar" in voices.json_body["voices"]
# 5) Speech with the default voice should still succeed
openai_client.send_audio_speech_request(speech_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_registered_default_voice_overrides_placeholder(omni_server, openai_client) -> None:
"""When a real voice is uploaded under the default name, it should be
used as a cloned voice; after deletion, the placeholder behavior resumes."""
speech_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"voice": _DEFAULT_VOICE_NAME,
}
# 1) Placeholder default works before any registration
openai_client.send_audio_speech_request(speech_config)
# 2) Register a real voice under the default name
register_config = {
"data": {"name": _DEFAULT_VOICE_NAME, "consent": "test-consent"},
"files": {
"audio_sample": (
"clone_2.wav",
get_asset_path("qwen3_tts/clone_2.wav").read_bytes(),
"audio/wav",
),
},
}
openai_client.send_audio_voices_create_http_request(register_config)
# 3) After creating the voice with the default name, it's visible as an uploaded_voice
voices = openai_client.send_audio_voices_list_http_request()[0]
assert _DEFAULT_VOICE_NAME in voices.json_body["voices"]
uploaded_names = [v["name"] for v in voices.json_body["uploaded_voices"]]
assert _DEFAULT_VOICE_NAME in uploaded_names
openai_client.send_audio_speech_request(speech_config)
# 4) Delete the uploaded default voice
openai_client.send_audio_voices_delete_http_request(
{"name": _DEFAULT_VOICE_NAME},
)
# 5) Ensure the placeholder behavior is consistent, but it's not an uploaded voice now
voices = openai_client.send_audio_voices_list_http_request()[0]
assert _DEFAULT_VOICE_NAME in voices.json_body["voices"]
uploaded_names = [v["name"] for v in voices.json_body["uploaded_voices"]]
assert _DEFAULT_VOICE_NAME not in uploaded_names
openai_client.send_audio_speech_request(speech_config)
+1 -2
View File
@@ -1482,8 +1482,7 @@ async def list_voices(raw_request: Request):
status_code=HTTPStatus.NOT_FOUND,
)
# Get all speakers (both model built-in and uploaded)
speakers = sorted(handler.supported_speakers) if handler.supported_speakers else []
speakers = sorted(handler._get_available_voices())
# Get uploaded speakers details
uploaded_speakers = []
@@ -59,6 +59,16 @@ class OpenAICreateSpeechRequest(BaseModel):
validation_alias=AliasChoices("voice", "speaker"),
description="Speaker/voice to use. For Qwen3-TTS: vivian, ryan, aiden, etc.",
)
@field_validator("voice", mode="before")
@classmethod
def _normalize_voice(cls, voice: str | dict[str, str]) -> str:
if isinstance(voice, dict):
if "id" not in voice:
raise ValueError("voice dict must contain 'id'")
return voice["id"]
return voice
instructions: str | None = Field(
default=None,
description="Instructions for voice style/emotion (maps to 'instruct' for Qwen3-TTS)",
+41 -10
View File
@@ -134,6 +134,13 @@ _QWEN3_TTS_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key"
_TTS_MAX_INSTRUCTIONS_LENGTH = 500
_TTS_MAX_NEW_TOKENS_MAX = 4096
_MING_DEFAULT_PROMPT = MING_DEFAULT_PROMPT
_DEFAULT_VOICE_NAME = "default"
def _is_default_voice(voice, supported_speakers):
"""Check if a lowercased voice name is the placeholder default and not
an actual registered/built-in speaker."""
return voice == _DEFAULT_VOICE_NAME and voice not in supported_speakers
def _create_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16) -> bytes:
@@ -470,7 +477,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._audex_tta_rvq = None
self._voxcpm2_split_map: dict[int, list[int]] = {}
logger.info("Loaded %d supported speakers: %s", len(self.supported_speakers), sorted(self.supported_speakers))
if self.supported_speakers:
logger.info(
"Loaded %d supported speakers: %s", len(self.supported_speakers), sorted(self.supported_speakers)
)
else:
logger.info(
"No built-in speakers configured; only '%s' and uploaded voices are available", _DEFAULT_VOICE_NAME
)
# Batch configuration
self._batch_max_items: int = getattr(self.engine_client, "tts_batch_max_items", 32)
@@ -759,6 +773,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# 3. Default fallback
return _TTS_MAX_INSTRUCTIONS_LENGTH
def _get_available_voices(self) -> set[str]:
"""Get all voice names accepted by the API, including the placeholder default."""
return self.supported_speakers | self.uploaded_speakers.keys() | {_DEFAULT_VOICE_NAME}
def _load_supported_speakers(self) -> set[str]:
"""Load supported speakers (case-insensitive) from the model configuration."""
if self._tts_model_type == "ming_flash_omni_tts":
@@ -3557,6 +3575,20 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if not artifact_ready:
self._discard_ref_audio_artifact_warmup(request_id)
def _get_normalized_voice(self, voice: str | None) -> str | None:
"""Get the normalized voice to be used; currently this means that
the voice is a:
- lowercase str if it's a valid supported/uploaded speaker
- None if the voice is the placeholder default or not provided
"""
if voice is not None:
voice = voice.lower()
if voice not in self.uploaded_speakers and voice not in self.supported_speakers:
raise ValueError(
f"Invalid voice '{voice}'. Supported: {', '.join(sorted(self._get_available_voices()))}"
)
return voice
async def _create_diffusion_speech(
self,
request: OpenAICreateSpeechRequest,
@@ -3573,11 +3605,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if fmt_err:
return self._diffusion_error_response(fmt_err, status_code=400)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower not in self.uploaded_speakers and voice_lower not in self.supported_speakers:
all_voices = sorted(self.uploaded_speakers.keys() | self.supported_speakers)
raise ValueError(f"Invalid voice '{request.voice}'. Supported: {', '.join(all_voices) or 'none'}")
request.voice = self._get_normalized_voice(request.voice)
has_inline_ref_audio = request.ref_audio is not None
err = self._apply_uploaded_speaker(request)
@@ -3592,10 +3620,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if request.ref_text:
prompt["ref_text"] = request.ref_text
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
prompt["voice_name"] = voice_lower
prompt["voice_created_at"] = self._voice_created_at(voice_lower)
if request.voice in self.uploaded_speakers and not has_inline_ref_audio:
prompt["voice_name"] = request.voice
prompt["voice_created_at"] = self._voice_created_at(request.voice)
if request.language:
prompt["lang"] = request.language
if request.instructions:
@@ -3734,6 +3761,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
Raw audio streaming yields each Code2Wav chunk as raw bytes as soon as it is
decoded. Raw WAV streaming emits a header with placeholder size values first.
"""
if request.voice is not None:
if _is_default_voice(request.voice.lower(), self.supported_speakers):
request.voice = None
if self._diffusion_mode:
return await self._create_diffusion_speech(request)