From 2e6895ff0ed15eb5182ee0f191f75dbd2a90c27f Mon Sep 17 00:00:00 2001 From: Juan Pablo Zuluaga <46724788+JuanPZuluaga@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:42:09 +0100 Subject: [PATCH] [Bugfix][Qwen3TTS] Load speaker_id/voices from model configuration (#1079) Signed-off-by: pablo Co-authored-by: Hongsheng Liu Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com> --- examples/online_serving/qwen3_tts/README.md | 3 ++ .../openai_api/test_serving_speech.py | 39 ++++++++++++++++- vllm_omni/entrypoints/openai/api_server.py | 19 ++++++++ .../entrypoints/openai/serving_speech.py | 43 +++++++++++++------ 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/examples/online_serving/qwen3_tts/README.md b/examples/online_serving/qwen3_tts/README.md index d4626e7c0..00f7ec2c9 100644 --- a/examples/online_serving/qwen3_tts/README.md +++ b/examples/online_serving/qwen3_tts/README.md @@ -75,6 +75,9 @@ curl -X POST http://localhost:8000/v1/audio/speech \ "voice": "Vivian", "instructions": "Speak with great enthusiasm" }' --output excited.wav + +# List available voices in CustomVoice models +curl http://localhost:8000/v1/audio/voices ``` ## API Reference diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 675af4141..da73acb5a 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -201,6 +201,13 @@ def test_app(): app = FastAPI() app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None) + # Add list_voices endpoint + async def list_voices(): + speakers = sorted(speech_server.supported_speakers) if speech_server.supported_speakers else [] + return {"voices": speakers} + + app.add_api_route("/v1/audio/voices", list_voices, methods=["GET"]) + return app @@ -268,6 +275,11 @@ class TestSpeechAPI: assert isinstance(audio_obj, CreateAudio) assert audio_obj.speed == 2.5 + def test_list_voices_endpoint(self, client): + response = client.get("/v1/audio/voices") + assert response.status_code == 200 + assert "voices" in response.json() + class TestTTSMethods: """Unit tests for TTS validation and parameter building.""" @@ -311,9 +323,9 @@ class TestTTSMethods: req = OpenAICreateSpeechRequest(input="Hello", language="InvalidLang") assert "Invalid language" in speech_server._validate_tts_request(req) - # Invalid speaker + # When no speakers loaded, any voice is accepted (unconstrained) req = OpenAICreateSpeechRequest(input="Hello", voice="Invalid") - assert "Invalid speaker" in speech_server._validate_tts_request(req) + assert speech_server._validate_tts_request(req) is None # Valid request req = OpenAICreateSpeechRequest(input="Hello", voice="Vivian") @@ -342,3 +354,26 @@ class TestTTSMethods: assert params["speaker"] == ["Ryan"] assert params["language"] == ["English"] assert params["task_type"] == ["CustomVoice"] + + def test_load_supported_speakers(self): + """Test _load_supported_speakers.""" + mock_engine_client = MagicMock() + mock_engine_client.errored = False + mock_engine_client.stage_list = None + + # Mock talker_config with mixed-case speaker names + mock_talker_config = MagicMock() + mock_talker_config.spk_id = {"Ryan": 0, "Vivian": 1, "Aiden": 2} + mock_engine_client.model_config.hf_config.talker_config = mock_talker_config + + mock_models = MagicMock() + mock_models.is_base_model.return_value = True + + server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=MagicMock(), + ) + + # Verify speakers are normalized to lowercase + assert server.supported_speakers == {"ryan", "vivian", "aiden"} diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 556ed8cfe..e428a6276 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -762,6 +762,25 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e +@router.get( + "/v1/audio/voices", + responses={ + HTTPStatus.OK.value: {"model": dict}, + HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse}, + HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse}, + }, +) +async def list_voices(raw_request: Request): + """List available TTS voices/speakers from the loaded model.""" + handler = Omnispeech(raw_request) + if handler is None: + return base(raw_request).create_error_response(message="The model does not support Speech API") + + speakers = sorted(handler.supported_speakers) if handler.supported_speakers else [] + return JSONResponse(content={"voices": speakers}) + + # Health and Model endpoints for diffusion mode diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 223557aab..cb9f6f8a8 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -19,17 +19,6 @@ logger = init_logger(__name__) # TTS Configuration (currently supports Qwen3-TTS) _TTS_MODEL_STAGES: set[str] = {"qwen3_tts"} -_TTS_SPEAKERS: set[str] = { - "Vivian", - "Serena", - "Uncle_Fu", - "Dylan", - "Eric", - "Ryan", - "Aiden", - "Ono_Anna", - "Sohee", -} _TTS_LANGUAGES: set[str] = { "Auto", "Chinese", @@ -49,6 +38,30 @@ _TTS_MAX_NEW_TOKENS_MAX = 4096 class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Load supported speakers + self.supported_speakers = self._load_supported_speakers() + logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}") + + def _load_supported_speakers(self) -> set[str]: + """Load supported speakers (case-insensitive) from the model configuration.""" + try: + talker_config = self.engine_client.model_config.hf_config.talker_config + + # Check for speakers in either spk_id or speaker_id + for attr_name in ["spk_id", "speaker_id"]: + speakers_dict = getattr(talker_config, attr_name, None) + if speakers_dict and isinstance(speakers_dict, dict): + # Normalize to lowercase for case-insensitive matching + return {speaker.lower() for speaker in speakers_dict.keys()} + + logger.warning("No speakers found in talker_config (checked spk_id and speaker_id)") + except Exception as e: + logger.warning(f"Could not load speakers from model config: {e}") + + return set() + def _is_tts_model(self) -> bool: """Check if the current model is a supported TTS model.""" stage_list = getattr(self.engine_client, "stage_list", None) @@ -63,6 +76,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): """Validate TTS request parameters. Returns error message or None.""" task_type = request.task_type or "CustomVoice" + # Normalize voice to lowercase for case-insensitive matching + if request.voice is not None: + request.voice = request.voice.lower() + # Validate input is not empty if not request.input or not request.input.strip(): return "Input text cannot be empty" @@ -73,8 +90,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Validate speaker for CustomVoice task if task_type == "CustomVoice" and request.voice is not None: - if request.voice not in _TTS_SPEAKERS: - return f"Invalid speaker '{request.voice}'. Supported: {', '.join(sorted(_TTS_SPEAKERS))}" + if self.supported_speakers and request.voice not in self.supported_speakers: + return f"Invalid speaker '{request.voice}'. Supported: {', '.join(sorted(self.supported_speakers))}" # Validate Base task requirements if task_type == "Base":