[Maintenance] Refresh MOSS-TTS optimization on main

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-09-09 12:46:57 +08:00
commit 28dd49a541
334 files changed
+24909 -6187

No files matched your search

@@ -568,6 +568,48 @@ def test_websocket_routes_emit_stable_unavailable_frames_and_close(path: str, pa
websocket.receive_text()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("duplex_query", "expected_handler"),
[
(None, "duplex"),
("1", "duplex"),
("true", "duplex"),
("on", "duplex"),
("0", "legacy"),
("false", "legacy"),
],
)
async def test_realtime_route_defaults_to_configured_duplex_handler(
monkeypatch, duplex_query: str | None, expected_handler: str
) -> None:
calls: list[str] = []
class _DuplexHandler:
async def handle_realtime_session(self, _websocket) -> None:
calls.append("duplex")
class _LegacyConnection:
async def handle_connection(self) -> None:
calls.append("legacy")
monkeypatch.setattr(api_server, "RealtimeConnection", lambda _websocket, _serving: _LegacyConnection())
query_params = {} if duplex_query is None else {"duplex": duplex_query}
websocket = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
openai_serving_duplex=_DuplexHandler(),
openai_serving_realtime=object(),
)
),
query_params=query_params,
)
await api_server.realtime_websocket(websocket)
assert calls == [expected_handler]
def test_health_without_engine_returns_stable_unhealthy_response() -> None:
"""Lock ``/health`` with no engine initialized.
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Serving-layer regressions for the Audex zero-codec-token contract.
A zero-codec-token (or phase-invalid TTA) request reaches the serving layer
@@ -171,7 +171,7 @@ class TestAudexCfgValidation:
class TestAudexCfgInjection:
def _serving_with_fake_tokenizer(self):
serving = _serving()
serving._get_audex_tokenizer = lambda: _MarkerTokenizer()
serving._adapter._get_tokenizer = lambda: _MarkerTokenizer()
return serving
def _cond_prompt(self, text: str = "Hello world.") -> str:
@@ -333,8 +333,8 @@ class TestAdapterRouting:
class TestAudexTTAInjection:
def _serving(self):
serving = _serving(model_type="audex_tta")
serving._get_audex_tokenizer = lambda: _TTAVocabTokenizer()
serving._audex_tta_rvq = None
serving._adapter._get_tokenizer = lambda: _TTAVocabTokenizer()
serving._adapter._tta_rvq = None
return serving
def _prompt(self):
@@ -163,6 +163,8 @@ class TestStreamingAudioResampler:
chunked = StreamingAudioResampler(24000, 8000)
pieces = [chunked.process(chunk) for chunk in np.split(waveform, [137, 2048, 9001, 17003])]
# Bounded FIR history must own its data, not retain the last large chunk.
assert chunked._history.base is None
pieces.append(chunked.process(np.empty(0), final=True))
actual = np.concatenate(pieces)
@@ -193,9 +195,15 @@ class TestStreamingAudioResampler:
output_rms = np.sqrt(np.mean(output[100:-100] ** 2))
assert output_rms < input_rms * 0.01
def test_rejects_non_integer_downsampling_ratio(self):
with pytest.raises(ValueError, match="integer downsampling ratio"):
StreamingAudioResampler(24000, 16000)
def test_supports_rational_downsampling_ratio(self):
waveform = np.sin(np.linspace(0, 200 * np.pi, 24000, endpoint=False)).astype(np.float32)
resampler = StreamingAudioResampler(24000, 16000)
output = resampler.process(waveform, final=True)
reference = torchaudio.functional.resample(torch.from_numpy(waveform), 24000, 16000).numpy()
assert output.shape == (16000,)
np.testing.assert_allclose(output[100:-100], reference[100:-100], atol=2e-3, rtol=1e-3)
def test_rejects_multichannel_audio(self):
resampler = StreamingAudioResampler(24000, 8000)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import gc
import threading
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v3 import HiggsAudioV3Adapter
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_ref_code_inflight_survives_request_cancellation():
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
encode_started = threading.Event()
release_encode = threading.Event()
calls = 0
expected_codes = torch.arange(16, dtype=torch.long).reshape(2, 8) + 1
def encode_reference_audio(_wav, _sr):
nonlocal calls
calls += 1
encode_started.set()
assert release_encode.wait(timeout=5)
return torch.arange(16, dtype=torch.long).reshape(2, 8)
def apply_delay_pattern(codes):
return codes + 1
async def resolve():
return await adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
encode_reference_audio,
apply_delay_pattern,
)
async def run():
creator = asyncio.create_task(resolve())
try:
assert await asyncio.wait_for(asyncio.to_thread(encode_started.wait), timeout=5)
cancelled_waiter = asyncio.create_task(resolve())
survivor = asyncio.create_task(resolve())
await asyncio.sleep(0)
cancelled_waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await cancelled_waiter
creator.cancel()
with pytest.raises(asyncio.CancelledError):
await creator
release_encode.set()
survivor_result = await asyncio.wait_for(survivor, timeout=5)
await asyncio.sleep(0)
return survivor_result
finally:
release_encode.set()
codes, cache_hit, inflight_wait = asyncio.run(run())
assert calls == 1
assert cache_hit is False
assert inflight_wait is True
assert torch.equal(codes, expected_codes)
assert "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight
cached = adapter._get_higgs_audio_v3_ref_codes("ref-a")
assert cached is not None
assert torch.equal(cached, expected_codes)
def test_ref_code_inflight_consumes_failure_after_all_callers_cancel():
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
encode_started = threading.Event()
release_encode = threading.Event()
loop_errors = []
def encode_reference_audio(_wav, _sr):
encode_started.set()
assert release_encode.wait(timeout=5)
raise RuntimeError("encode failed")
async def resolve():
return await adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
encode_reference_audio,
lambda codes: codes,
)
async def run():
loop = asyncio.get_running_loop()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
creator = asyncio.create_task(resolve())
try:
assert await asyncio.wait_for(asyncio.to_thread(encode_started.wait), timeout=5)
waiter = asyncio.create_task(resolve())
await asyncio.sleep(0)
creator.cancel()
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await creator
with pytest.raises(asyncio.CancelledError):
await waiter
release_encode.set()
for _ in range(100):
if "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight:
break
await asyncio.sleep(0.01)
gc.collect()
await asyncio.sleep(0)
finally:
release_encode.set()
asyncio.run(run())
assert "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight
assert not loop_errors
@@ -68,7 +68,7 @@ def test_pack_and_unpack_round_trip_numpy_values():
assert decoded["nested"][0]["done"] == np.bool_(True)
def test_unpack_accepts_msgpack_numpy_marker_dicts():
def test_unpack_accepts_vllm_native_marker_dicts():
action = np.asarray([[1.0, 2.0]], dtype=np.float32)
payload = {
b"actions": {
@@ -88,6 +88,58 @@ def test_unpack_accepts_msgpack_numpy_marker_dicts():
np.testing.assert_allclose(decoded[b"actions"], np.asarray([[1.0, 0.0]], dtype=np.float32))
def test_unpack_accepts_msgpack_numpy_marker_dicts():
action = np.asarray([[1.0, 2.0]], dtype=np.float32)
payload = {
b"actions": {
b"nd": True,
b"type": action.dtype.str,
b"kind": b"",
b"shape": action.shape,
b"data": action.tobytes(),
}
}
decoded = openpi_connection._unpack_numpy(payload)
np.testing.assert_allclose(decoded[b"actions"], action)
assert decoded[b"actions"].dtype == np.float32
def test_unpack_rejects_msgpack_numpy_structured_array_markers():
values = np.zeros(2, dtype=[("a", "<i4"), ("b", "<f4")])
payload = {
b"nd": True,
b"type": values.dtype.descr,
b"kind": b"V",
b"shape": values.shape,
b"data": values.tobytes(),
}
with pytest.raises(ValueError, match="Unsupported dtype"):
openpi_connection._unpack_numpy(payload)
def test_unpack_msgpack_numpy_packed_observation():
msgpack_numpy = pytest.importorskip("msgpack_numpy")
obs = {
"observation/exterior_image_0_left": np.zeros((180, 320, 3), dtype=np.uint8),
"observation/joint_position": np.arange(7, dtype=np.float32),
}
decoded = openpi_connection._unpack(msgpack_numpy.packb(obs))
np.testing.assert_array_equal(
decoded["observation/exterior_image_0_left"],
obs["observation/exterior_image_0_left"],
)
np.testing.assert_allclose(
decoded["observation/joint_position"],
obs["observation/joint_position"],
)
assert decoded["observation/joint_position"].dtype == np.float32
def test_unpack_accepts_openpi_client_ndarray_markers():
image = np.arange(6, dtype=np.uint8).reshape(2, 3)
payload = {
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import json
import threading
@@ -218,6 +221,18 @@ def test_build_request_uses_unique_engine_request_id_per_inference():
assert request_a.request_id != request_b.request_id
def test_build_request_forwards_seed_to_sampling_params():
"""``seed`` in the inference message is the engine-level seed; omitted, the request auto-seeds."""
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config())
seeded = serving._build_request({"prompt": "pick up the object", "seed": 42}, session_id="s", reset=True)
unseeded = serving._build_request({"prompt": "pick up the object"}, session_id="s", reset=False)
assert seeded.sampling_params.seed == 42
assert "seed" not in seeded.sampling_params.extra_args["robot_obs"]
assert isinstance(unseeded.sampling_params.seed, int)
def test_infer_keeps_session_state_but_uses_unique_engine_request_ids():
engine = RecordingEngine()
serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine)
@@ -3,10 +3,11 @@
"""
E2E online tests for Qwen3-Omni /v1/realtime WebSocket (streaming PCM in, audio out).
Three scenarios:
Four scenarios:
- Ready CI: async_chunk on, smoke only (no send delay, no accuracy check).
- Merge CI: async_chunk on + send delay, full accuracy check.
- Merge CI: async_chunk off, no send delay, full accuracy check.
- Server VAD: two turns without client commits.
"""
from __future__ import annotations
@@ -28,11 +29,18 @@ from tests.helpers.media import (
generate_synthetic_audio,
)
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.config.stage_config import load_deploy_config
from vllm_omni.entrypoints.duplex.server_vad import (
SILERO_VAD_FILENAME,
SILERO_VAD_REPO_ID,
SILERO_VAD_REVISION,
)
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
MODEL = os.environ.get("VLLM_OMNI_TEST_QWEN3_OMNI_MODEL", "Qwen/Qwen3-Omni-30B-A3B-Instruct")
SERVER_VAD_MODEL_PATH = os.environ.get("VLLM_OMNI_TEST_SILERO_VAD_MODEL_PATH")
# Synthetic input for realtime E2E (``generate_synthetic_audio``); distinct cache file per phrase.
REALTIME_SYNTH_PHRASE_TEXT = (
@@ -48,6 +56,15 @@ SEND_DELAY_MS = 200
# CI overlay bakes in async_chunk: False and covers CUDA/ROCm/XPU via ``platforms:``.
default_stage_config = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
server_vad_stage_config = modify_stage_config(
default_stage_config,
{
"async_chunk": True,
"duplex_session": (
{"server_vad_model_path": SERVER_VAD_MODEL_PATH} if SERVER_VAD_MODEL_PATH is not None else {}
),
},
)
realtime_sync_server_params = [
pytest.param(
@@ -72,6 +89,17 @@ realtime_async_chunk_server_params = [
),
]
realtime_server_vad_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=server_vad_stage_config,
use_stage_cli=True,
),
id="server_vad",
),
]
def _pcm16_mono_16k_from_wav_bytes(wav_bytes: bytes) -> bytes:
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
@@ -183,6 +211,86 @@ async def _run_realtime_audio_roundtrip(
}
async def _append_pcm16_chunks(ws, pcm16: bytes, chunk_bytes: int) -> None:
for offset in range(0, len(pcm16), chunk_bytes):
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(pcm16[offset : offset + chunk_bytes]).decode("utf-8"),
}
)
)
async def _receive_server_vad_turn(ws) -> list[dict]:
events: list[dict] = []
while True:
message = await asyncio.wait_for(ws.recv(), timeout=600)
if isinstance(message, bytes):
continue
event = json.loads(message)
events.append(event)
if event.get("type") == "error":
raise AssertionError(f"WebSocket error: {event}")
if event.get("type") == "response.done":
return events
async def _run_server_vad_audio_roundtrips(
host: str,
port: int,
model: str,
pcm16: bytes,
*,
chunk_ms: int = 100,
turns: int = 2,
) -> list[list[dict]]:
chunk_bytes = max(16_000 * 2 // 1000 * chunk_ms, 2)
turn_events: list[list[dict]] = []
async with websockets.connect(f"ws://{host}:{port}/v1/realtime", max_size=64 * 1024 * 1024) as ws:
await ws.send(
json.dumps(
{
"type": "session.update",
"session": {
"model": model,
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 16_000},
"turn_detection": {"type": "server_vad"},
}
},
},
}
)
)
silence = bytes(16_000 * 2 * 3 // 2)
for _ in range(turns):
await _append_pcm16_chunks(ws, pcm16, chunk_bytes)
await _append_pcm16_chunks(ws, silence, chunk_bytes)
turn_events.append(await _receive_server_vad_turn(ws))
return turn_events
@pytest.fixture(scope="class")
def cached_silero_vad_artifact() -> str:
"""Prepare the pinned artifact before the serving subprocess starts."""
if SERVER_VAD_MODEL_PATH is not None:
return SERVER_VAD_MODEL_PATH
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id=SILERO_VAD_REPO_ID,
filename=SILERO_VAD_FILENAME,
revision=SILERO_VAD_REVISION,
)
def _synthetic_pcm16_input(
*,
phrase_text: str = REALTIME_SYNTH_PHRASE_TEXT,
@@ -296,6 +404,66 @@ class TestQwen3OmniRealtimeWebSocket:
f"Expected an issue-like audio response longer than 5 seconds, got {output_duration_s:.2f}s"
)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", realtime_server_vad_server_params, indirect=True)
def test_server_vad_multi_turn_without_client_commit(
self,
cached_silero_vad_artifact: str,
omni_server,
) -> None:
"""Two Qwen turns are endpointed without client commits."""
assert cached_silero_vad_artifact
deploy = load_deploy_config(server_vad_stage_config)
assert deploy.session_mode == "turn"
assert deploy.async_chunk is True
assert deploy.duplex_session.server_vad_model_path == SERVER_VAD_MODEL_PATH
pcm16 = _synthetic_pcm16_input()
turns = asyncio.run(
_run_server_vad_audio_roundtrips(
omni_server.host,
omni_server.port,
omni_server.model,
pcm16,
chunk_ms=100,
turns=2,
)
)
assert len(turns) == 2
required_sequence = [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"input_audio_buffer.committed",
"response.created",
"response.audio.delta",
"response.audio.done",
"response.done",
]
input_item_ids: list[str] = []
response_ids: list[str] = []
for events in turns:
event_types = [event["type"] for event in events]
positions = [event_types.index(event_type) for event_type in required_sequence]
assert positions == sorted(positions)
started = next(event for event in events if event["type"] == "input_audio_buffer.speech_started")
stopped = next(event for event in events if event["type"] == "input_audio_buffer.speech_stopped")
committed = next(event for event in events if event["type"] == "input_audio_buffer.committed")
created = next(event for event in events if event["type"] == "response.created")["response"]
done = next(event for event in events if event["type"] == "response.done")["response"]
assert started["item_id"] == stopped["item_id"] == committed["item_id"]
assert created["id"] == done["id"]
assert done["status"] == "completed"
input_item_ids.append(committed["item_id"])
response_ids.append(created["id"])
assert len(set(input_item_ids)) == 2
assert len(set(response_ids)) == 2
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@@ -0,0 +1,429 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import base64
import io
import sys
import threading
import wave
import weakref
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.entrypoints.duplex.protocol import (
DuplexSession,
DuplexSessionConfig,
)
from vllm_omni.entrypoints.duplex.server_vad import (
ServerVADConfig,
ServerVADFrame,
ServerVADPipeline,
SileroVADBackend,
SileroVADBackendProvider,
SpeechEndpointDecision,
ThresholdEndpointPolicy,
parse_session_turn_detection,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class SequenceDetector:
frame_samples = 160
def __init__(self, probabilities: list[float]) -> None:
self.probabilities = probabilities
def new_state(self) -> int:
return 0
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
index = int(state)
probability = self.probabilities[index] if index < len(self.probabilities) else 0.0
return probability, index + 1
def test_server_vad_config_defaults_and_validation():
config = ServerVADConfig.from_value({"type": "server_vad"})
assert config.type == "server_vad"
assert config.threshold == 0.5
assert config.prefix_padding_ms == 300
assert config.silence_duration_ms == 500
assert config.create_response is True
assert config.interrupt_response is None
assert "interrupt_response" not in config.as_dict()
assert config.min_speech_duration_ms is None
assert config.as_dict()["type"] == config.type
interrupting = ServerVADConfig.from_value(
{
"type": "server_vad",
"interrupt_response": True,
"min_speech_duration_ms": 96,
}
)
assert interrupting.interrupt_response is True
assert interrupting.min_speech_duration_ms == 96
for value in (None, 0, "true"):
with pytest.raises(ValueError, match="interrupt_response must be a boolean"):
ServerVADConfig.from_value({"type": "server_vad", "interrupt_response": value})
for enabled in (False, True):
_, aliased = parse_session_turn_detection(
{
"turn_detection": {"type": "server_vad"},
"audio": {"input": {"turn_detection": {"type": "server_vad", "interrupt_response": enabled}}},
}
)
assert aliased is not None and aliased.interrupt_response is enabled
with pytest.raises(ValueError, match="Unknown"):
ServerVADConfig.from_value({"type": "server_vad", "semantic_eagerness": "high"})
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix_padding_ms", [0, 10, 30])
async def test_server_vad_pipeline_handles_arbitrary_chunk_boundaries(prefix_padding_ms: int):
detector = SequenceDetector([0.0, 0.9, 0.8, 0.0, 0.0])
pipeline = ServerVADPipeline(
detector,
ServerVADConfig(prefix_padding_ms=prefix_padding_ms, silence_duration_ms=20),
)
samples = np.zeros(detector.frame_samples * 5, dtype=np.float32)
batches = [
await pipeline.push(samples[:73]),
await pipeline.push(samples[73:431]),
await pipeline.push(samples[431:]),
]
frames = [frame for batch in batches for frame in batch.frames]
assert len(frames) == 5
assert [index for index, frame in enumerate(frames) if frame.decision.speech_started] == [1]
assert [index for index, frame in enumerate(frames) if frame.decision.speech_stopped] == [4]
assert frames[1].decision.audio_start_ms == max(0, 10 - prefix_padding_ms)
assert frames[4].decision.audio_end_ms == 50
pipeline.reset()
reset_batch = await pipeline.push(samples)
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_started] == [1]
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_stopped] == [4]
# Keep session-relative timestamps, but never reference prefix audio
# discarded by reset at 50 ms.
assert reset_batch.frames[1].decision.audio_start_ms == max(50, 60 - prefix_padding_ms)
assert reset_batch.frames[4].decision.audio_end_ms == 100
@pytest.mark.asyncio
async def test_server_vad_retained_audio_does_not_keep_input_chunk():
pipeline = ServerVADPipeline(SequenceDetector([]), ServerVADConfig())
session = DuplexSession("server-vad-memory", DuplexSessionConfig())
samples = np.zeros(160 * 100 + 1, dtype=np.float32)
source_ref = weakref.ref(samples)
batch = await pipeline.push(samples)
session.append_server_vad_frame(
batch.frames[-1].samples,
speech_started=False,
speech_stopped=False,
prefix_samples=80,
)
session.append_server_vad_frame(
batch.frames[-1].samples,
speech_started=True,
speech_stopped=False,
prefix_samples=80,
)
del batch, samples
# Allow the completed to_thread callback to release its result, too.
await asyncio.sleep(0)
assert source_ref() is None
assert pipeline.scratch_bytes == 4
assert session.server_vad_utterance_bytes == (80 + 160) * 4
@pytest.mark.asyncio
async def test_server_vad_pipeline_pcm16_resampling_is_split_invariant():
sample_index = np.arange(2_400, dtype=np.int32)
source = ((sample_index * 7919) % 65_536 - 32_768).astype("<i2")
# Supply ample silence after the signal so the streaming resampler can
# emit a complete set of VAD frames without depending on its FIR width.
right_context = np.zeros(240, dtype="<i2")
continuous_source = np.concatenate((source, right_context))
async def process(chunk_sizes: list[int]) -> tuple[np.ndarray, list[SpeechEndpointDecision]]:
pipeline = ServerVADPipeline(
SequenceDetector([0.0] * 10),
ServerVADConfig(),
)
frames: list[ServerVADFrame] = []
offset = 0
for chunk_size in chunk_sizes:
chunk = continuous_source[offset : offset + chunk_size]
batch = await pipeline.push_pcm16(chunk.tobytes(), source_sample_rate_hz=24_000)
frames.extend(batch.frames)
offset += chunk_size
assert offset == continuous_source.size
return np.concatenate([frame.samples for frame in frames]), [frame.decision for frame in frames]
whole_samples, whole_decisions = await process([continuous_source.size])
split_samples, split_decisions = await process([7, 13, 511, 512, 513, continuous_source.size - 1_556])
np.testing.assert_array_equal(split_samples, whole_samples)
assert whole_samples.size == 1_600
assert split_decisions == whole_decisions
@pytest.mark.asyncio
async def test_server_vad_pipeline_reset_clears_resampler_and_source_rate_lock():
pipeline = ServerVADPipeline(
SequenceDetector([0.0]),
ServerVADConfig(),
)
batch = await pipeline.push_pcm16(
np.asarray([30_000, -30_000], dtype="<i2").tobytes(),
source_sample_rate_hz=24_000,
)
assert not batch.frames
pipeline.reset()
source = np.zeros(160, dtype="<i2")
source[:3] = [-32768, 0, 32767]
batch = await pipeline.push_pcm16(source.tobytes(), source_sample_rate_hz=16_000)
assert len(batch.frames) == 1
np.testing.assert_array_equal(
batch.frames[0].samples[:3],
np.asarray([-1.0, 0.0, 32767 / 32768], dtype=np.float32),
)
def test_threshold_endpoint_policy_uses_silero_v62_exit_hysteresis():
policy = ThresholdEndpointPolicy(
ServerVADConfig(threshold=0.5, silence_duration_ms=20),
sample_rate_hz=16_000,
)
frame_samples = 160
started = policy.update(0.8, frame_start_sample=0, frame_samples=frame_samples)
assert started.speech_started is True
# Values below the activation threshold but above threshold - 0.15 must
# not begin a silence candidate or close an active speech segment.
for frame_index in range(1, 5):
decision = policy.update(
0.4,
frame_start_sample=frame_index * frame_samples,
frame_samples=frame_samples,
)
assert decision.speech_stopped is False
first_silence = policy.update(
0.2,
frame_start_sample=5 * frame_samples,
frame_samples=frame_samples,
)
stopped = policy.update(
0.2,
frame_start_sample=6 * frame_samples,
frame_samples=frame_samples,
)
assert first_silence.speech_stopped is False
assert stopped.speech_stopped is True
def test_threshold_endpoint_policy_honors_minimum_speech_duration():
policy = ThresholdEndpointPolicy(
ServerVADConfig(prefix_padding_ms=0, min_speech_duration_ms=25),
sample_rate_hz=16_000,
)
frame_samples = 160
assert not policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples).speech_started
assert not policy.update(0.9, frame_start_sample=160, frame_samples=frame_samples).speech_started
started = policy.update(0.9, frame_start_sample=320, frame_samples=frame_samples)
assert started.speech_started is True
assert started.audio_start_ms == 0
def test_threshold_endpoint_policy_clamps_silero_exit_threshold():
policy = ThresholdEndpointPolicy(
ServerVADConfig(threshold=0.1, silence_duration_ms=20),
sample_rate_hz=16_000,
)
frame_samples = 160
started = policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples)
assert started.speech_started is True
first_silence = policy.update(
0.0,
frame_start_sample=frame_samples,
frame_samples=frame_samples,
)
stopped = policy.update(
0.0,
frame_start_sample=2 * frame_samples,
frame_samples=frame_samples,
)
assert first_silence.speech_stopped is False
assert stopped.speech_stopped is True
# The committed audio ends at 30 ms: 10 ms of speech followed by the
# 20 ms of silence required to detect the endpoint.
assert stopped.audio_end_ms == 30
assert stopped.endpoint_delay_ms == 20
def test_silero_backend_matches_upstream_v62_streaming_contract(monkeypatch, tmp_path):
class FakeSessionOptions:
inter_op_num_threads = 0
intra_op_num_threads = 0
class FakeInferenceSession:
def __init__(self, path: str, *, providers: list[str], sess_options: object) -> None:
self.providers = providers
self.sess_options = sess_options
self.calls: list[dict[str, np.ndarray]] = []
self.run_barrier: threading.Barrier | None = None
sessions.append(self)
def get_inputs(self) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in ("input", "state", "sr")]
def run(self, _outputs: object, inputs: dict[str, np.ndarray]) -> list[np.ndarray]:
self.calls.append({name: np.array(value, copy=True) for name, value in inputs.items()})
if self.run_barrier is not None:
self.run_barrier.wait(timeout=5)
return [
np.asarray([[0.75]], dtype=np.float32),
np.full((2, 1, 128), len(self.calls), dtype=np.float32),
]
sessions: list[FakeInferenceSession] = []
monkeypatch.setitem(
sys.modules,
"onnxruntime",
SimpleNamespace(
SessionOptions=FakeSessionOptions,
InferenceSession=FakeInferenceSession,
),
)
model_path = tmp_path / "silero_vad.onnx"
model_path.write_bytes(b"fake-model")
backend = SileroVADBackend(model_path)
session = sessions[0]
assert session.providers == ["CPUExecutionProvider"]
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 1
assert session.calls[0]["input"].shape == (1, 576)
assert session.calls[0]["state"].shape == (2, 1, 128)
np.testing.assert_array_equal(session.calls[0]["state"], np.zeros((2, 1, 128), dtype=np.float32))
assert session.calls[0]["sr"].shape == ()
assert session.calls[0]["sr"].item() == 16_000
state = backend.new_state()
first_frame = np.arange(backend.frame_samples, dtype=np.float32)
probability, state = backend.infer(first_frame, state)
first_call = session.calls[1]
assert probability == pytest.approx(0.75)
np.testing.assert_array_equal(
first_call["input"][:, : backend.context_samples],
np.zeros((1, backend.context_samples), dtype=np.float32),
)
np.testing.assert_array_equal(
first_call["input"][:, backend.context_samples :],
first_frame[None, :],
)
np.testing.assert_array_equal(first_call["state"], np.zeros((2, 1, 128), dtype=np.float32))
second_frame = -first_frame
backend.infer(second_frame, state)
second_call = session.calls[2]
np.testing.assert_array_equal(
second_call["input"][:, : backend.context_samples],
first_frame[None, -backend.context_samples :],
)
np.testing.assert_array_equal(
second_call["input"][:, backend.context_samples :],
second_frame[None, :],
)
np.testing.assert_array_equal(
second_call["state"],
np.full((2, 1, 128), 2, dtype=np.float32),
)
# Independent pipeline states may enter the shared ORT session concurrently.
session.run_barrier = threading.Barrier(2)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(backend.infer, first_frame, backend.new_state()),
executor.submit(backend.infer, second_frame, backend.new_state()),
]
assert [future.result()[0] for future in futures] == pytest.approx([0.75, 0.75])
def test_duplex_session_owns_server_vad_prefix_and_utterance_audio():
session = DuplexSession("server-vad-session", DuplexSessionConfig())
frame = np.zeros(160, dtype=np.float32)
session.reserve_input_bytes(frame.nbytes * 3, limit=frame.nbytes * 4)
released = session.append_server_vad_frame(
frame,
speech_started=False,
speech_stopped=False,
prefix_samples=160,
)
assert released == 0
released = session.append_server_vad_frame(
frame,
speech_started=False,
speech_stopped=False,
prefix_samples=160,
)
assert released == frame.nbytes
session.release_input_bytes(released)
session.append_server_vad_frame(
frame,
speech_started=True,
speech_stopped=False,
prefix_samples=160,
)
assert session.server_vad_utterance_bytes == frame.nbytes * 2
assert session.stage_server_vad_audio_for_commit() is True
committed = session.commit_user_input()
assert committed is not None
assert committed.message["role"] == "user"
audio_url = committed.message["content"][0]["audio_url"]["url"]
assert audio_url.startswith("data:audio/wav;base64,")
wav_bytes = base64.b64decode(audio_url.partition(",")[2])
with wave.open(io.BytesIO(wav_bytes), "rb") as wav_file:
assert wav_file.getnchannels() == 1
assert wav_file.getsampwidth() == 2
assert wav_file.getframerate() == 16_000
assert wav_file.getnframes() == frame.size * 2
def test_silero_provider_rejects_missing_or_invalid_local_artifact(tmp_path):
missing = SileroVADBackendProvider(model_path=str(tmp_path / "missing.onnx"))
with pytest.raises(RuntimeError, match="does not exist"):
missing.get()
invalid_path = tmp_path / "silero_vad.onnx"
invalid_path.write_bytes(b"not-the-pinned-model")
invalid = SileroVADBackendProvider(model_path=str(invalid_path))
with pytest.raises(RuntimeError, match="checksum mismatch"):
invalid.get()
@@ -27,6 +27,9 @@ from pydantic import ValidationError
from pytest_mock import MockerFixture
from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler
from vllm_omni.diffusion.sched.step_scheduler import StepScheduler
from vllm_omni.entrypoints.omni_base import OmniEngineDeadError
from vllm_omni.entrypoints.openai import api_server as api_server_module
from vllm_omni.entrypoints.openai import serving_speech as serving_speech_module
@@ -53,8 +56,9 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import (
)
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers
from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSCodecLimitError
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter, Qwen3TTSCodecLimitError
from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
FISH_TEXT_ONLY_SYSTEM_PROMPT,
build_fish_voice_clone_prompt_ids,
@@ -753,13 +757,12 @@ class TestSpeechAPI:
@pytest.mark.asyncio
async def test_create_diffusion_speech_extra_params(self, mocker: MockerFixture):
"""Test public diffusion speech success and extra_params propagation."""
"""Test diffusion parameters reach StepScheduler as standard fields."""
# Mock the engine client
mock_engine = mocker.MagicMock()
# Mock default sampling params
mock_sampling_param = mocker.MagicMock()
mock_sampling_param.extra_args = {"existing_arg": "value"}
mock_sampling_param = OmniDiffusionSamplingParams(extra_args={"existing_arg": "value"})
mock_engine.default_sampling_params_list = [mock_sampling_param]
# Mock generate to yield a valid OmniRequestOutput
@@ -775,7 +778,15 @@ class TestSpeechAPI:
server, "create_audio", return_value=mocker.MagicMock(audio_data=b"dummy", media_type="audio/wav")
)
req = OpenAICreateSpeechRequest(input="Hello", extra_params={"new_arg": 123, "existing_arg": "new_value"})
req = OpenAICreateSpeechRequest(
input="Hello",
extra_params={
"new_arg": 123,
"existing_arg": "new_value",
"num_inference_steps": 12,
"guidance_scale": 7.0,
},
)
response = await server.create_speech(req)
@@ -792,7 +803,98 @@ class TestSpeechAPI:
# Verify it was deepcopied and updated
assert passed_params is not mock_engine.default_sampling_params_list
assert passed_params[0].extra_args == {"existing_arg": "new_value", "new_arg": 123}
assert passed_params[0].extra_args == {
"existing_arg": "new_value",
"new_arg": 123,
"num_inference_steps": 12,
"guidance_scale": 7.0,
}
assert passed_params[0].num_inference_steps == 12
assert passed_params[0].guidance_scale == 7.0
# Regression: StepScheduler.add_request() used to receive
# num_inference_steps=None and fail while converting it to int.
scheduler = StepScheduler()
scheduler.add_request(
OmniDiffusionRequest(
prompt="Hello",
sampling_params=passed_params[0],
request_id="speech-test",
)
)
assert scheduler._request_progress["speech-test"].total_steps == 12
@pytest.mark.asyncio
async def test_diffusion_speech_guidance_promotion_controls_request_batch_admission(
self,
mocker: MockerFixture,
) -> None:
"""Different request guidance values must not enter one request batch."""
mock_engine = mocker.MagicMock()
mock_engine.default_sampling_params_list = [OmniDiffusionSamplingParams(num_inference_steps=12)]
passed_sampling_params = []
async def mock_generate(*args, **kwargs):
passed_sampling_params.append(kwargs["sampling_params_list"][0])
yield create_mock_audio_output_for_test()
mock_engine.generate = mocker.MagicMock(side_effect=mock_generate)
server = OmniOpenAIServingSpeech.for_diffusion(diffusion_engine=mock_engine, model_name="test-model")
mocker.patch.object(
server,
"create_audio",
return_value=mocker.MagicMock(audio_data=b"dummy", media_type="audio/wav"),
)
for guidance_scale in (2.0, 7.0):
response = await server.create_speech(
OpenAICreateSpeechRequest(
input="Hello",
extra_params={"guidance_scale": guidance_scale},
)
)
assert response.status_code == 200
scheduler = RequestScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
for index, sampling_params in enumerate(passed_sampling_params):
scheduler.add_request(
OmniDiffusionRequest(
prompt="Hello",
sampling_params=sampling_params,
request_id=f"speech-{index}",
)
)
first = scheduler.schedule()
assert [request.request_id for request in first.scheduled_new_reqs] == ["speech-0"]
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_params", "expected_message"),
[
({"num_inference_steps": "invalid"}, "num_inference_steps must be an integer"),
({"guidance_scale": "invalid"}, "guidance_scale must be a number"),
],
)
async def test_create_diffusion_speech_rejects_invalid_scheduler_params(
self,
mocker: MockerFixture,
extra_params: dict[str, str],
expected_message: str,
) -> None:
mock_engine = mocker.MagicMock()
mock_engine.default_sampling_params_list = [OmniDiffusionSamplingParams()]
server = OmniOpenAIServingSpeech.for_diffusion(diffusion_engine=mock_engine, model_name="test-model")
response = await server.create_speech(OpenAICreateSpeechRequest(input="Hello", extra_params=extra_params))
assert response.status_code == 400
assert expected_message in response.body.decode()
mock_engine.generate.assert_not_called()
class TestTTSMethods:
@@ -812,6 +914,15 @@ class TestTTSMethods:
models=mock_models,
request_logger=mocker.MagicMock(),
)
qwen3_adapter = Qwen3TTSAdapter(
SpeechServingContext(
server=server,
engine_client=mock_engine_client,
)
)
server._build_tts_params = qwen3_adapter._build_tts_params
server._estimate_prompt_len = qwen3_adapter._estimate_prompt_len
yield server
server.shutdown()
@@ -1663,12 +1774,12 @@ class TestTTSMethods:
def test_conditioning_cache_salt_changes_with_ref_audio_cache_key(self):
"""Prefix-cache salt must fold the content-aware resolve key so a
same-path file rewrite cannot reuse KV from the previous reference."""
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
from vllm_omni.entrypoints.openai.tts_adapters.base import conditioning_cache_salt
req = OpenAICreateSpeechRequest(input="hello", ref_audio="file:///data/spk.wav")
salt_a = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_aaa"})
salt_b = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_bbb"})
salt_locator_only = _conditioning_cache_salt(req, {})
salt_a = conditioning_cache_salt(req, {"ref_audio_cache_key": "key_aaa"})
salt_b = conditioning_cache_salt(req, {"ref_audio_cache_key": "key_bbb"})
salt_locator_only = conditioning_cache_salt(req, {})
assert salt_a != salt_b
assert salt_a != salt_locator_only
@@ -1689,20 +1800,22 @@ class TestTTSMethods:
}
adapter = HiggsAudioV3TokenizerAdapter(tokenizer)
mocker.patch.object(adapter, "build_prompt", return_value=[1, -100, -100, 2])
speech_server._resolve_higgs_audio_v3_adapter = mocker.AsyncMock(return_value=adapter)
speech_server._tts_model_type = "higgs_audio_v3"
speech_server._adapter = speech_server._get_tts_adapter()
speech_server._adapter._resolve_higgs_audio_v3_adapter = mocker.AsyncMock(return_value=adapter)
codes = torch.arange(16, dtype=torch.long).view(2, 8)
speech_server._resolve_higgs_audio_v3_ref_codes = mocker.AsyncMock(return_value=(codes, False, False))
speech_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value="art")
speech_server._adapter._resolve_higgs_audio_v3_ref_codes = mocker.AsyncMock(return_value=(codes, False, False))
speech_server._adapter._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value="art")
req = OpenAICreateSpeechRequest(
input="hello",
ref_audio="file:///data/spk.wav",
ref_text="transcript",
)
speech_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.1] * 48000, 24000, "key_aaa"))
prompt_a = await speech_server._build_higgs_audio_v3_params(req)
speech_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.9] * 48000, 24000, "key_bbb"))
prompt_b = await speech_server._build_higgs_audio_v3_params(req)
speech_server._adapter._resolve_ref_audio = mocker.AsyncMock(return_value=([0.1] * 48000, 24000, "key_aaa"))
prompt_a = await speech_server._adapter._build_higgs_audio_v3_params(req)
speech_server._adapter._resolve_ref_audio = mocker.AsyncMock(return_value=([0.9] * 48000, 24000, "key_bbb"))
prompt_b = await speech_server._adapter._build_higgs_audio_v3_params(req)
assert prompt_a["prompt_token_ids"] == [1, 151700, 151700, 2]
assert prompt_a["prompt_token_ids"] == prompt_b["prompt_token_ids"]
@@ -1830,7 +1943,9 @@ class TestTTSMethods:
@pytest.mark.asyncio
async def test_ttsd_second_reference_does_not_use_named_voice(self, speech_server, mocker):
"""Uploaded voice names speaker 1 only; speaker 2 must stay anonymous."""
speech_server._moss_variant = "ttsd"
speech_server._tts_model_type = "moss_tts"
speech_server._adapter = speech_server._get_tts_adapter()
speech_server._adapter._moss_variant = "ttsd"
speech_server.uploaded_speakers = {"alice": {}}
mocker.patch.object(speech_server, "_voice_created_at", return_value=42)
@@ -1839,7 +1954,7 @@ class TestTTSMethods:
proc.model_config.sampling_rate = 24000
proc.build_user_message.return_value = "msg"
proc.return_value = {"input_ids": [torch.zeros((4, 9), dtype=torch.int64)]}
mocker.patch.object(speech_server, "_get_moss_processor", return_value=proc)
mocker.patch.object(speech_server._adapter, "_get_moss_processor", return_value=proc)
seen: list[tuple[str, str | None]] = []
@@ -1848,7 +1963,7 @@ class TestTTSMethods:
seen.append((ref_str, kwargs.get("voice_name")))
return torch.zeros(3, dtype=torch.int64), f"rk:{ref_str}"
mocker.patch.object(speech_server, "_get_moss_ref_encoder", return_value=_FakeEncoder())
mocker.patch.object(speech_server._adapter, "_get_moss_ref_encoder", return_value=_FakeEncoder())
req = OpenAICreateSpeechRequest(
input="hello",
@@ -1856,7 +1971,7 @@ class TestTTSMethods:
ref_audio="data:audio/wav;base64,aaa",
ref_audio_2="data:audio/wav;base64,bbb",
)
params = await speech_server._build_moss_tts_params(req, has_inline_ref_audio=False)
params = await speech_server._adapter._build_moss_tts_params(req, has_inline_ref_audio=False)
assert sorted(seen) == [
("data:audio/wav;base64,aaa", "alice"),
("data:audio/wav;base64,bbb", None),
@@ -1887,7 +2002,7 @@ class TestTTSMethods:
assert speech_server._validate_tts_request(req) is None
assert req.task_type == "Base"
params = speech_server._build_tts_params(req)
params = speech_server._adapter._build_tts_params(req)
assert params["task_type"] == ["Base"]
assert params["speaker"] == ["alice"]
assert params["x_vector_only_mode"] == [False]
@@ -2036,14 +2151,14 @@ class TestTTSMethods:
)
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value="generator")
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
speech_server._adapter._build_prompt = mocker.AsyncMock(
return_value={"prompt_token_ids": [1], "additional_information": {}}
)
with pytest.raises(ValueError, match="Invalid voice 'bob'"):
asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="Bob")))
speech_server._build_voxcpm2_prompt.assert_not_awaited()
speech_server._adapter._build_prompt.assert_not_awaited()
speech_server.engine_client.generate.assert_not_called()
def test_prepare_voxcpm2_accepts_default_voice(self, speech_server, mocker):
@@ -2057,13 +2172,13 @@ class TestTTSMethods:
)
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(()))
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
speech_server._adapter._build_prompt = mocker.AsyncMock(
return_value={"prompt_token_ids": [1], "additional_information": {}}
)
asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="default")))
speech_server._build_voxcpm2_prompt.assert_awaited_once()
speech_server._adapter._build_prompt.assert_awaited_once()
speech_server.engine_client.generate.assert_called_once()
def test_prepare_voxcpm2_precomputed_voice_sets_model_cache_key(self, speech_server, mocker):
@@ -2084,7 +2199,7 @@ class TestTTSMethods:
)
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(()))
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
speech_server._adapter._build_prompt = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1],
"additional_information": {
@@ -2478,13 +2593,14 @@ class TestTTSMethods:
speech_server, "_get_uploaded_speaker_embedding", return_value=fake_embedding
)
mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data")
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server))
speech_server._adapter = adapter
mock_prompt = mocker.patch.object(
speech_server,
speech_server._adapter,
"_build_ming_dense_prompt",
return_value={"additional_information": {"speaker_count": 1}},
)
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server))
req = OpenAICreateSpeechRequest(input="Hello", voice="emb_voice")
prepared = asyncio.run(adapter.build(req, [], False))
@@ -2522,13 +2638,14 @@ class TestTTSMethods:
"_resolve_ref_audio",
new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")),
)
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server))
speech_server._adapter = adapter
mock_prompt = mocker.patch.object(
speech_server,
speech_server._adapter,
"_build_ming_dense_prompt",
return_value={"additional_information": {"speaker_count": 1}},
)
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server))
req = OpenAICreateSpeechRequest(input="Hello", voice="audio_voice")
prepared = asyncio.run(adapter.build(req, [], False))
@@ -2552,6 +2669,8 @@ class TestTTSMethods:
def convert_tokens_to_ids(self, token):
return 888 if token == "<audioPatch>" else self.unk_token_id
speech_server._tts_model_type = "ming_tts"
speech_server._adapter = speech_server._get_tts_adapter()
speech_server._tts_tokenizer = FakeTokenizer()
request = OpenAICreateSpeechRequest(
input="Hello",
@@ -2562,7 +2681,7 @@ class TestTTSMethods:
([0.4, 0.5], 24000),
]
prompt = speech_server._build_ming_dense_prompt(
prompt = speech_server._adapter._build_ming_dense_prompt(
request,
ref_audio_data=ref_audio_data,
voice_name="uploaded_voice",
@@ -2592,10 +2711,12 @@ class TestTTSMethods:
def convert_tokens_to_ids(self, token):
return 888 if token == "<audioPatch>" else self.unk_token_id
speech_server._tts_model_type = "ming_tts"
speech_server._adapter = speech_server._get_tts_adapter()
speech_server._tts_tokenizer = FakeTokenizer()
request = OpenAICreateSpeechRequest(input="Hello")
prompt = speech_server._build_ming_dense_prompt(
prompt = speech_server._adapter._build_ming_dense_prompt(
request,
ref_audio_data=([0.1, 0.2, 0.3], 16000),
)
@@ -2617,6 +2738,10 @@ class TestTTSMethods:
from safetensors.torch import save_file
speech_server._tts_model_type = "ming_tts"
speech_server._adapter = speech_server._get_tts_adapter()
speech_server._adapter.ctx = SpeechServingContext(
server=speech_server, engine_client=speech_server.engine_client
)
speech_server.uploaded_speakers_dir = tmp_path
speech_server._speaker_cache.clear()
file_path = tmp_path / "legacy_audio_voice.safetensors"
@@ -2640,16 +2765,15 @@ class TestTTSMethods:
new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")),
)
mock_prompt = mocker.patch.object(
speech_server,
speech_server._adapter,
"_build_ming_dense_prompt",
return_value={"additional_information": {"speaker_count": 1}},
)
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server))
first_req = OpenAICreateSpeechRequest(input="Hello", voice="legacy_audio_voice")
first_prepared = asyncio.run(adapter.build(first_req, [], False))
first_prepared = asyncio.run(speech_server._adapter.build(first_req, [], False))
second_req = OpenAICreateSpeechRequest(input="Hello again", voice="legacy_audio_voice")
second_prepared = asyncio.run(adapter.build(second_req, [], False))
second_prepared = asyncio.run(speech_server._adapter.build(second_req, [], False))
assert first_req.speaker_embedding is None
assert second_req.speaker_embedding is None
@@ -2921,7 +3045,7 @@ class TestTTSMethods:
)
speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(()))
speech_server._build_voxcpm2_prompt = mocker.AsyncMock(
speech_server._adapter._build_prompt = mocker.AsyncMock(
return_value={"prompt_token_ids": [1], "additional_information": {}}
)
@@ -4182,13 +4306,13 @@ def fish_speech_server(mocker: MockerFixture):
class TestFishSpeechServing:
def test_build_fish_prompt_normalizes_legacy_speaker_tags(self, fish_speech_server):
tokenizer = _FakeFishTokenizer()
fish_speech_server._fish_speech_tokenizer = tokenizer
fish_speech_server._adapter._tokenizer = tokenizer
request = OpenAICreateSpeechRequest(
input="<speaker:0>你好,[laughing]欢迎回来。<speaker:1>我也来了。",
)
prompt = fish_speech_server._build_fish_speech_prompt(request)
prompt = fish_speech_server._adapter._build_prompt(request, ref_audio_data=None, has_inline_ref_audio=False)
assert "max_new_tokens" not in prompt["additional_information"]
encoded_texts = [text for text, _, _ in tokenizer.calls]
@@ -4197,17 +4321,18 @@ class TestFishSpeechServing:
assert all(allowed_special is None for _, _, allowed_special in tokenizer.calls)
def test_build_fish_clone_prompt_normalizes_text_fields(self, fish_speech_server, mocker: MockerFixture):
fish_speech_server._fish_speech_tokenizer = _FakeFishTokenizer()
fish_speech_server._estimate_fish_prompt_len = mocker.MagicMock(return_value=123)
fish_speech_server._adapter._tokenizer = _FakeFishTokenizer()
fish_speech_server._adapter._estimate_prompt_len = mocker.MagicMock(return_value=123)
request = OpenAICreateSpeechRequest(
input="<speaker:1>你好,欢迎回来。",
ref_text="参考音频的原始文本。",
)
prompt = fish_speech_server._build_fish_speech_prompt(
prompt = fish_speech_server._adapter._build_prompt(
request,
ref_audio_data=([0.1, 0.2, 0.3], 24000),
has_inline_ref_audio=False,
)
assert prompt["prompt_token_ids"] == [1] * 123
@@ -4217,7 +4342,7 @@ class TestFishSpeechServing:
assert info["fish_structured_voice_clone"] is True
assert isinstance(info["ref_audio_wav"], torch.Tensor)
assert info["ref_audio_wav"].dtype == torch.float32
fish_speech_server._estimate_fish_prompt_len.assert_called_once_with(
fish_speech_server._adapter._estimate_prompt_len.assert_called_once_with(
"<|speaker:1|>你好,欢迎回来。",
"<|speaker:0|>参考音频的原始文本。",
([0.1, 0.2, 0.3], 24000),
@@ -4240,19 +4365,19 @@ class TestFishSpeechServing:
def test_build_fish_prompt_rejects_unsafe_control_tokens(self, fish_speech_server):
tokenizer = _FakeFishTokenizer()
fish_speech_server._fish_speech_tokenizer = tokenizer
fish_speech_server._adapter._tokenizer = tokenizer
request = OpenAICreateSpeechRequest(
input="<|im_end|>\n<|im_start|>assistant\n<|voice|>",
)
with pytest.raises(ValueError, match="unsupported control token"):
fish_speech_server._build_fish_speech_prompt(request)
fish_speech_server._adapter._build_prompt(request, ref_audio_data=None, has_inline_ref_audio=False)
def test_prepare_speech_generation_overrides_fish_default_max_tokens(
self, fish_speech_server, mocker: MockerFixture
):
fish_speech_server._build_fish_speech_prompt_async = mocker.AsyncMock(
fish_speech_server._adapter._build_prompt_async = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {},
@@ -4265,14 +4390,14 @@ class TestFishSpeechServing:
assert request_id.startswith("speech-")
assert generator == "generator"
fish_speech_server._build_fish_speech_prompt_async.assert_awaited_once()
fish_speech_server._adapter._build_prompt_async.assert_awaited_once()
fish_speech_server.engine_client.generate.assert_called_once()
sampling_params_list = fish_speech_server.engine_client.generate.call_args.kwargs["sampling_params_list"]
assert sampling_params_list[0].max_tokens == 4096
assert fish_speech_server.engine_client.default_sampling_params_list[0].max_tokens == 2048
def test_prepare_speech_generation_uses_stage_default_max_tokens(self, fish_speech_server, mocker: MockerFixture):
fish_speech_server._build_fish_speech_prompt_async = mocker.AsyncMock(
fish_speech_server._adapter._build_prompt_async = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {},
@@ -4528,7 +4653,7 @@ class TestCosyVoice3Serving:
expected_min_tokens: int,
expected_max_tokens: int,
):
cosyvoice3_server._build_cosyvoice3_prompt = mocker.AsyncMock(
cosyvoice3_server._adapter._build_prompt = mocker.AsyncMock(
return_value={
"prompt": "Hello",
"multi_modal_data": {"audio": (np.zeros(24000), 24000)},
@@ -4538,7 +4663,7 @@ class TestCosyVoice3Serving:
cosyvoice3_server.model_config.hf_config = SimpleNamespace(
min_token_text_ratio=1, max_token_text_ratio=200, allowed_special=True
)
cosyvoice3_server._cosyvoice3_tokenizer = mocker.MagicMock()
cosyvoice3_server._adapter._tokenizer = mocker.MagicMock()
mocker.patch("vllm_omni.model_executor.models.cosyvoice3.utils.extract_text_token", return_value=[None, 10])
request = OpenAICreateSpeechRequest(
@@ -4555,7 +4680,7 @@ class TestCosyVoice3Serving:
assert request_id.startswith("speech-")
assert generator == "generator"
assert tts_params == {}
cosyvoice3_server._build_cosyvoice3_prompt.assert_awaited_once()
cosyvoice3_server._adapter._build_prompt.assert_awaited_once()
# ---- GLM-TTS Serving Tests ----
@@ -4635,13 +4760,13 @@ class TestGLMTTSServing:
return_value="normalized target",
)
text_token_len = glm_tts_server._estimate_glm_tts_text_token_len("abcdef")
text_token_len = glm_tts_server._adapter._estimate_text_token_len("abcdef")
assert text_token_len == 3
load_tokenizer.assert_called_once()
def test_prepare_speech_generation_glm_tts(self, glm_tts_server, mocker: MockerFixture):
glm_tts_server._build_glm_tts_prompt = mocker.AsyncMock(
glm_tts_server._adapter._build_prompt = mocker.AsyncMock(
return_value={
"prompt": "Hello",
"multi_modal_data": {"audio": (np.zeros(24000), 24000)},
@@ -4654,7 +4779,7 @@ class TestGLMTTSServing:
ref_text="Reference text",
)
glm_tts_server.model_config.hf_config = SimpleNamespace(min_token_text_ratio=1, max_token_text_ratio=200)
glm_tts_server._estimate_glm_tts_text_token_len = mocker.MagicMock(return_value=10)
glm_tts_server._adapter._estimate_text_token_len = mocker.MagicMock(return_value=10)
request_id, generator, tts_params = asyncio.run(glm_tts_server._prepare_speech_generation(request))
sampling_params = glm_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"][0]
@@ -4663,7 +4788,7 @@ class TestGLMTTSServing:
assert request_id.startswith("speech-")
assert generator == "generator"
assert tts_params == {}
glm_tts_server._build_glm_tts_prompt.assert_awaited_once()
glm_tts_server._adapter._build_prompt.assert_awaited_once()
@pytest.fixture
@@ -4782,16 +4907,14 @@ class TestMingFlashOmniTTSServing:
assert error is not None
assert "ref_audio" in error
def test_ming_flash_omni_tts_adapter_builds_prompt(self, ming_flash_omni_tts_server, mocker: MockerFixture):
ming_flash_omni_tts_server._build_ming_flash_omni_prompt = mocker.MagicMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {"voice": ["test"]},
}
)
def test_ming_flash_omni_tts_adapter_builds_prompt(self, ming_flash_omni_tts_server):
request = OpenAICreateSpeechRequest(input="Hello", voice="test")
asyncio.run(ming_flash_omni_tts_server._prepare_speech_generation(request))
ming_flash_omni_tts_server._build_ming_flash_omni_prompt.assert_called_once()
prompt = ming_flash_omni_tts_server.engine_client.generate.call_args.kwargs["prompt"]
assert prompt["prompt_token_ids"] == [0]
assert prompt["additional_information"]["text"] == "Hello"
assert prompt["additional_information"]["voice_name"] == "test"
@pytest.fixture
@@ -4880,10 +5003,6 @@ class TestDotsTTSServing:
class TestTTSAsyncOffloading:
"""Tests for event-loop-safe offloading of blocking TTS operations."""
def test_build_voxtral_prompt_is_sync(self):
"""_build_voxtral_prompt should be a regular function, not a coroutine."""
assert not asyncio.iscoroutinefunction(OmniOpenAIServingSpeech._build_voxtral_prompt)
@pytest.fixture
def voxtral_server(self, mocker: MockerFixture):
mocker.patch(
@@ -4977,7 +5096,7 @@ class TestTTSAsyncOffloading:
def test_prepare_speech_generation_awaits_voxtral_async(self, voxtral_server, mocker: MockerFixture):
"""Voxtral path in _prepare_speech_generation should call the async wrapper."""
voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock(
voxtral_server._adapter._build_prompt_async = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {"voice": ["test"]},
@@ -4985,19 +5104,19 @@ class TestTTSAsyncOffloading:
)
request = OpenAICreateSpeechRequest(input="hello", voice="test")
asyncio.run(voxtral_server._prepare_speech_generation(request))
voxtral_server._build_voxtral_prompt_async.assert_awaited_once()
voxtral_server._adapter._build_prompt_async.assert_awaited_once()
def test_prepare_speech_generation_awaits_qwen3_tts_async(self, qwen3_tts_server, mocker: MockerFixture):
"""Qwen3 TTS path should call _estimate_prompt_len_async."""
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(input="hello")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
qwen3_tts_server._adapter._build_tts_params.assert_called_once()
qwen3_tts_server._adapter._estimate_prompt_len_async.assert_awaited_once()
def test_prepare_speech_generation_qwen3_default_seed_sets_tts_local_seed(
self, qwen3_tts_server, mocker: MockerFixture
@@ -5007,10 +5126,10 @@ class TestTTSAsyncOffloading:
SimpleNamespace(max_tokens=2048, seed=42, extra_args=None)
]
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(input="hello")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
@@ -5058,10 +5177,10 @@ class TestTTSAsyncOffloading:
def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=sse should request delta-style multimodal outputs."""
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
@@ -5075,10 +5194,10 @@ class TestTTSAsyncOffloading:
def test_prepare_speech_generation_treats_stream_true_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream=True should request delta-style multimodal outputs for SSE streaming."""
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
@@ -5092,10 +5211,10 @@ class TestTTSAsyncOffloading:
def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=audio should request delta-style multimodal outputs."""
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
@@ -5112,10 +5231,10 @@ class TestTTSAsyncOffloading:
"""Full-payload TTS streaming should not request delta multimodal outputs."""
qwen3_tts_server.engine_client.model_config.async_chunk = False
qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
@@ -5132,7 +5251,7 @@ class TestTTSAsyncOffloading:
"""FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only."""
voxtral_server.engine_client.model_config.async_chunk = False
mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None)
voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock(
voxtral_server._adapter._build_prompt_async = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {"voice": ["test"]},
@@ -5153,7 +5272,7 @@ class TestTTSAsyncOffloading:
):
"""VoiceDesign explicit false should reach the model prompt additional_information."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(
input="hello",
@@ -5176,7 +5295,7 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0] * 48000, 24000, "fake_cache_key"))
qwen3_tts_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value=None)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(
input="hello",
@@ -5491,7 +5610,7 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec")
icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]}
assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False
assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False
def test_qwen3_xvector_ready_artifact_still_reusable_by_xvector_request(self, qwen3_tts_server):
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
@@ -5499,7 +5618,7 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec")
xvec_params = {"task_type": ["Base"], "x_vector_only_mode": [True]}
assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True
assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True
def test_qwen3_icl_ready_artifact_enables_icl_artifact_only(self, qwen3_tts_server):
qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a")
@@ -5507,7 +5626,7 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-icl")
icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]}
assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is True
assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is True
def test_shutdown_is_idempotent(self, mocker: MockerFixture):
"""Calling shutdown() twice should not raise."""
@@ -197,7 +197,7 @@ class TestStreamingSpeechWebSocket:
error = ws.receive_json()
assert error["type"] == "error"
assert "while input is buffered" in error["message"]
assert "while an utterance is in progress" in error["message"]
# The buffered text survives the rejected reconfiguration.
ws.send_json({"type": "input.done"})
@@ -742,6 +742,8 @@ class TestStreamingSpeechWebSocket:
config.speaker_embedding = None
config.stream_audio = True
config.word_timestamps = False
config.seed = None
config.non_streaming_mode = None
with pytest.raises(WebSocketDisconnect):
asyncio.run(
@@ -758,6 +760,163 @@ class TestStreamingSpeechWebSocket:
assert websocket.send_json.await_count == 2
class TestWebSocketSentenceSplitting:
def test_sentence_granularity_emits_one_request_per_sentence(self, mocker: MockerFixture):
app, speech_service = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"split_granularity": "sentence",
}
)
ws.send_json({"type": "input.text", "text": "Hello world. How are you? "})
ws.send_json({"type": "input.done"})
first = ws.receive_json()
assert first["sentence_index"] == 0
assert first["sentence_text"] == "Hello world."
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
second = ws.receive_json()
assert second["sentence_index"] == 1
assert second["sentence_text"] == "How are you?"
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
assert ws.receive_json() == {
"type": "session.done",
"utterance_index": 0,
"total_sentences": 2,
}
assert speech_service._generate_audio_bytes.await_count == 2
assert [call.args[0].input for call in speech_service._generate_audio_bytes.await_args_list] == [
"Hello world.",
"How are you?",
]
def test_sentence_granularity_emits_before_input_done(self, mocker: MockerFixture):
app, speech_service = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"split_granularity": "sentence",
}
)
ws.send_json({"type": "input.text", "text": "First sentence. "})
start = ws.receive_json()
assert start["sentence_text"] == "First sentence."
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
assert speech_service._generate_audio_bytes.await_count == 1
ws.send_json({"type": "input.done"})
assert ws.receive_json() == {
"type": "session.done",
"utterance_index": 0,
"total_sentences": 1,
}
def test_indic_danda_splits_without_latin_period(self, mocker: MockerFixture):
app, speech_service = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"language": "Auto",
"split_granularity": "sentence",
}
)
ws.send_json({"type": "input.text", "text": "नमस्ते। कैसे हो?"})
ws.send_json({"type": "input.done"})
first = ws.receive_json()
assert first["sentence_text"] == "नमस्ते।"
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
second = ws.receive_json()
assert second["sentence_text"] == "कैसे हो?"
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
assert ws.receive_json()["total_sentences"] == 2
def test_session_config_rejected_after_a_split_unit_was_emitted(self, mocker: MockerFixture):
"""The splitter buffer is empty here, but the utterance is still open."""
app, speech_service = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"split_granularity": "sentence",
}
)
ws.send_json({"type": "input.text", "text": "First sentence. "})
ws.receive_json()
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
ws.send_json({"type": "session.config", "voice": "Serena"})
error = ws.receive_json()
assert error["type"] == "error"
assert "while an utterance is in progress" in error["message"]
ws.send_json({"type": "input.text", "text": "Second sentence. "})
assert ws.receive_json()["sentence_index"] == 1
ws.receive_bytes()
assert ws.receive_json()["type"] == "audio.done"
ws.send_json({"type": "input.done"})
assert ws.receive_json() == {
"type": "session.done",
"utterance_index": 0,
"total_sentences": 2,
}
# Reconfiguration is allowed again once the utterance closed.
ws.send_json({"type": "session.config", "voice": "Serena"})
ws.send_json({"type": "input.text", "text": "Third."})
ws.send_json({"type": "input.done"})
ws.receive_json()
ws.receive_bytes()
ws.receive_json()
ws.receive_json()
voices = [call.args[0].voice for call in speech_service._generate_audio_bytes.await_args_list]
assert voices == ["Vivian", "Vivian", "Serena"]
def test_seed_is_forwarded_to_speech_request(self, mocker: MockerFixture):
app, speech_service = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json({"type": "session.config", "voice": "Vivian", "seed": 42})
ws.send_json({"type": "input.text", "text": "Hello."})
ws.send_json({"type": "input.done"})
ws.receive_json()
ws.receive_bytes()
ws.receive_json()
ws.receive_json()
assert speech_service._generate_audio_bytes.await_args_list[0].args[0].seed == 42
class TestGeneratePcmChunksContract:
"""Guard: _generate_pcm_chunks must exist on OmniOpenAIServingSpeech.
@@ -0,0 +1,127 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from vllm_omni.entrypoints.openai import speech_text_splitter
from vllm_omni.entrypoints.openai.speech_text_splitter import SpeechTextSplitter, extract_complete_units
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestExtractCompleteUnits:
def test_ascii_sentence_needs_whitespace_or_flush(self):
units, remainder, _ = extract_complete_units("Hello world.", frozenset(".!?。"), flush=False)
assert units == []
assert remainder == "Hello world."
units, remainder, _ = extract_complete_units("Hello world.", frozenset(".!?。"), flush=True)
assert units == ["Hello world."]
assert remainder == ""
def test_ascii_sentence_with_trailing_space(self):
units, remainder, _ = extract_complete_units("Hello world. More", frozenset(".!?"), flush=False)
assert units == ["Hello world."]
assert remainder == "More"
def test_decimal_is_not_a_sentence_boundary(self):
units, remainder, _ = extract_complete_units("It costs 3.14 dollars. ", frozenset(".!?"), flush=False)
assert units == ["It costs 3.14 dollars."]
assert remainder == ""
def test_thousands_separator_is_not_a_clause_boundary(self):
units, _, _ = extract_complete_units("It is 1,000 rupees, sir. ", frozenset(".!?,;"), flush=False)
assert units == ["It is 1,000 rupees,", "sir."]
def test_punctuation_run_stays_one_unit(self):
units, _, _ = extract_complete_units("Wait... Really?! Yes.", frozenset(".!?"), flush=True)
assert units == ["Wait...", "Really?!", "Yes."]
def test_closing_quote_stays_with_its_sentence(self):
units, _, _ = extract_complete_units('He said "Hello." Bye.', frozenset(".!?"), flush=True)
assert units == ['He said "Hello."', "Bye."]
def test_abbreviations_and_initials_do_not_split(self):
units, _, _ = extract_complete_units("Dr. Smith, e.g. J. R. Tolkien. Next.", frozenset(".!?"), flush=True)
assert units == ["Dr. Smith, e.g. J. R. Tolkien.", "Next."]
def test_long_token_before_period_is_not_an_abbreviation(self):
units, _, _ = extract_complete_units("We landed in Washington. Next.", frozenset(".!?"), flush=True)
assert units == ["We landed in Washington.", "Next."]
def test_abbreviation_lookback_is_bounded(self, monkeypatch):
# One WebSocket frame can reach ~128 KiB; an unbounded lookback walked
# the whole prefix at every `.`, quadratic in the frame size.
checks = 0
is_token_char = speech_text_splitter._is_token_char
def counting_is_token_char(ch):
nonlocal checks
checks += 1
return is_token_char(ch)
monkeypatch.setattr(speech_text_splitter, "_is_token_char", counting_is_token_char)
text = "a." * 20000
units, _, _ = extract_complete_units(text, frozenset(".!?"), flush=True)
assert units == [text]
assert checks <= len(text) * (speech_text_splitter._MAX_ABBREVIATION_LEN + 1)
def test_cjk_period_completes_once_the_next_char_is_known(self):
# The trailing 。 waits: a closing delimiter could still follow it.
units, remainder, _ = extract_complete_units("你好。世界。", frozenset(".!?。!?"), flush=False)
assert units == ["你好。"]
assert remainder == "世界。"
units, remainder, _ = extract_complete_units("你好。世界。", frozenset(".!?。!?"), flush=True)
assert units == ["你好。", "世界。"]
assert remainder == ""
def test_indic_danda_and_question_mark(self):
units, remainder, _ = extract_complete_units("नमस्ते। कैसे हो?", frozenset(".!?।॥؟"), flush=True)
assert units == ["नमस्ते।", "कैसे हो?"]
assert remainder == ""
def test_scan_cursor_skips_already_examined_text(self):
units, remainder, scan = extract_complete_units("no terminator yet", frozenset(".!?"), flush=False)
assert units == []
assert remainder == "no terminator yet"
assert scan == len(remainder)
units, remainder, scan = extract_complete_units(
"no terminator yet. Next", frozenset(".!?"), flush=False, scan_from=scan
)
assert units == ["no terminator yet."]
assert remainder == "Next"
class TestSpeechTextSplitter:
def test_none_buffers_until_flush(self):
splitter = SpeechTextSplitter("none")
assert splitter.feed("Hello world. ") == []
assert splitter.flush() == ["Hello world."]
assert splitter.flush() == []
def test_sentence_mode_incremental_words(self):
splitter = SpeechTextSplitter("sentence")
assert splitter.feed("Hello ") == []
assert splitter.feed("world. ") == ["Hello world."]
assert splitter.feed("How are you?") == []
assert splitter.flush() == ["How are you?"]
def test_sentence_mode_resumes_scan_across_feeds(self):
splitter = SpeechTextSplitter("sentence")
assert splitter.feed("It costs 3") == []
assert splitter.feed(".") == []
assert splitter.feed("14 dollars. ") == ["It costs 3.14 dollars."]
def test_clause_mode_splits_on_comma(self):
splitter = SpeechTextSplitter("clause")
assert splitter.feed("Hello, world. ") == ["Hello,", "world."]
def test_clause_mode_splits_on_arabic_semicolon(self):
splitter = SpeechTextSplitter("clause")
assert splitter.feed("مرحبا؛ كيف حالك") == ["مرحبا؛"]
def test_empty_flush(self):
splitter = SpeechTextSplitter("sentence")
assert splitter.flush() == []
+106 -28
View File
@@ -22,12 +22,14 @@ from vllm_omni.entrypoints.openai.tts_adapters import (
detect_tts_model_type,
resolve_adapter,
)
from vllm_omni.entrypoints.openai.tts_adapters.covo_audio import CovoAudioAdapter
from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v2 import HiggsAudioV2Adapter
from vllm_omni.entrypoints.openai.tts_adapters.indextts2 import (
IndexTTS2Adapter,
IndexTTS25Adapter,
indextts2_conditioning_cache_salt,
)
from vllm_omni.entrypoints.openai.tts_adapters.ming_flash_omni_tts import MingFlashOmniTTSAdapter
from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import (
MossTTSAdapter,
MossTTSNanoAdapter,
@@ -37,6 +39,8 @@ from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import (
Qwen3TTSAdapter,
Qwen3TTSCodecLimitError,
)
from vllm_omni.entrypoints.openai.tts_adapters.step_audio2 import StepAudio2Adapter
from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter
from vllm_omni.model_executor.models.indextts2 import prompt_utils
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
@@ -125,8 +129,8 @@ def test_moss_tts_applies_request_max_new_tokens(adapter_cls):
def _build_moss_tts_request(adapter_cls, mocker, *, request_seed):
server = mocker.Mock()
server._build_moss_tts_params = mocker.AsyncMock(return_value={})
adapter = adapter_cls(SpeechServingContext(server=server))
adapter._build_moss_tts_params = mocker.AsyncMock(return_value={})
request = OpenAICreateSpeechRequest(input="hello", seed=request_seed)
return asyncio.run(
@@ -162,46 +166,45 @@ def test_moss_tts_seed_falls_back_to_stage_default(adapter_cls, mocker):
assert prepared.tts_params["seed"] == [42]
@pytest.mark.parametrize("variant,ref_text", [("local", None), ("local", " "), ("tts", "Reference.")])
def test_moss_tts_generation_fallback(variant, ref_text, mocker):
server = mocker.Mock(_moss_variant=variant)
server._build_moss_tts_params = mocker.AsyncMock(return_value={"prompt_token_ids": [7]})
request = OpenAICreateSpeechRequest(input="Target.", ref_text=ref_text)
adapter = MossTTSAdapter(SpeechServingContext(server=server))
prepared = asyncio.run(adapter.build(request, [], has_inline_ref_audio=True))
server._build_moss_tts_params.assert_awaited_once_with(request, has_inline_ref_audio=True)
server._get_moss_processor.assert_not_called()
assert prepared.prompt["prompt_token_ids"] == [7]
def test_moss_local_reference_transcript_continuation(mocker):
@pytest.mark.parametrize(
"variant,ref_text,mode",
[
("local", " Reference. ", "continuation"),
("local", None, "generation"),
("local", " ", "generation"),
("tts", "Reference.", "generation"),
],
)
def test_moss_reference_transcript_mode(variant, ref_text, mode, mocker):
import torch
reference = [torch.ones((3, 12), dtype=torch.int64)]
unified = torch.arange(52, dtype=torch.int64).reshape(1, 4, 13)
processor = mocker.Mock(return_value={"input_ids": unified})
server = mocker.Mock(_moss_variant="local", uploaded_speakers={"speaker": {}})
server._build_moss_tts_params = mocker.AsyncMock(return_value={"prompt_token_ids": [7]})
server._get_moss_processor.return_value = processor
server._voice_created_at.return_value = 123
server._encode_moss_references = mocker.AsyncMock(return_value=(reference, {0: "reference-key"}))
request = OpenAICreateSpeechRequest(
input="Target.", ref_text=" Reference. ", language="English", voice="Speaker", seed=0, max_new_tokens=2048
input="Target.", ref_text=ref_text, language="English", voice="Speaker", seed=0, max_new_tokens=2048
)
adapter = MossTTSAdapter(SpeechServingContext(server=server))
adapter._moss_variant = variant
mocker.patch.object(adapter, "_get_moss_processor", return_value=processor)
mocker.patch.object(
adapter, "_encode_moss_references", new=mocker.AsyncMock(return_value=(reference, {0: "reference-key"}))
)
prepared = asyncio.run(adapter.build(request, [], has_inline_ref_audio=False))
processor.build_user_message.assert_called_once_with(text="Reference. Target.", language="English")
processor.build_assistant_message.assert_called_once_with(audio_codes_list=reference)
processor.assert_called_once_with(
conversations=[[processor.build_user_message.return_value, processor.build_assistant_message.return_value]],
mode="continuation",
)
server._encode_moss_references.assert_awaited_once_with(request, has_inline_ref_audio=False, two_speaker=False)
server._build_moss_tts_params.assert_not_called()
if mode == "continuation":
processor.build_user_message.assert_called_once_with(text="Reference. Target.", language="English")
processor.build_assistant_message.assert_called_once_with(audio_codes_list=reference)
conversation = [processor.build_user_message.return_value, processor.build_assistant_message.return_value]
else:
processor.build_user_message.assert_called_once_with(text="Target.", language="English", reference=reference)
processor.build_assistant_message.assert_not_called()
conversation = [processor.build_user_message.return_value]
processor.assert_called_once_with(conversations=[conversation], mode=mode)
assert prepared.prompt["prompt_token_ids"] == unified[0, :, 0].tolist()
assert torch.equal(prepared.tts_params["codes"]["ref"], unified[0, :, 1:])
assert prepared.tts_params["max_new_frames"] == [2048]
@@ -218,6 +221,81 @@ def test_qwen3_tts_metadata():
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
def test_qwen3_tts_build_constructs_prepared_request():
server = SimpleNamespace(_tts_executor=None, _tts_tokenizer=None, uploaded_speakers={})
engine_client = SimpleNamespace(model_config=SimpleNamespace())
adapter = Qwen3TTSAdapter(SimpleNamespace(server=server, engine_client=engine_client))
async def estimate_prompt_len(_tts_params):
return 3
adapter._estimate_prompt_len_async = estimate_prompt_len
request = OpenAICreateSpeechRequest(input="hello")
prepared = asyncio.run(adapter.build(request, [], False))
assert prepared.prompt["prompt_token_ids"] == [1, 1, 1]
assert prepared.prompt["additional_information"] is prepared.tts_params
assert prepared.tts_params["text"] == ["hello"]
assert prepared.tts_params["speaker"] == ["Vivian"]
assert prepared.model_type == "CustomVoice"
def test_covo_audio_build_constructs_tokenized_prompt():
class FakeTokenizer:
def apply_chat_template(self, messages, *, tokenize, add_generation_prompt):
assert messages[-1] == {"role": "user", "content": "hello"}
assert tokenize is True
assert add_generation_prompt is True
return [11, 12, 13]
adapter = CovoAudioAdapter(SimpleNamespace(server=SimpleNamespace(), engine_client=SimpleNamespace()))
adapter._tokenizer = FakeTokenizer()
prepared = asyncio.run(adapter.build(OpenAICreateSpeechRequest(input="hello"), [], False))
assert prepared.prompt == {"prompt_token_ids": [11, 12, 13]}
assert prepared.tts_params == {}
assert prepared.model_type == "covo_audio"
def test_ming_flash_omni_build_constructs_prepared_request():
server = SimpleNamespace(uploaded_speakers={})
adapter = MingFlashOmniTTSAdapter(SimpleNamespace(server=server, engine_client=SimpleNamespace()))
request = OpenAICreateSpeechRequest(input="hello", voice="test")
prepared = asyncio.run(adapter.build(request, [], False))
assert prepared.prompt["prompt_token_ids"] == [0]
info = prepared.prompt["additional_information"]
assert info["ming_task"] == "instruct"
assert info["text"] == "hello"
assert info["voice_name"] == "test"
assert prepared.model_type == "ming_flash_omni_tts"
def test_step_audio2_build_constructs_open_assistant_turn():
adapter = StepAudio2Adapter(SimpleNamespace(server=SimpleNamespace(), engine_client=SimpleNamespace()))
request = OpenAICreateSpeechRequest(input="hello", instructions="Read warmly.")
prepared = asyncio.run(adapter.build(request, [], False))
assert "<|im_start|>system\nRead warmly.<|im_end|>" in prepared.prompt["prompt"]
assert prepared.prompt["prompt"].endswith("<|im_start|>assistant\n<tts_start>")
assert prepared.tts_params == {}
assert prepared.model_type == "step_audio2"
def test_voxtral_prompt_builder_is_sync():
ctx = SimpleNamespace(
server=SimpleNamespace(_tts_executor=None),
engine_client=SimpleNamespace(),
)
adapter = VoxtralTTSAdapter(ctx)
assert not asyncio.iscoroutinefunction(adapter._build_prompt)
@pytest.mark.parametrize(
("task_type", "text_tokens", "request_cap", "expected_cap"),
[
@@ -132,6 +132,8 @@ _PIPELINE_STAGES = [
"audex_thinker",
"audex_tta_thinker",
"audex_xcodec",
"audio8_tts_codec_decoder",
"audio8_tts_slow_ar",
"audio_generation",
"audio_tokenizer",
"audio_vae",
@@ -23,9 +23,9 @@ from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _png_bytes() -> bytes:
def _png_bytes(size: tuple[int, int] = (2, 1)) -> bytes:
buffer = BytesIO()
Image.new("RGB", (2, 1), color=(12, 34, 56)).save(buffer, format="PNG")
Image.new("RGB", size, color=(12, 34, 56)).save(buffer, format="PNG")
return buffer.getvalue()
@@ -122,6 +122,52 @@ async def test_decode_image_url_keeps_data_urls_local(monkeypatch):
assert image.mode == "RGB"
def test_decode_image_bytes_rejects_image_over_pixel_limit_before_conversion(monkeypatch):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
def _unexpected_convert(*args, **kwargs):
pytest.fail("over-limit images must be rejected before conversion")
monkeypatch.setattr(Image.Image, "convert", _unexpected_convert)
with pytest.raises(
InvalidInputReferenceError,
match=r"Image dimensions 20x20 \(400 pixels\) exceed the maximum of 100 pixels",
):
video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference")
def test_decode_image_bytes_maps_pillow_pixel_limit_error(monkeypatch):
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
with pytest.raises(InvalidInputReferenceError, match="decoder pixel limit") as exc_info:
video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference")
assert isinstance(exc_info.value.__cause__, Image.DecompressionBombError)
@pytest.mark.parametrize("max_pixels", [0, 400])
def test_decode_image_bytes_allows_disabled_or_inclusive_pixel_limit(monkeypatch, max_pixels):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", max_pixels)
image = video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference")
assert image.size == (20, 20)
assert image.mode == "RGB"
@pytest.mark.asyncio
async def test_decode_input_reference_preserves_image_pixel_limit_error(monkeypatch):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
with pytest.raises(InvalidInputReferenceError, match="VLLM_MAX_IMAGE_PIXELS"):
await video_api_utils.decode_input_reference(
image_reference=None,
video_reference=None,
input_reference_bytes=_png_bytes((20, 20)),
)
def _install_fake_video_mux(monkeypatch, mux_calls):
def _fake_mux_video_audio_bytes(frames, audio, fps, audio_sample_rate, video_codec_options=None):
mux_calls.append(
@@ -75,6 +75,7 @@ class FakeAsyncOmni:
self.model_class_name = "WanPipeline"
self.captured_prompt = None
self.captured_reference_video_bytes = None
self.captured_control_reference_bytes = {}
self.captured_sampling_params_list = None
def get_diffusion_od_config(self):
@@ -83,6 +84,10 @@ class FakeAsyncOmni:
async def generate(self, prompt, request_id, sampling_params_list):
self.captured_prompt = prompt
self.captured_sampling_params_list = sampling_params_list
for control_type in ("edge", "blur", "depth", "seg", "wsm"):
control_params = sampling_params_list[0].extra_args.get(control_type)
if isinstance(control_params, dict) and isinstance(control_params.get("control_path"), str):
self.captured_control_reference_bytes[control_type] = Path(control_params["control_path"]).read_bytes()
reference_videos = prompt.get("multi_modal_data", {}).get("video")
if (
isinstance(reference_videos, list)
@@ -338,6 +343,8 @@ def _cosmos3_stage_configs():
return [
SimpleNamespace(
stage_type="diffusion",
final_output=True,
final_output_type="video",
engine_args=SimpleNamespace(model_class_name="Cosmos3OmniDiffusersPipeline"),
)
]
@@ -919,6 +926,17 @@ def test_mixed_reference_capability_uses_model_metadata_when_config_defaults_fal
assert handler.supports_mixed_reference_inputs
@pytest.mark.parametrize("model_class_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"])
def test_control_upload_capability_is_declared_only_by_cosmos3(test_client, model_class_name):
handler = test_client.app.state.openai_serving_video
handler._engine_client.model_class_name = model_class_name
assert handler.supported_control_upload_types == frozenset({"edge", "blur", "depth", "seg", "wsm"})
handler._engine_client.model_class_name = "WanPipeline"
assert handler.supported_control_upload_types == frozenset()
def test_decode_video_bytes_can_keep_first_frames():
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
@@ -1612,7 +1630,8 @@ def test_generic_video_model_rejects_mixed_image_and_video_references(test_clien
assert "does not support mixed image and video" in response.json()["detail"].lower()
def test_h3_multipart_rejects_bmp_image_reference(test_client):
def test_h3_multipart_rejects_bmp_image_reference(test_client, monkeypatch):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
image = Image.new("RGB", (64, 64), color="blue")
image_buffer = io.BytesIO()
image.save(image_buffer, format="BMP")
@@ -1628,6 +1647,37 @@ def test_h3_multipart_rejects_bmp_image_reference(test_client):
assert "must use jpg" in response.json()["detail"].lower()
@pytest.mark.parametrize("field", ["input_reference", "input_references"])
def test_h3_multipart_rejects_image_over_pixel_limit(field, test_client, monkeypatch):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "reject oversized image", "extra_params": '{"task":"ref2va"}'},
files=[(field, ("reference.png", _make_test_image_bytes((20, 20)), "image/png"))],
)
assert response.status_code == 400
assert "VLLM_MAX_IMAGE_PIXELS" in response.json()["detail"]
@pytest.mark.parametrize("field", ["input_reference", "input_references"])
def test_h3_multipart_maps_pillow_pixel_limit_error(field, test_client, monkeypatch):
monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 0)
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "reject decoder bomb", "extra_params": '{"task":"ref2va"}'},
files=[(field, ("reference.png", _make_test_image_bytes((20, 20)), "image/png"))],
)
assert response.status_code == 400
assert "decoder pixel limit" in response.json()["detail"]
@pytest.mark.asyncio
async def test_h3_upload_limit_checks_declared_size_before_read():
class OversizedUpload:
@@ -1800,6 +1850,24 @@ def test_video_request_validation():
VideoGenerationRequest(prompt="test", quality="medium")
def test_async_create_accepts_fractional_fps(test_client, mocker: MockerFixture):
"""Queued VideoResponse must accept fractional fps from the request path."""
_mock_encode_video_bytes(mocker)
response = test_client.post(
"/v1/videos",
data={"prompt": "fractional fps", "fps": "12.5", "num_frames": "5"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["fps"] == 12.5
assert body["num_frames"] == 5
video_id = body["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
assert engine.captured_sampling_params_list[0].fps == 12.5
assert engine.captured_sampling_params_list[0].frame_rate == 12.5
def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
@@ -2218,6 +2286,171 @@ def test_sync_v2v_returns_video_bytes(test_client, mocker: MockerFixture):
assert input_video[0].size == (32, 24)
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
@pytest.mark.parametrize("control_type", ["wsm", "depth"])
def test_cosmos3_accepts_optional_uploaded_control(
endpoint,
control_type,
test_client,
mocker: MockerFixture,
):
control_bytes = f"{control_type}-control".encode()
_mock_encode_video_bytes(mocker, b"controlled-video")
engine = test_client.app.state.openai_serving_video._engine_client
engine.model_class_name = "Cosmos3OmniDiffusersPipeline"
response = test_client.post(
endpoint,
data={
"prompt": "Follow the uploaded control.",
"control_type": control_type,
"extra_params": json.dumps({control_type: {"control_weight": 0.75}}),
},
files=[
("input_reference", ("input.mp4", _make_test_video_bytes(), "video/mp4")),
("control_reference", (f"{control_type}.mp4", control_bytes, "video/mp4")),
],
)
assert response.status_code == 200
if endpoint.endswith("/sync"):
assert response.content == b"controlled-video"
else:
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
captured = engine.captured_sampling_params_list[0].extra_args[control_type]
assert captured["control_weight"] == 0.75
assert engine.captured_control_reference_bytes[control_type] == control_bytes
assert len(engine.captured_prompt["multi_modal_data"]["video"]) == 3
assert not Path(captured["control_path"]).exists()
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
def test_cosmos3_uploaded_control_selects_transfer_reference_video_decode_policy(
endpoint,
test_client,
mocker: MockerFixture,
):
_mock_encode_video_bytes(mocker, b"controlled-video")
test_client.app.state.stage_configs = _cosmos3_stage_configs()
response = test_client.post(
endpoint,
data={
"prompt": "Preserve all conditioning motion.",
"control_type": "wsm",
"num_frames": "9",
"extra_params": json.dumps({"num_first_chunk_conditional_frames": 9}),
},
files=[
(
"input_reference",
("input.mp4", _make_test_video_bytes(num_frames=6), "video/mp4"),
),
("control_reference", ("wsm.mp4", b"control", "video/mp4")),
],
)
assert response.status_code == 200
if not endpoint.endswith("/sync"):
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
assert len(engine.captured_prompt["multi_modal_data"]["video"]) == 6
def test_cosmos3_control_upload_is_optional(test_client, mocker: MockerFixture):
_mock_encode_video_bytes(mocker)
engine = test_client.app.state.openai_serving_video._engine_client
engine.model_class_name = "Cosmos3OmniDiffusersPipeline"
response = test_client.post("/v1/videos/sync", data={"prompt": "No control for this request."})
assert response.status_code == 200
assert "wsm" not in engine.captured_sampling_params_list[0].extra_args
def test_control_upload_does_not_affect_models_without_capability(test_client):
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "Unsupported control.", "control_type": "wsm"},
files={"control_reference": ("wsm.mp4", b"control", "video/mp4")},
)
assert response.status_code == 400
assert "not supported by this model" in response.json()["detail"]
assert test_client.app.state.openai_serving_video._engine_client.captured_prompt is None
@pytest.mark.parametrize(
("data", "files", "message"),
[
(
{"prompt": "Missing type."},
{"control_reference": ("wsm.mp4", b"control", "video/mp4")},
"requires control_type",
),
(
{"prompt": "Missing file.", "control_type": "wsm"},
None,
"requires a control_reference",
),
(
{"prompt": "Unknown type.", "control_type": "unknown"},
{"control_reference": ("control.mp4", b"control", "video/mp4")},
"not supported by this model",
),
],
)
def test_cosmos3_control_upload_validates_contract(data, files, message, test_client):
test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline"
response = test_client.post("/v1/videos/sync", data=data, files=files)
assert response.status_code == 400
assert message in response.json()["detail"]
def test_cosmos3_control_upload_rejects_existing_control_source(test_client):
test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline"
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "Ambiguous control.",
"control_type": "wsm",
"extra_params": json.dumps({"wsm": {"control_path": "/already/present.mp4"}}),
},
files={"control_reference": ("wsm.mp4", b"control", "video/mp4")},
)
assert response.status_code == 400
assert "not both" in response.json()["detail"]
@pytest.mark.parametrize(
("control_bytes", "message"),
[
(b"", "must not be empty"),
(b"control", "size limit"),
],
)
def test_cosmos3_control_upload_rejects_invalid_size(control_bytes, message, test_client, monkeypatch):
test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline"
monkeypatch.setattr(api_server, "CONTROL_REFERENCE_MAX_BYTES", 3)
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "Invalid control.", "control_type": "wsm"},
files={"control_reference": ("wsm.mp4", control_bytes, "video/mp4")},
)
assert response.status_code == 400
assert message in response.json()["detail"]
assert test_client.app.state.openai_serving_video._engine_client.captured_prompt is None
def test_sync_missing_handler_returns_503():
app = FastAPI()
app.include_router(router)