Files
vllm-omni/tests/e2e/online_serving/test_omnivoice_expansion.py
T

262 lines
9.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E Online tests for OmniVoice TTS model via /v1/audio/speech endpoint.
Tests verify that the OmniVoice model generates valid audio when
accessed through the standard OpenAI-compatible speech API.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
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
_HAS_VOICE_CLONE = True
except ImportError:
_HAS_VOICE_CLONE = False
pytestmark = [pytest.mark.slow, pytest.mark.tts]
MODEL = "k2-fsa/OmniVoice"
STAGE_CONFIG = get_deploy_config_path("omnivoice.yaml")
EXTRA_ARGS = [
"--trust-remote-code",
"--disable-log-stats",
]
TEST_PARAMS = [
OmniServerParams(
model=MODEL,
stage_config_path=STAGE_CONFIG,
server_args=EXTRA_ARGS,
)
]
# Lower this in ``request_config`` via ``min_audio_bytes`` if a run produces legitimately short WAVs.
_DEFAULT_MIN_AUDIO_BYTES = 5000
REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
def get_prompt(prompt_type="text"):
prompts = {
"text": "The weather is nice today, perfect for a walk in the park.",
}
return prompts.get(prompt_type, prompts["text"])
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestOmniVoiceTTS:
"""E2E tests for OmniVoice TTS model."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speech_auto_voice(self, omni_server, openai_client) -> None:
"""Test auto voice TTS generation (text only, no reference audio)."""
request_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"response_format": "wav",
"timeout": 180.0,
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
openai_client.send_audio_speech_request(request_config)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestOmniVoiceSeed:
"""E2E tests for OmniVoice Seed Params."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speech_auto_voice_seed_deterministic(self, omni_server, openai_client) -> None:
cfg = {
"model": omni_server.model,
"input": get_prompt("text"),
"response_format": "wav",
"seed": 42,
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
r1 = openai_client.send_audio_speech_request(cfg)[0]
r2 = openai_client.send_audio_speech_request(cfg)[0]
assert r1.audio_bytes == r2.audio_bytes
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_speech_auto_voice_seed_non_deterministic(self, omni_server, openai_client) -> None:
cfg = {
"model": omni_server.model,
"input": get_prompt("text"),
"response_format": "wav",
"min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES,
}
cfg1 = {**cfg, "seed": 42}
cfg2 = {**cfg, "seed": 43}
r1 = openai_client.send_audio_speech_request(cfg1)[0]
r2 = openai_client.send_audio_speech_request(cfg2)[0]
assert r1.audio_bytes != r2.audio_bytes
@pytest.mark.skipif(not _HAS_VOICE_CLONE, reason="Voice cloning requires transformers>=5.3.0")
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestOmniVoiceVoiceCloning:
"""E2E tests for OmniVoice voice cloning functionality."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voice_clone_ref_audio_only(self, omni_server, openai_client) -> None:
"""Test voice cloning with ref_audio only (x_vector mode)."""
request_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"ref_audio": REF_AUDIO_URL,
"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)
def test_voice_clone_ref_audio_and_text(self, omni_server, openai_client) -> None:
"""Test voice cloning with ref_audio and ref_text (in-context mode)."""
request_config = {
"model": omni_server.model,
"input": get_prompt("text"),
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
"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)
@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)