diff --git a/tests/e2e/online_serving/test_omnivoice_expansion.py b/tests/e2e/online_serving/test_omnivoice_expansion.py index ab35b74ea..78326f74c 100644 --- a/tests/e2e/online_serving/test_omnivoice_expansion.py +++ b/tests/e2e/online_serving/test_omnivoice_expansion.py @@ -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) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 1ba26096f..71020bd84 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -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 = [] diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index b2b55bc52..e309f1cb7 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -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)", diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 109777282..3612a2ff1 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -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)