[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:
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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user