[Frontend] Loosen Validation for TTS Models with No Uploaded Speakers (#5878)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
This commit is contained in:
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)
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user