[Bugfix][Qwen3TTS] Load speaker_id/voices from model configuration (#1079)

Signed-off-by: pablo <juanz9312@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-01-31 10:42:09 +08:00
1 parent 07f4c3fdd6
commit 2e6895ff0e
4 files changed
+89 -15

No files matched your search

@@ -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
@@ -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"}
@@ -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
+30 -13
View File
@@ -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":