[Test] Unify L2/L3 test layout, Buildkite steps, and test helpers (#2556)
Signed-off-by: wangyu <410167048@qq.com> Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com> Co-authored-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
This commit is contained in:
1 parent
b85833eeec
commit
6f2ad7b403
134 files changed
+4904
-4185
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
GET ``/v1/audio/voices`` and ``/v1/models`` against a running omni server (TTS model).
|
||||
|
||||
Migrated from ``tests/e2e/online_serving/test_voxtral_tts.py`` to colocate with other OpenAPI entrypoint tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
MODEL = "mistralai/Voxtral-4B-TTS-2603"
|
||||
STAGE_CONFIG = get_deploy_config_path("voxtral_tts.yaml")
|
||||
EXTRA_ARGS = ["--trust-remote-code", "--enforce-eager", "--disable-log-stats"]
|
||||
TEST_PARAMS = [OmniServerParams(model=MODEL, stage_config_path=STAGE_CONFIG, server_args=EXTRA_ARGS)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
|
||||
class TestOpenAIAudioListEndpoints:
|
||||
"""HTTP surface checks for OpenAI-compatible meta routes (voices, models)."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
def test_list_voices_endpoint(self, omni_server) -> None:
|
||||
"""``GET /v1/audio/voices`` returns a non-empty ``voices`` list."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/voices"
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "voices" in data
|
||||
assert isinstance(data["voices"], list)
|
||||
assert len(data["voices"]) > 0
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
def test_models_endpoint(self, omni_server) -> None:
|
||||
"""``GET /v1/models`` returns at least one model."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/models"
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "data" in data
|
||||
assert len(data["data"]) > 0
|
||||
@@ -0,0 +1,242 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
E2E tests for speaker_embedding API parameter.
|
||||
|
||||
Tests Base models (0.6B-Base and 1.7B-Base) which support voice cloning
|
||||
via pre-computed ECAPA-TDNN embeddings, bypassing ref_audio extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
|
||||
|
||||
import struct
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
MODEL_BASE = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
MODEL_BASE_1_7B = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
|
||||
# A synthetic 1024-dim speaker embedding (all 0.1 — not a real voice, but
|
||||
# exercises the full code path through the talker's _build_prompt_embeds).
|
||||
DUMMY_EMBEDDING_1024 = [0.1] * 1024
|
||||
DUMMY_EMBEDDING_2048 = [0.1] * 2048
|
||||
|
||||
SYN_TEXT = "Hello."
|
||||
MIN_AUDIO_BYTES = 2000
|
||||
# Limit generation to keep tests fast (dummy embeddings produce nonsensical
|
||||
# output that may never hit a natural stop token).
|
||||
MAX_NEW_TOKENS = 256
|
||||
|
||||
|
||||
base_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL_BASE,
|
||||
stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
|
||||
server_args=["--trust-remote-code", "--enforce-eager", "--disable-log-stats"],
|
||||
),
|
||||
id="qwen3-tts-0.6b-base",
|
||||
)
|
||||
]
|
||||
|
||||
base_1_7b_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL_BASE_1_7B,
|
||||
stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
|
||||
server_args=["--trust-remote-code", "--enforce-eager", "--disable-log-stats"],
|
||||
),
|
||||
id="qwen3-tts-1.7b-base",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def verify_wav_audio(content: bytes) -> bool:
|
||||
if len(content) < 44:
|
||||
return False
|
||||
return content[:4] == b"RIFF" and content[8:12] == b"WAVE"
|
||||
|
||||
|
||||
def assert_not_silence(pcm_bytes: bytes):
|
||||
"""Assert PCM16 samples are not all identical (e.g. all-silence)."""
|
||||
samples = struct.unpack(f"<{len(pcm_bytes) // 2}h", pcm_bytes)
|
||||
unique = set(samples)
|
||||
assert len(unique) > 1, f"All-silence detected: {len(samples)} samples, unique values: {unique}"
|
||||
|
||||
|
||||
# ── 0.6B-Base model tests ──
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", base_server_params, indirect=True)
|
||||
class TestSpeakerEmbeddingBase:
|
||||
"""Speaker embedding tests against the 0.6B-Base model (supports Base task)."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_speaker_embedding_produces_audio(self, omni_server) -> None:
|
||||
"""speaker_embedding with Base task produces valid WAV audio."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_1024,
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "wav",
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
assert response.headers.get("content-type") == "audio/wav"
|
||||
assert verify_wav_audio(response.content), "Response is not valid WAV"
|
||||
assert len(response.content) > MIN_AUDIO_BYTES, f"Audio too small: {len(response.content)} bytes"
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_speaker_embedding_pcm_not_silence(self, omni_server) -> None:
|
||||
"""speaker_embedding PCM output contains real audio, not all-silence."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_1024,
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "pcm",
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
assert len(response.content) > MIN_AUDIO_BYTES
|
||||
assert_not_silence(response.content)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_speaker_embedding_streaming(self, omni_server) -> None:
|
||||
"""speaker_embedding works with streaming PCM output."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_1024,
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "pcm",
|
||||
"stream": True,
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
assert "audio/pcm" in response.headers.get("content-type", "")
|
||||
assert len(response.content) > MIN_AUDIO_BYTES
|
||||
assert_not_silence(response.content)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_speaker_embedding_mutually_exclusive_with_ref_audio(self, omni_server) -> None:
|
||||
"""Sending both speaker_embedding and ref_audio returns 400."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_1024,
|
||||
"ref_audio": "https://example.com/audio.wav",
|
||||
"response_format": "wav",
|
||||
}
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.text}"
|
||||
assert "mutually exclusive" in response.text
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_speaker_embedding_empty_rejected(self, omni_server) -> None:
|
||||
"""Empty speaker_embedding list returns 400."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": [],
|
||||
"response_format": "wav",
|
||||
}
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.text}"
|
||||
assert "non-empty" in response.text
|
||||
|
||||
|
||||
# ── 1.7B-Base model tests (2048-dim embeddings) ──
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", base_1_7b_server_params, indirect=True)
|
||||
class TestSpeakerEmbedding1_7B:
|
||||
"""Speaker embedding tests against the 1.7B-Base model (2048-dim embeddings)."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_2048_dim_embedding_produces_audio(self, omni_server) -> None:
|
||||
"""2048-dim speaker_embedding with 1.7B-Base model produces valid WAV audio."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_2048,
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "wav",
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
assert response.headers.get("content-type") == "audio/wav"
|
||||
assert verify_wav_audio(response.content), "Response is not valid WAV"
|
||||
assert len(response.content) > MIN_AUDIO_BYTES, f"Audio too small: {len(response.content)} bytes"
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_1024_dim_on_1_7b_model_rejected(self, omni_server) -> None:
|
||||
"""1024-dim speaker_embedding on a 1.7B model (expects 2048) should fail gracefully."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech"
|
||||
payload = {
|
||||
"model": omni_server.model,
|
||||
"input": SYN_TEXT,
|
||||
"task_type": "Base",
|
||||
"speaker_embedding": DUMMY_EMBEDDING_1024,
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "wav",
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
# Wrong dimensions should produce an error, not silent garbage.
|
||||
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.text}"
|
||||
assert "speaker_embedding has 1024 dimensions" in response.text
|
||||
assert "expected 2048" in response.text
|
||||
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
E2E online tests for Qwen3-TTS WebSocket streaming speech.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
|
||||
|
||||
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
|
||||
|
||||
|
||||
tts_ws_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
|
||||
server_args=["--trust-remote-code", "--enforce-eager", "--disable-log-stats"],
|
||||
env_dict={"VLLM_DISABLE_COMPILE_CACHE": "1"},
|
||||
),
|
||||
id="qwen3-tts-ws-customvoice",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def _run_ws_session(host: str, port: int, model: str) -> dict:
|
||||
uri = f"ws://{host}:{port}/v1/audio/speech/stream"
|
||||
starts: list[dict] = []
|
||||
dones: list[dict] = []
|
||||
chunk_lengths: dict[int, list[int]] = {}
|
||||
session_done: dict | None = None
|
||||
|
||||
async with websockets.connect(uri, max_size=None) as ws:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.config",
|
||||
"model": model,
|
||||
"voice": "vivian",
|
||||
"language": "English",
|
||||
"response_format": "pcm",
|
||||
"stream_audio": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input.text",
|
||||
"text": (
|
||||
"Hello, this is a websocket streaming test for Qwen three TTS, "
|
||||
"and this sentence is intentionally long enough to produce audio chunks. "
|
||||
"This is the second sentence."
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
await ws.send(json.dumps({"type": "input.done"}))
|
||||
|
||||
while True:
|
||||
message = await asyncio.wait_for(ws.recv(), timeout=180)
|
||||
if isinstance(message, bytes):
|
||||
if not starts:
|
||||
raise AssertionError("Received audio bytes before audio.start")
|
||||
sentence_index = starts[-1]["sentence_index"]
|
||||
chunk_lengths.setdefault(sentence_index, []).append(len(message))
|
||||
continue
|
||||
|
||||
payload = json.loads(message)
|
||||
msg_type = payload.get("type")
|
||||
if msg_type == "audio.start":
|
||||
starts.append(payload)
|
||||
chunk_lengths.setdefault(payload["sentence_index"], [])
|
||||
elif msg_type == "audio.done":
|
||||
dones.append(payload)
|
||||
elif msg_type == "session.done":
|
||||
session_done = payload
|
||||
break
|
||||
elif msg_type == "error":
|
||||
raise AssertionError(f"WebSocket error: {payload['message']}")
|
||||
else:
|
||||
raise AssertionError(f"Unexpected WebSocket message: {payload}")
|
||||
|
||||
return {
|
||||
"starts": starts,
|
||||
"dones": dones,
|
||||
"chunk_lengths": chunk_lengths,
|
||||
"session_done": session_done,
|
||||
}
|
||||
|
||||
|
||||
class TestQwen3TTSWebSocket:
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
@pytest.mark.parametrize("omni_server", tts_ws_server_params, indirect=True)
|
||||
def test_streaming_pcm_output(self, omni_server) -> None:
|
||||
result = asyncio.run(_run_ws_session(omni_server.host, omni_server.port, omni_server.model))
|
||||
|
||||
starts = result["starts"]
|
||||
dones = result["dones"]
|
||||
chunk_lengths = result["chunk_lengths"]
|
||||
session_done = result["session_done"]
|
||||
|
||||
assert session_done is not None
|
||||
assert session_done["total_sentences"] == 2
|
||||
assert len(starts) == 2
|
||||
assert len(dones) == 2
|
||||
|
||||
for idx, start in enumerate(starts):
|
||||
assert start["type"] == "audio.start"
|
||||
assert start["sentence_index"] == idx
|
||||
assert start["format"] == "pcm"
|
||||
assert start["sample_rate"] == 24000
|
||||
assert start["sentence_text"]
|
||||
|
||||
for done in dones:
|
||||
sentence_index = done["sentence_index"]
|
||||
total_bytes = done["total_bytes"]
|
||||
assert done["error"] is False
|
||||
assert total_bytes > 0
|
||||
assert chunk_lengths[sentence_index], f"Expected binary PCM frames for sentence {sentence_index}"
|
||||
assert sum(chunk_lengths[sentence_index]) == total_bytes
|
||||
@@ -0,0 +1,414 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
E2E tests for the /v1/audio/speech/batch endpoint.
|
||||
|
||||
Validates bulk synthesis via the batch API with actual model inference.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
|
||||
|
||||
import base64
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import convert_audio_file_to_text, cosine_similarity_text
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
|
||||
|
||||
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
|
||||
|
||||
|
||||
default_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
|
||||
server_args=["--trust-remote-code", "--enforce-eager", "--disable-log-stats"],
|
||||
),
|
||||
id="qwen3-tts-batch-default",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def make_batch_request(
|
||||
host: str,
|
||||
port: int,
|
||||
items: list[dict],
|
||||
voice: str | None = "vivian",
|
||||
language: str | None = "English",
|
||||
response_format: str = "wav",
|
||||
timeout: float = 300.0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Make a request to the /v1/audio/speech/batch endpoint."""
|
||||
url = f"http://{host}:{port}/v1/audio/speech/batch"
|
||||
payload: dict = {
|
||||
"items": items,
|
||||
"response_format": response_format,
|
||||
}
|
||||
if voice is not None:
|
||||
payload["voice"] = voice
|
||||
if language is not None:
|
||||
payload["language"] = language
|
||||
payload.update(kwargs)
|
||||
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
return client.post(url, json=payload)
|
||||
|
||||
|
||||
def verify_wav_bytes(audio_bytes: bytes) -> bool:
|
||||
"""Verify decoded audio bytes form a valid WAV."""
|
||||
if len(audio_bytes) < 44:
|
||||
return False
|
||||
return audio_bytes[:4] == b"RIFF" and audio_bytes[8:12] == b"WAVE"
|
||||
|
||||
|
||||
def assert_not_silence(pcm_bytes: bytes):
|
||||
"""Assert PCM16 samples are not all identical."""
|
||||
samples = struct.unpack(f"<{len(pcm_bytes) // 2}h", pcm_bytes)
|
||||
unique = set(samples)
|
||||
assert len(unique) > 1, f"All-silence: {len(samples)} samples, unique={unique}"
|
||||
|
||||
|
||||
MIN_AUDIO_BYTES = 10000
|
||||
|
||||
|
||||
class TestSpeechBatchE2E:
|
||||
"""E2E tests for /v1/audio/speech/batch endpoint."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_basic_two_items(self, omni_server) -> None:
|
||||
"""Batch with two items returns two successful base64-encoded results."""
|
||||
items = [
|
||||
{"input": "Hello, how are you today?"},
|
||||
{"input": "The weather is nice outside."},
|
||||
]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert data["total"] == 2
|
||||
assert data["succeeded"] == 2
|
||||
assert data["failed"] == 0
|
||||
assert data["id"].startswith("speech-batch-")
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
for i, result in enumerate(data["results"]):
|
||||
assert result["index"] == i
|
||||
assert result["status"] == "success"
|
||||
assert result["audio_data"] is not None
|
||||
assert result["error"] is None
|
||||
audio_bytes = base64.b64decode(result["audio_data"])
|
||||
assert verify_wav_bytes(audio_bytes), f"Item {i}: invalid WAV"
|
||||
assert len(audio_bytes) > MIN_AUDIO_BYTES, f"Item {i}: audio too small ({len(audio_bytes)} bytes)"
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_single_item(self, omni_server) -> None:
|
||||
"""Batch with a single item works correctly."""
|
||||
items = [{"input": "Single item batch test."}]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert data["total"] == 1
|
||||
assert data["succeeded"] == 1
|
||||
assert data["failed"] == 0
|
||||
assert len(data["results"]) == 1
|
||||
assert data["results"][0]["status"] == "success"
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_per_item_voice_override(self, omni_server) -> None:
|
||||
"""Per-item voice overrides the batch-level default."""
|
||||
items = [
|
||||
{"input": "First item with default voice."},
|
||||
{"input": "Second item with ryan voice.", "voice": "ryan"},
|
||||
]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
voice="vivian",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert data["total"] == 2
|
||||
assert data["succeeded"] == 2
|
||||
# Both items should produce valid audio
|
||||
for result in data["results"]:
|
||||
assert result["status"] == "success"
|
||||
audio_bytes = base64.b64decode(result["audio_data"])
|
||||
assert verify_wav_bytes(audio_bytes)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_multiple_languages(self, omni_server) -> None:
|
||||
"""Batch items with different languages per item."""
|
||||
items = [
|
||||
{"input": "Hello, nice to meet you.", "language": "English"},
|
||||
{"input": "你好,很高兴认识你。", "language": "Chinese"},
|
||||
]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert data["succeeded"] == 2
|
||||
for result in data["results"]:
|
||||
assert result["status"] == "success"
|
||||
audio_bytes = base64.b64decode(result["audio_data"])
|
||||
assert len(audio_bytes) > MIN_AUDIO_BYTES
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_whisper_transcription(self, omni_server) -> None:
|
||||
"""Whisper transcription of batch output matches input text."""
|
||||
input_text = "Good morning, welcome to the speech synthesis test."
|
||||
items = [{"input": input_text}]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
assert data["succeeded"] == 1
|
||||
|
||||
audio_bytes = base64.b64decode(data["results"][0]["audio_data"])
|
||||
assert verify_wav_bytes(audio_bytes)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
f.write(audio_bytes)
|
||||
wav_path = f.name
|
||||
|
||||
try:
|
||||
transcript = convert_audio_file_to_text(wav_path)
|
||||
print(f"Whisper transcript: {transcript}")
|
||||
assert len(transcript.strip()) > 0, "Empty transcript — likely silence"
|
||||
similarity = cosine_similarity_text(transcript.lower(), input_text.lower())
|
||||
print(f"Cosine similarity: {similarity:.3f}")
|
||||
assert similarity > 0.7, f"Transcript mismatch: similarity={similarity:.2f}, transcript='{transcript}'"
|
||||
finally:
|
||||
os.unlink(wav_path)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_result_indices_ordered(self, omni_server) -> None:
|
||||
"""Result indices match the input item order."""
|
||||
items = [
|
||||
{"input": "First sentence."},
|
||||
{"input": "Second sentence."},
|
||||
{"input": "Third sentence."},
|
||||
]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
indices = [r["index"] for r in data["results"]]
|
||||
assert indices == [0, 1, 2]
|
||||
|
||||
|
||||
class TestSpeechBatchValidation:
|
||||
"""Validation / error-handling tests for the batch endpoint."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_empty_items_rejected(self, omni_server) -> None:
|
||||
"""Empty items list returns a 4xx error."""
|
||||
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech/batch"
|
||||
payload = {"items": [], "voice": "vivian"}
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
|
||||
assert response.status_code in (400, 422)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", default_server_params, indirect=True)
|
||||
def test_batch_exceeds_max_items(self, omni_server) -> None:
|
||||
"""Batch exceeding 32 items returns 400 error."""
|
||||
items = [{"input": f"Item {i}"} for i in range(33)]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def _make_batch2_stage_config() -> str:
|
||||
"""Create a modified stage config with max_num_seqs=2 for all stages."""
|
||||
base_config_path = get_deploy_config_path("qwen3_tts.yaml")
|
||||
with open(base_config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
stage_updates = {stage["stage_id"]: {"max_num_seqs": 2} for stage in cfg["stages"]}
|
||||
return modify_stage_config(base_config_path, updates={"stages": stage_updates})
|
||||
|
||||
|
||||
BATCH2_STAGE_CONFIG_PATH = _make_batch2_stage_config()
|
||||
|
||||
|
||||
batch2_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=BATCH2_STAGE_CONFIG_PATH,
|
||||
server_args=["--trust-remote-code", "--enforce-eager", "--disable-log-stats"],
|
||||
),
|
||||
id="qwen3-tts-batch-max2",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def make_single_request(
|
||||
host: str,
|
||||
port: int,
|
||||
text: str,
|
||||
voice: str = "vivian",
|
||||
language: str = "English",
|
||||
timeout: float = 300.0,
|
||||
) -> httpx.Response:
|
||||
"""Make a request to the single /v1/audio/speech endpoint."""
|
||||
url = f"http://{host}:{port}/v1/audio/speech"
|
||||
payload = {"input": text, "voice": voice, "language": language}
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
return client.post(url, json=payload)
|
||||
|
||||
|
||||
class TestSpeechBatchSize2:
|
||||
"""E2E tests with max_batch_size=2 to verify true batched inference."""
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", batch2_server_params, indirect=True)
|
||||
def test_batch2_produces_valid_audio(self, omni_server) -> None:
|
||||
"""Batch of 2 items with batched engine produces valid audio."""
|
||||
items = [
|
||||
{"input": "Hello, this is the first sentence."},
|
||||
{"input": "And this is the second sentence."},
|
||||
]
|
||||
response = make_batch_request(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert data["total"] == 2
|
||||
assert data["succeeded"] == 2
|
||||
assert data["failed"] == 0
|
||||
|
||||
# Save audio files for inspection
|
||||
output_dir = Path(tempfile.mkdtemp(prefix="tts_batch2_output_"))
|
||||
for result in data["results"]:
|
||||
assert result["status"] == "success"
|
||||
audio_bytes = base64.b64decode(result["audio_data"])
|
||||
assert verify_wav_bytes(audio_bytes), f"Item {result['index']}: invalid WAV"
|
||||
assert len(audio_bytes) > MIN_AUDIO_BYTES
|
||||
|
||||
wav_path = output_dir / f"batch_item_{result['index']}.wav"
|
||||
wav_path.write_bytes(audio_bytes)
|
||||
print(f" Item {result['index']}: {len(audio_bytes)} bytes -> {wav_path}")
|
||||
|
||||
print(f"\nBatch audio saved to: {output_dir}")
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", batch2_server_params, indirect=True)
|
||||
def test_batch2_vs_sequential_timing(self, omni_server) -> None:
|
||||
"""Compare batch-of-2 vs 2 sequential single requests.
|
||||
|
||||
With max_batch_size=2 the engine can process both items
|
||||
concurrently, so the batch call should be faster than two
|
||||
sequential single calls (or at least comparable).
|
||||
"""
|
||||
text_a = "The quick brown fox jumps over the lazy dog."
|
||||
text_b = "A journey of a thousand miles begins with a single step."
|
||||
|
||||
host = omni_server.host
|
||||
port = omni_server.port
|
||||
|
||||
# --- Sequential: 2 single /v1/audio/speech calls ---
|
||||
t0 = time.perf_counter()
|
||||
resp_a = make_single_request(host, port, text_a)
|
||||
resp_b = make_single_request(host, port, text_b)
|
||||
sequential_time = time.perf_counter() - t0
|
||||
|
||||
assert resp_a.status_code == 200, f"Single A failed: {resp_a.text}"
|
||||
assert resp_b.status_code == 200, f"Single B failed: {resp_b.text}"
|
||||
|
||||
# --- Batch: 1 /v1/audio/speech/batch call with 2 items ---
|
||||
items = [{"input": text_a}, {"input": text_b}]
|
||||
t0 = time.perf_counter()
|
||||
resp_batch = make_batch_request(host, port, items)
|
||||
batch_time = time.perf_counter() - t0
|
||||
|
||||
assert resp_batch.status_code == 200, f"Batch failed: {resp_batch.text}"
|
||||
batch_data = resp_batch.json()
|
||||
assert batch_data["succeeded"] == 2
|
||||
|
||||
# Verify both produced valid audio
|
||||
for result in batch_data["results"]:
|
||||
audio_bytes = base64.b64decode(result["audio_data"])
|
||||
assert verify_wav_bytes(audio_bytes)
|
||||
assert len(audio_bytes) > MIN_AUDIO_BYTES
|
||||
|
||||
print(f"\n Sequential (2 singles): {sequential_time:.2f}s")
|
||||
print(f" Batch (2 items): {batch_time:.2f}s")
|
||||
print(f" Speedup: {sequential_time / batch_time:.2f}x")
|
||||
Reference in new issue
Block a user