[Bugfix] Accept 'speaker' as alias for 'voice' in TTS speech API (#2424)
Signed-off-by: marksverdhei <marksverdhei@hotmail.com> Signed-off-by: marksverdhai <249650165+marksverdhai@users.noreply.github.com> Co-authored-by: marksverdhai <249650165+marksverdhai@users.noreply.github.com>
This commit is contained in:
1 parent
408365fa0d
commit
feefdaee9f
4 files changed
+255
-16
No files matched your search
@@ -1028,6 +1028,181 @@ class TestTTSMethods:
|
||||
|
||||
assert result is None
|
||||
|
||||
# ── speaker field alias ──
|
||||
|
||||
def test_speaker_alias_accepted_as_voice(self):
|
||||
"""The 'speaker' JSON key should be accepted as an alias for 'voice'."""
|
||||
req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "speaker": "custom_voice"})
|
||||
assert req.voice == "custom_voice"
|
||||
|
||||
def test_voice_field_still_accepted(self):
|
||||
"""The canonical 'voice' JSON key should still work."""
|
||||
req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "voice": "custom_voice"})
|
||||
assert req.voice == "custom_voice"
|
||||
|
||||
def test_speaker_alias_in_base_task_with_uploaded_voice(self, speech_server):
|
||||
"""Using 'speaker' key with an uploaded voice should work for Base task."""
|
||||
speech_server.uploaded_speakers = {
|
||||
"utesf": {
|
||||
"name": "UTESF",
|
||||
"file_path": "/tmp/voice_samples/utesf.wav",
|
||||
"mime_type": "audio/wav",
|
||||
"ref_text": None,
|
||||
}
|
||||
}
|
||||
req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "speaker": "UTESF", "task_type": "Base"})
|
||||
assert req.voice == "UTESF"
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
result = speech_server._validate_qwen_tts_request(req)
|
||||
assert result is None
|
||||
|
||||
# ── uploaded voice with embedding ──
|
||||
|
||||
def test_build_tts_params_with_uploaded_voice_embedding(self, speech_server):
|
||||
"""Test _build_tts_params loads embedding for embedding-uploaded voices."""
|
||||
speech_server.uploaded_speakers = {
|
||||
"emb_voice": {
|
||||
"name": "emb_voice",
|
||||
"file_path": "/tmp/voice_samples/emb_voice.safetensors",
|
||||
"mime_type": "application/x-safetensors",
|
||||
"embedding_source": "direct",
|
||||
"embedding_dim": 1024,
|
||||
"cache_status": "ready",
|
||||
"cache_file": "/tmp/voice_samples/emb_voice.safetensors",
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"ryan", "vivian", "emb_voice"}
|
||||
|
||||
fake_embedding = [0.1] * 1024
|
||||
with patch.object(speech_server, "_get_uploaded_speaker_embedding") as mock_get_emb:
|
||||
mock_get_emb.return_value = fake_embedding
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="emb_voice")
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert "voice_clone_prompt" in params
|
||||
assert params["voice_clone_prompt"][0]["ref_spk_embedding"] == fake_embedding
|
||||
assert params["task_type"] == ["Base"]
|
||||
assert params["x_vector_only_mode"] == [True]
|
||||
assert "ref_audio" not in params
|
||||
|
||||
# ── regression: full flow from issue #1603 ──
|
||||
|
||||
def test_regression_1603_speaker_key_with_uploaded_audio_voice(self, speech_server):
|
||||
"""Regression test for #1603: upload audio voice, then invoke TTS with 'speaker' key.
|
||||
|
||||
Verifies the full validate → build_params pipeline works end-to-end.
|
||||
"""
|
||||
speech_server.uploaded_speakers = {
|
||||
"utesf": {
|
||||
"name": "UTESF",
|
||||
"file_path": "/tmp/voice_samples/utesf.wav",
|
||||
"mime_type": "audio/wav",
|
||||
"ref_text": "Hola, esta es una prueba.",
|
||||
}
|
||||
}
|
||||
# Parse with 'speaker' alias (the key users actually send)
|
||||
req = OpenAICreateSpeechRequest.model_validate(
|
||||
{"input": "Hello world", "speaker": "UTESF", "task_type": "Base"}
|
||||
)
|
||||
assert req.voice == "UTESF"
|
||||
|
||||
# Validation should pass (file exists)
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
err = speech_server._validate_qwen_tts_request(req)
|
||||
assert err is None, f"Validation failed: {err}"
|
||||
|
||||
# Build params should auto-set ref_audio from stored file
|
||||
with patch.object(speech_server, "_get_uploaded_audio_data") as mock_audio:
|
||||
mock_audio.return_value = "data:audio/wav;base64,ZmFrZQ=="
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert params["task_type"] == ["Base"]
|
||||
assert params["ref_audio"] == ["data:audio/wav;base64,ZmFrZQ=="]
|
||||
assert params["ref_text"] == ["Hola, esta es una prueba."]
|
||||
assert params["x_vector_only_mode"] == [False]
|
||||
assert params["speaker"] == ["utesf"]
|
||||
|
||||
def test_regression_1603_speaker_key_with_uploaded_embedding_voice(self, speech_server):
|
||||
"""Regression test for #1603: upload embedding voice, then invoke TTS with 'speaker' key.
|
||||
|
||||
Verifies embedding-uploaded voices are loaded as voice_clone_prompt, not as audio.
|
||||
"""
|
||||
speech_server.uploaded_speakers = {
|
||||
"myvoice": {
|
||||
"name": "myvoice",
|
||||
"file_path": "/tmp/voice_samples/myvoice.safetensors",
|
||||
"mime_type": "application/x-safetensors",
|
||||
"embedding_source": "direct",
|
||||
"embedding_dim": 1024,
|
||||
"cache_status": "ready",
|
||||
"cache_file": "/tmp/voice_samples/myvoice.safetensors",
|
||||
}
|
||||
}
|
||||
# Parse with 'speaker' alias
|
||||
req = OpenAICreateSpeechRequest.model_validate(
|
||||
{"input": "Hello world", "speaker": "myvoice", "task_type": "Base"}
|
||||
)
|
||||
assert req.voice == "myvoice"
|
||||
|
||||
# Validation should pass
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
err = speech_server._validate_qwen_tts_request(req)
|
||||
assert err is None, f"Validation failed: {err}"
|
||||
|
||||
# Build params should use embedding, NOT audio
|
||||
fake_emb = [0.1] * 1024
|
||||
with patch.object(speech_server, "_get_uploaded_speaker_embedding") as mock_emb:
|
||||
mock_emb.return_value = fake_emb
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert params["task_type"] == ["Base"]
|
||||
assert params["x_vector_only_mode"] == [True]
|
||||
assert "voice_clone_prompt" in params
|
||||
assert params["voice_clone_prompt"][0]["ref_spk_embedding"] == fake_emb
|
||||
# Must NOT have ref_audio — that would fail for safetensors files
|
||||
assert "ref_audio" not in params
|
||||
|
||||
def test_validate_rejects_embedding_voice_with_pending_cache(self, speech_server):
|
||||
"""Validation should reject embedding voices whose cache is not yet ready."""
|
||||
speech_server.uploaded_speakers = {
|
||||
"myvoice": {
|
||||
"name": "myvoice",
|
||||
"file_path": "/tmp/myvoice.safetensors",
|
||||
"mime_type": "application/x-safetensors",
|
||||
"embedding_source": "direct",
|
||||
"cache_status": "pending",
|
||||
"cache_file": None,
|
||||
}
|
||||
}
|
||||
req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "speaker": "myvoice", "task_type": "Base"})
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
err = speech_server._validate_qwen_tts_request(req)
|
||||
assert err is not None
|
||||
assert "not yet ready" in err
|
||||
|
||||
def test_x_vector_only_mode_not_overwritten_for_uploaded_embedding(self, speech_server):
|
||||
"""x_vector_only_mode set by uploaded embedding must not be overwritten by request field."""
|
||||
speech_server.uploaded_speakers = {
|
||||
"emb_voice": {
|
||||
"name": "emb_voice",
|
||||
"file_path": "/tmp/emb_voice.safetensors",
|
||||
"mime_type": "application/x-safetensors",
|
||||
"embedding_source": "direct",
|
||||
"embedding_dim": 1024,
|
||||
"cache_status": "ready",
|
||||
"cache_file": "/tmp/emb_voice.safetensors",
|
||||
}
|
||||
}
|
||||
fake_emb = [0.1] * 1024
|
||||
with patch.object(speech_server, "_get_uploaded_speaker_embedding") as mock_emb:
|
||||
mock_emb.return_value = fake_emb
|
||||
# Client explicitly sends x_vector_only_mode=False, but embedding requires True
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="emb_voice", x_vector_only_mode=False)
|
||||
params = speech_server._build_tts_params(req)
|
||||
|
||||
assert params["x_vector_only_mode"] == [True]
|
||||
assert "voice_clone_prompt" in params
|
||||
|
||||
def test_max_instructions_length_default(self, speech_server):
|
||||
"""Test default max instructions length (500) when no config provided."""
|
||||
# Fixture creates server with no CLI override and no TTS stage
|
||||
|
||||
Reference in new issue
Block a user