[Migrate]Move model-specific helpers out of serving_speech (#6697)

Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
This commit is contained in:
boatman authored and GitHub committed 2026-09-08 08:59:45 +08:00
1 parent c3498fb869
commit 043f03d576
24 files changed
+1581 -1563

No files matched your search

@@ -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):
@@ -53,7 +53,7 @@ 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.model_executor.models.fish_speech.prompt_utils import (
FISH_TEXT_ONLY_SYSTEM_PROMPT,
@@ -812,6 +812,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 +1672,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 +1698,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 +1841,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 +1852,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 +1861,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 +1869,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 +1900,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 +2049,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 +2070,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 +2097,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 +2491,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 +2536,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 +2567,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 +2579,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 +2609,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 +2636,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 +2663,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 +2943,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 +4204,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 +4219,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 +4240,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 +4263,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 +4288,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 +4551,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 +4561,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 +4578,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 +4658,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 +4677,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 +4686,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 +4805,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 +4901,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 +4994,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 +5002,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 +5024,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 +5075,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 +5092,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 +5109,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 +5129,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 +5149,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 +5170,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 +5193,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 +5508,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 +5516,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 +5524,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."""
@@ -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(
@@ -167,6 +171,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"),
[
@@ -11,13 +11,15 @@ import asyncio
import json
import os
import time
from collections import OrderedDict, defaultdict
from collections import defaultdict
from types import SimpleNamespace
import pytest
import torch
from tests.helpers.stage_config import get_deploy_config_path
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]
@@ -1683,31 +1685,21 @@ class TestVoiceCloneReferenceCache:
assert tok._resolve_audio_tokenizer_dir() is None
def test_higgs_v3_ref_code_cache_returns_clone(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
codes = torch.arange(16, dtype=torch.long).reshape(2, 8)
serving._put_higgs_audio_v3_ref_codes("ref-a", codes)
adapter._put_higgs_audio_v3_ref_codes("ref-a", codes)
cached = serving._get_higgs_audio_v3_ref_codes("ref-a")
cached = adapter._get_higgs_audio_v3_ref_codes("ref-a")
assert cached is not None
cached.fill_(0)
cached_again = serving._get_higgs_audio_v3_ref_codes("ref-a")
cached_again = adapter._get_higgs_audio_v3_ref_codes("ref-a")
assert cached_again is not None
assert torch.equal(cached_again, codes)
def test_higgs_v3_ref_code_inflight_deduplicates_concurrent_encode(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
serving = object.__new__(OmniOpenAIServingSpeech)
serving._higgs_audio_v3_ref_code_cache = OrderedDict()
serving._higgs_audio_v3_ref_code_cache_bytes = 0
serving._higgs_audio_v3_ref_code_inflight = {}
adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace()))
calls = 0
def encode_reference_audio(_wav, _sr):
@@ -1722,7 +1714,7 @@ class TestVoiceCloneReferenceCache:
async def run():
return await asyncio.gather(
*[
serving._resolve_higgs_audio_v3_ref_codes(
adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
@@ -1741,7 +1733,7 @@ class TestVoiceCloneReferenceCache:
assert torch.equal(codes, torch.arange(16, dtype=torch.long).reshape(2, 8) + 1)
cached, cache_hit, inflight_wait = asyncio.run(
serving._resolve_higgs_audio_v3_ref_codes(
adapter._resolve_higgs_audio_v3_ref_codes(
"ref-a",
object(),
24000,
@@ -1,12 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import (
MingTTSAdapter,
parse_ming_instruction_fields,
)
from vllm_omni.model_executor.models.common.ming.aggregator import Aggregator
from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig
from vllm_omni.model_executor.models.common.ming.cfm_cudagraph import CFMGraphExecutor, CFMSampler
@@ -174,19 +178,19 @@ def test_ming_dense_validation_rejects_semantic_audio_vae_config():
def test_ming_instruction_parser_preserves_dense_and_flash_defaults():
"""Ming dense and Ming flash keep distinct instruction defaults."""
serving = object.__new__(OmniOpenAIServingSpeech)
serving.uploaded_speakers = {"uploaded": {}}
serving = SimpleNamespace(uploaded_speakers={"uploaded": {}})
adapter = MingTTSAdapter(SpeechServingContext(server=serving))
dense_plain = serving._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None))
dense_plain = adapter._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None))
assert dense_plain == "calm"
dense_with_fields = serving._parse_ming_instruction(
dense_with_fields = adapter._parse_ming_instruction(
SimpleNamespace(instructions="calm", language="Auto", voice="灵小甄")
)
assert dense_with_fields == {"IP": "灵小甄", "风格": "calm"}
flash_fields = serving._parse_ming_instruction_fields(
SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄")
flash_fields = parse_ming_instruction_fields(
serving, SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄")
)
assert flash_fields == {"风格": "calm"}
+2 -1
View File
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Ratchet on the TTS adapter migration (RFC #4327, #4855).
Per-model behaviour is being moved out of ``serving_speech.py`` and into
@@ -28,7 +29,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
SERVING_SPEECH = Path("vllm_omni/entrypoints/openai/serving_speech.py")
# Budgets. These may only go DOWN. See the module docstring.
MAX_MODEL_TYPE_BRANCHES = 9
MAX_MODEL_TYPE_BRANCHES = 6
_GUIDANCE = """
Move the per-model behaviour into its adapter under
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Audex (Nemotron-Labs-Audex-2B) TTS serving adapter."""
import os
from typing import TYPE_CHECKING, Any
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
@@ -29,6 +31,30 @@ class AudexAdapter(ARTTSAdapter):
stage_keys = frozenset({"audex_thinker", "audex_omni"})
name = "audex"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._tokenizer = None
def _get_tokenizer(self):
"""Load and cache the tokenizer used only by the Audex TTS adapter."""
if self._tokenizer is None:
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
stage = getattr(getattr(self.ctx.server._tts_stage, "engine_args", None), "model_stage", None)
profile, folder = (
("full", "checkpoint_folder_full") if stage == "audex_omni" else ("tts", "checkpoint_folder_audiogen")
)
model_path = os.path.normpath(self.ctx.engine_client.model_config.model)
root = (
os.path.dirname(model_path)
if os.path.basename(model_path).startswith("checkpoint_folder")
else ensure_audex_snapshot(model_path, profile=profile)
)
self._tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, folder))
return self._tokenizer
@classmethod
def stage_serves_speech(cls, model_stage: str | None, all_stage_keys: frozenset[str]) -> bool:
"""``audex_omni`` covers the audex_s2s deployment, whose TTS pass uses
@@ -102,7 +128,6 @@ class AudexAdapter(ARTTSAdapter):
element of the engine's shared ``default_sampling_params_list`` —
see ``_inject_audex_tta_args``).
"""
server = self.ctx.server
import copy
stage0_params = copy.deepcopy(stage0_params)
@@ -121,7 +146,7 @@ class AudexAdapter(ARTTSAdapter):
cond_prompt = prompt.get("prompt") if isinstance(prompt, dict) else prompt
if not isinstance(cond_prompt, str) or not cond_prompt:
raise ValueError("Audex CFG requires the adapter-built text prompt")
null_prompt = build_null_prompt(cond_prompt, server._get_audex_tokenizer())
null_prompt = build_null_prompt(cond_prompt, self._get_tokenizer())
extra_args.update(
{
"cfg_scale": cfg_scale,
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Audex (Nemotron-Labs-Audex-2B) text-to-audio serving adapter.
Caption → general audio through the ``audex_tta`` pipeline.
@@ -8,6 +9,7 @@ completes the sampling parameters with the RVQ phase-mask contract
scale 3.0 — guidance is effectively mandatory for TTA quality).
"""
import os
from typing import TYPE_CHECKING, Any
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
@@ -32,6 +34,27 @@ class AudexTTAAdapter(ARTTSAdapter):
stage_keys = frozenset({"audex_tta_thinker"})
name = "audex_tta"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._tokenizer = None
self._tta_rvq = None
def _get_tokenizer(self):
"""Load and cache the tokenizer used only by the Audex TTA adapter."""
if self._tokenizer is None:
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
model_path = os.path.normpath(self.ctx.engine_client.model_config.model)
root = (
os.path.dirname(model_path)
if os.path.basename(model_path).startswith("checkpoint_folder")
else ensure_audex_snapshot(model_path, profile="tta")
)
self._tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen"))
return self._tokenizer
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
if not request.input or not request.input.strip():
return "Audex TTA requires a non-empty caption"
@@ -95,7 +118,6 @@ class AudexTTAAdapter(ARTTSAdapter):
state (``cfg_pair_id``/``cfg_null_prompt``) into it would race
concurrent requests and leak stale CFG metadata into later ones.
"""
server = self.ctx.server
import copy
from vllm_omni.model_executor.models.audex.prompt import build_tta_null_prompt
@@ -106,10 +128,10 @@ class AudexTTAAdapter(ARTTSAdapter):
raise ValueError("Audex TTA requires the adapter-built caption prompt")
stage0_params = copy.deepcopy(stage0_params)
tokenizer = server._get_audex_tokenizer()
if server._audex_tta_rvq is None:
tokenizer = self._get_tokenizer()
if self._tta_rvq is None:
phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer)
server._audex_tta_rvq = {
self._tta_rvq = {
"phase_token_ids": phase_token_ids,
"start_tid": start_tid,
"end_tid": end_tid,
@@ -123,7 +145,7 @@ class AudexTTAAdapter(ARTTSAdapter):
if extra_args is None:
extra_args = {}
stage0_params.extra_args = extra_args
extra_args["tta_rvq"] = server._audex_tta_rvq
extra_args["tta_rvq"] = self._tta_rvq
cfg_scale = extra_args.get("cfg_scale")
cfg_scale = 3.0 if cfg_scale is None else float(cfg_scale)
@@ -11,8 +11,9 @@ instead of editing the shared serving module in ~10 scattered places.
See the RFC for the full design (issue #4327).
"""
import hashlib
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar
@@ -37,22 +38,52 @@ DEFAULT_TTS_LANGUAGES = frozenset(
}
)
_conditioning_cache_salt_fn: "Callable[..., str] | None" = None
def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict | None = None) -> str:
"""Stable hash of the real Stage 0 conditioning for the prefix cache.
def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict) -> str:
"""Return the conditioning cache salt for ``request`` + ``tts_params``.
The talker's vLLM prompt is placeholder token ids; the real inputs are
rebuilt from text / ref_audio / ref_text into inputs_embeds. vLLM hashes
token ids (folded with cache_salt) for prefix caching, so without a salt
every request collides and a hit could reuse KV from a semantically
different input.
Lazily imports and caches ``serving_speech._conditioning_cache_salt`` on first
use: the import is deferred to break the adapters<->serving_speech import
cycle, and cached so it resolves once instead of on every ``build()`` call.
Raw request fields alone are not enough for uploaded voices: resolved
conditioning such as ``voice_created_at`` and content-aware ref-audio
cache keys must also be folded in. This distinguishes delete/re-upload of
the same voice and same-path local audio rewrites without hashing decoded
waveform arrays.
"""
global _conditioning_cache_salt_fn
if _conditioning_cache_salt_fn is None:
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
_conditioning_cache_salt_fn = _conditioning_cache_salt
return _conditioning_cache_salt_fn(request, tts_params)
h = hashlib.sha256()
for part in (
request.input,
request.task_type,
request.language,
request.voice,
request.ref_text,
request.ref_audio,
request.instructions,
request.x_vector_only_mode,
request.speaker_embedding,
):
h.update(b"\x00")
if part is not None:
h.update(repr(part).encode("utf-8"))
# Fold conditioning derived by adapters and absent from the raw request.
for key in (
"voice_created_at",
"task_type",
"speaker",
"ref_text",
"x_vector_only_mode",
"ref_audio_cache_key",
"ref_audio_2_cache_key",
):
h.update(b"\x00")
value = tts_params.get(key) if tts_params is not None else None
if value is not None:
h.update(repr(value).encode("utf-8"))
return h.hexdigest()[:32]
def apply_max_new_tokens(
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CosyVoice3 TTS serving adapter."""
import os
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
@@ -14,12 +16,46 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
# Without this delimiter the talker re-speaks the reference transcript instead
# of emitting target-only speech (issue #4644). Matches the offline example and
# upstream demo.
_PROMPT_DELIMITER = "<|endofprompt|>"
_PROMPT_PREFIX = f"You are a helpful assistant.{_PROMPT_DELIMITER}"
@register_tts_adapter
class CosyVoice3Adapter(ARTTSAdapter):
stage_keys = frozenset({"cosyvoice3_talker"})
name = "cosyvoice3"
def __init__(self, ctx) -> None:
super().__init__(ctx)
# Cache the resolved Qwen tokenizer used for dynamic-token sizing.
# Rebuilding it would repeat snapshot resolution and add ~100 ms to the
# TTFP-critical sampling override on every request.
self._tokenizer = None
async def _build_prompt(
self, request: "OpenAICreateSpeechRequest", *, has_inline_ref_audio: bool = False
) -> dict[str, Any]:
"""Build the multimodal CosyVoice3 voice-cloning prompt."""
server = self.ctx.server
wav_samples, sr, _ = await server._resolve_ref_audio(request.ref_audio)
ref_text = request.ref_text or ""
if _PROMPT_DELIMITER not in ref_text:
ref_text = f"{_PROMPT_PREFIX}{ref_text}"
mm_kwargs: dict[str, Any] = {"prompt_text": ref_text, "sample_rate": sr}
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
mm_kwargs["voice_name"] = voice_lower
mm_kwargs["voice_created_at"] = server._voice_created_at(voice_lower)
return {
"prompt": request.input,
"multi_modal_data": {"audio": (np.asarray(wav_samples, dtype=np.float32), sr)},
"mm_processor_kwargs": mm_kwargs,
}
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate CosyVoice3 request parameters. Returns error message or None."""
server = self.ctx.server
@@ -51,7 +87,7 @@ class CosyVoice3Adapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
prompt = await self._build_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="cosyvoice3")
def apply_sampling_overrides(
@@ -76,7 +112,7 @@ class CosyVoice3Adapter(ARTTSAdapter):
hf_cfg = server.model_config.hf_config
# Build the Qwen tokenizer once per process (resolving the model dir via
# snapshot_download at most once) and reuse it across requests.
tokenizer = server._cosyvoice3_tokenizer
tokenizer = self._tokenizer
if tokenizer is None:
model_path = server.engine_client.model_config.model
if not os.path.isdir(model_path):
@@ -88,7 +124,7 @@ class CosyVoice3Adapter(ARTTSAdapter):
skip_special_tokens=hf_cfg.skip_special_tokens,
version=hf_cfg.version,
)
server._cosyvoice3_tokenizer = tokenizer
self._tokenizer = tokenizer
_, text_token_len = extract_text_token(
request.input,
tokenizer,
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CoVo-Audio serving adapter."""
from typing import TYPE_CHECKING
@@ -15,6 +16,10 @@ class CovoAudioAdapter(ARTTSAdapter):
stage_keys = frozenset({"fused_thinker_talker"})
name = "covo_audio"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._tokenizer = None
@classmethod
def matches(cls, model_stage: str | None, model_arch: str | None) -> bool:
"""``fused_thinker_talker`` is a generic stage key that non-CoVo fused
@@ -30,5 +35,21 @@ class CovoAudioAdapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = self.ctx.server._build_covo_audio_prompt(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="covo_audio")
"""Build the tokenized chat prompt expected by Covo-Audio-Chat.
The model requires a specific system prompt instructing it to
interleave text and audio tokens. Passing token IDs avoids a second
engine-side tokenization step.
"""
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.covo_audio.prompt_utils import build_covo_audio_prompt_token_ids
if self._tokenizer is None:
model_name = self.ctx.engine_client.model_config.model
try:
self._tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
except Exception as exc:
raise RuntimeError(f"Failed to load Covo-Audio tokenizer from '{model_name}': {exc}") from exc
prompt_ids = build_covo_audio_prompt_token_ids(self._tokenizer, request.input)
return PreparedRequest(prompt={"prompt_token_ids": prompt_ids}, tts_params={}, model_type="covo_audio")
@@ -1,8 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Fish Speech serving adapter (retires the legacy ``_is_fish_speech`` flag)."""
import math
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
from vllm.inputs import tokens_input
from vllm.utils.async_utils import make_async
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import (
ARTTSAdapter,
@@ -20,6 +27,101 @@ class FishSpeechAdapter(ARTTSAdapter):
stage_keys = frozenset({"fish_speech_slow_ar"})
name = "fish_tts"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._tokenizer = None
self._build_prompt_async = make_async(self._build_prompt, executor=getattr(ctx.server, "_tts_executor", None))
@staticmethod
def _estimate_ref_code_len(ref_audio: object) -> int | None:
from vllm_omni.model_executor.models.fish_speech.dac_utils import DAC_HOP_LENGTH, DAC_SAMPLE_RATE
if not isinstance(ref_audio, (list, tuple)) or len(ref_audio) != 2:
return None
wav, sr = ref_audio
sr = int(sr)
if sr <= 0 or len(wav) <= 0:
return None
return max(1, math.ceil(max(1, math.ceil(len(wav) * DAC_SAMPLE_RATE / sr)) / DAC_HOP_LENGTH))
def _get_tokenizer(self):
if self._tokenizer is None:
from transformers import AutoTokenizer
self._tokenizer = AutoTokenizer.from_pretrained(
self.ctx.engine_client.model_config.model, trust_remote_code=True
)
return self._tokenizer
def _estimate_prompt_len(self, text: str, ref_text: str, ref_audio: object) -> int:
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
estimate_fish_voice_clone_prompt_len_from_normalized,
)
try:
semantic_len = self._estimate_ref_code_len(ref_audio)
if semantic_len is None:
raise ValueError("Failed to estimate Fish Speech semantic token length")
return estimate_fish_voice_clone_prompt_len_from_normalized(
self._get_tokenizer(), text, ref_text, semantic_len
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Failed to estimate Fish Speech prompt length, using fallback 2048: %s", exc
)
return 2048
def _build_prompt(
self,
request: "OpenAICreateSpeechRequest",
ref_audio_data: tuple[list[float], int] | None,
has_inline_ref_audio: bool,
) -> dict[str, Any]:
"""Build either the text-only or structured Fish Speech clone prompt.
Structured clone metadata uses concrete scalar fields because model-side
preprocess consumes one request directly; text-only metadata retains the
legacy single-item list representation used across EngineCore IPC.
"""
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
build_fish_text_only_prompt_ids,
normalize_fish_voice_clone_texts,
)
tokenizer = self._get_tokenizer()
if ref_audio_data is None or not request.ref_text:
prompt_ids, normalized_text = build_fish_text_only_prompt_ids(tokenizer, request.input)
info: dict[str, Any] = {"text": [normalized_text]}
if request.max_new_tokens is not None:
info["max_new_tokens"] = [request.max_new_tokens]
prompt = tokens_input(prompt_token_ids=prompt_ids)
prompt["additional_information"] = info
return prompt
wav_samples, sr = ref_audio_data
normalized_text, normalized_ref_text = normalize_fish_voice_clone_texts(request.input, request.ref_text)
ph_len = self._estimate_prompt_len(normalized_text, normalized_ref_text, ref_audio_data)
info = {
"text": normalized_text,
"ref_text": normalized_ref_text,
"ref_audio_wav": torch.from_numpy(np.asarray(wav_samples, dtype=np.float32)),
"ref_audio_sr": int(sr),
"fish_structured_voice_clone": True,
}
server = self.ctx.server
if request.voice is not None:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
info["voice_name"] = voice_lower
info["voice_created_at"] = server._voice_created_at(voice_lower)
if request.max_new_tokens is not None:
info["max_new_tokens"] = request.max_new_tokens
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
prompt["additional_information"] = info
return prompt
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate Fish Speech request parameters. Returns error message or None."""
server = self.ctx.server
@@ -54,9 +156,7 @@ class FishSpeechAdapter(ARTTSAdapter):
wav_list, sr, cache_key = await server._resolve_ref_audio(request.ref_audio)
ref_audio_data = (wav_list, sr)
tts_params["ref_audio_cache_key"] = cache_key
prompt = await server._build_fish_speech_prompt_async(
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
)
prompt = await self._build_prompt_async(request, ref_audio_data, has_inline_ref_audio)
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="fish_speech")
@@ -1,8 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""GLM-TTS serving adapter."""
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
@@ -19,6 +21,72 @@ class GlmTTSAdapter(ARTTSAdapter):
stage_keys = frozenset({"glm_tts"})
name = "glm_tts"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._text_tokenizer = None
self._text_frontend = None
def _text_tokenizer_and_frontend(self):
from vllm_omni.model_executor.models.glm_tts.glm_tts import (
load_glm_tts_tokenizer,
resolve_glm_tts_tokenizer_path,
)
from vllm_omni.model_executor.models.glm_tts.text_frontend import GLMTTSTextFrontend
if self._text_tokenizer is None:
config = self.ctx.engine_client.model_config
tokenizer_path = getattr(config, "tokenizer", None) or resolve_glm_tts_tokenizer_path(config.model)
self._text_tokenizer = load_glm_tts_tokenizer(
tokenizer_path,
model_name_or_path=config.model,
trust_remote_code=bool(getattr(config, "trust_remote_code", False)),
)
if self._text_frontend is None:
self._text_frontend = GLMTTSTextFrontend()
return self._text_tokenizer, self._text_frontend
def _estimate_text_token_len(self, text: str | None, *, add_trailing_space: bool = False) -> int:
tokenizer, frontend = self._text_tokenizer_and_frontend()
normalized = (frontend.text_normalize(text or "") or text or "").strip()
if add_trailing_space and normalized:
normalized = f"{normalized} "
return max(1, len(tokenizer.encode(normalized)))
def _build_prefill_metadata(self, text: str, prompt_text: str | None) -> dict[str, Any]:
text_len = self._estimate_text_token_len(text)
prompt_len = self._estimate_text_token_len(prompt_text, add_trailing_space=True) if prompt_text else 0
return {
"glm_tts_text_token_len": [text_len],
"glm_tts_prompt_text_token_len": [prompt_len],
"input_len": [prompt_len + text_len + 1],
}
async def _build_prompt(
self, request: "OpenAICreateSpeechRequest", *, has_inline_ref_audio: bool = False
) -> dict[str, Any]:
"""Build GLM-TTS multimodal voice-cloning input and prefill metadata.
AR preprocess constructs PromptText, Text, BOA and prompt speech tokens;
the DiT consumes the resulting prompt tokens, features and embedding as
conditioning.
"""
server = self.ctx.server
if request.ref_audio is None or not request.ref_text:
raise ValueError("GLM-TTS requires ref_audio and ref_text for voice cloning.")
wav_samples, sr, _ = await server._resolve_ref_audio(request.ref_audio)
mm_kwargs: dict[str, Any] = {"prompt_text": request.ref_text}
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
mm_kwargs["voice_name"] = voice_lower
mm_kwargs["voice_created_at"] = server._voice_created_at(voice_lower)
return {
"prompt": request.input,
"multi_modal_data": {"audio": (np.asarray(wav_samples, dtype=np.float32), int(sr))},
"mm_processor_kwargs": mm_kwargs,
"additional_information": self._build_prefill_metadata(request.input, request.ref_text),
}
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate GLM-TTS request — requires ref_audio for voice cloning."""
server = self.ctx.server
@@ -46,7 +114,7 @@ class GlmTTSAdapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
prompt = await self._build_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="glm_tts")
def _load_supported_speakers(self) -> set[str]:
@@ -71,9 +139,7 @@ class GlmTTSAdapter(ARTTSAdapter):
if isinstance(text_len_value, list) and text_len_value:
text_len_value = text_len_value[0]
text_token_len = (
int(text_len_value)
if text_len_value is not None
else server._estimate_glm_tts_text_token_len(request.input)
int(text_len_value) if text_len_value is not None else self._estimate_text_token_len(request.input)
)
hf_cfg = server.model_config.hf_config
min_ratio = getattr(hf_cfg, "min_token_text_ratio", 2)
@@ -1,8 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Higgs-Audio v2 serving adapter."""
import asyncio
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm.inputs import tokens_input
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens
@@ -12,6 +17,89 @@ if TYPE_CHECKING:
@register_tts_adapter
class HiggsAudioV2Adapter(ARTTSAdapter):
@property
def engine_client(self):
return self.ctx.engine_client
async def _resolve_ref_audio(self, ref_audio: str):
return await self.ctx.server._resolve_ref_audio(ref_audio)
async def _build_higgs_audio_v2_params(self, request: "OpenAICreateSpeechRequest"):
"""Build prompt_token_ids for higgs_audio_v2 via the upstream processor.
Plain-text path: runs ``build_plain_text_prompt`` and returns the
token-only prompt. Voice-clone path (``ref_audio`` + ``ref_text``):
resolves the reference clip via ``_resolve_ref_audio``, runs
``build_voice_clone_prompt`` (which encodes the clip through HF's
``HiggsAudioV2TokenizerModel`` loaded from the k2-fsa/OmniVoice
``audio_tokenizer/`` subdirectory), and attaches the encoded
``audio_input_ids`` + ``audio_input_ids_mask`` tensors via
``additional_information`` so the talker substitutes them at the
prompt-side audio placeholders.
"""
from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_v2_tokenizer import (
build_plain_text_prompt,
build_voice_clone_prompt,
input_ids_to_python_list,
)
processor = await self._resolve_higgs_audio_v2_processor()
if request.ref_audio is None:
inputs = await asyncio.to_thread(build_plain_text_prompt, processor, request.input)
prompt_token_ids = input_ids_to_python_list(inputs)
return tokens_input(prompt_token_ids=prompt_token_ids)
wav_list, sr, _ = await self._resolve_ref_audio(request.ref_audio)
wav = np.asarray(wav_list, dtype=np.float32)
out = await asyncio.to_thread(
build_voice_clone_prompt,
processor,
request.input,
wav,
int(sr),
request.ref_text or "",
)
prompt = tokens_input(prompt_token_ids=out["prompt_token_ids"])
# Pass tensors at the top level of additional_information (NOT list-
# wrapped). ``vllm_omni.data_entry_keys.serialize_payload`` routes
# bare ``torch.Tensor`` values through ``_serialize_tensor``; a list
# containing tensors would fall into the ``list_data`` field which
# msgspec cannot serialize and the tensors would be dropped over the
# process boundary (silent voice-clone failure).
prompt["additional_information"] = {
"audio_input_ids": out["audio_input_ids"],
"audio_input_ids_mask": out["audio_input_ids_mask"],
}
return prompt
async def _resolve_higgs_audio_v2_processor(self):
"""Lazy-load the AutoProcessor for higgs_audio_v2 (once per serving instance)."""
cached = getattr(self, "_higgs_audio_v2_processor", None)
if cached is not None:
return cached
from transformers import AutoProcessor
model_path = None
for stage in self.engine_client.stage_configs:
model_path = getattr(getattr(stage, "engine_args", None), "model", None)
if model_path:
break
if model_path is None:
# Fallback: the orchestrator stores the served model id on the engine
# itself (set by AsyncOmniEngine.__init__). Stage-level engine_args
# may not surface ``model`` when the deploy yaml doesn't set it per
# stage (the CLI-passed model id is the single source of truth).
model_path = getattr(self.engine_client, "model", None)
if model_path is None:
raise RuntimeError("higgs_audio_v2 serving could not resolve the model path from the engine stage configs")
processor = AutoProcessor.from_pretrained(model_path)
self._higgs_audio_v2_processor = processor
return processor
# ---- higgs-audio v3 ----
stage_keys = frozenset({"higgs_audio_v2"})
name = "higgs_audio_v2"
@@ -91,7 +179,7 @@ class HiggsAudioV2Adapter(ARTTSAdapter):
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
prompt = await server._build_higgs_audio_v2_params(request)
prompt = await self._build_higgs_audio_v2_params(request)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
@@ -1,10 +1,25 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Higgs-Audio v3 serving adapter."""
import asyncio
from collections import OrderedDict
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
from vllm.inputs import tokens_input
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens
from vllm_omni.entrypoints.openai.tts_adapters.base import (
ARTTSAdapter,
PreparedRequest,
apply_max_new_tokens,
conditioning_cache_salt,
)
_REF_CODE_CACHE_MAX_ENTRIES = 256
_REF_CODE_CACHE_MAX_BYTES = 64 * 1024 * 1024
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@@ -12,6 +27,159 @@ if TYPE_CHECKING:
@register_tts_adapter
class HiggsAudioV3Adapter(ARTTSAdapter):
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._higgs_audio_v3_ref_code_cache: OrderedDict[str, tuple[torch.Tensor, int]] = OrderedDict()
self._higgs_audio_v3_ref_code_cache_bytes = 0
self._higgs_audio_v3_ref_code_inflight: dict[str, asyncio.Task[torch.Tensor]] = {}
@property
def engine_client(self):
return self.ctx.engine_client
async def _resolve_ref_audio(self, ref_audio: str):
return await self.ctx.server._resolve_ref_audio(ref_audio)
def _get_resolved_ref_audio_artifact_key(self, cache_key: str):
return self.ctx.server._get_resolved_ref_audio_artifact_key(cache_key)
async def _build_higgs_audio_v3_params(self, request: "OpenAICreateSpeechRequest"):
"""Build prompt_token_ids for higgs_audio_v3.
Plain-text path: builds ``[tts, text, tokens, audio]``.
Voice-clone path: encodes reference audio, applies delay pattern, and
records reference-audio prompt positions while submitting valid token IDs.
"""
adapter = await self._resolve_higgs_audio_v3_adapter()
if request.ref_audio is None:
prompt_ids = adapter.build_prompt(request.input)
return tokens_input(prompt_token_ids=prompt_ids)
# Voice clone
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import (
apply_delay_pattern,
encode_reference_audio,
)
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key)
wav = np.asarray(wav_list, dtype=np.float32)
ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes(
artifact_key,
wav,
int(sr),
encode_reference_audio,
apply_delay_pattern,
)
del cache_hit, inflight_wait
prompt_ids = adapter.build_prompt(
request.input,
num_ref_tokens=int(ref_codes_delayed.shape[0]),
reference_text=request.ref_text or None,
)
prompt_ids, audio_placeholder_positions = adapter.prepare_prompt_for_engine(prompt_ids)
prompt = tokens_input(prompt_token_ids=prompt_ids)
import torch
prompt["additional_information"] = {
"audio_input_ids": ref_codes_delayed.to(torch.long),
"audio_input_ids_mask": torch.ones(ref_codes_delayed.shape[0], dtype=torch.bool),
"audio_placeholder_positions": audio_placeholder_positions,
"ref_audio_cache_key": cache_key,
}
prompt["cache_salt"] = conditioning_cache_salt(request, prompt["additional_information"])
return prompt
async def _resolve_higgs_audio_v3_ref_codes(
self,
artifact_key: str | None,
wav: np.ndarray,
sr: int,
encode_reference_audio,
apply_delay_pattern,
) -> tuple[torch.Tensor, bool, bool]:
ref_codes_delayed = self._get_higgs_audio_v3_ref_codes(artifact_key)
if ref_codes_delayed is not None:
return ref_codes_delayed, True, False
if not artifact_key:
ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr)
return apply_delay_pattern(ref_codes_raw), False, False
task = self._higgs_audio_v3_ref_code_inflight.get(artifact_key)
if task is not None:
return (await task).clone(), False, True
async def _encode_and_cache() -> torch.Tensor:
ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr)
delayed = apply_delay_pattern(ref_codes_raw)
self._put_higgs_audio_v3_ref_codes(artifact_key, delayed)
cached = self._get_higgs_audio_v3_ref_codes(artifact_key)
return cached if cached is not None else delayed.detach().to("cpu", dtype=torch.long).contiguous()
task = asyncio.create_task(_encode_and_cache())
self._higgs_audio_v3_ref_code_inflight[artifact_key] = task
try:
return (await task).clone(), False, False
finally:
if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is task:
self._higgs_audio_v3_ref_code_inflight.pop(artifact_key, None)
def _get_higgs_audio_v3_ref_codes(self, artifact_key: str | None) -> torch.Tensor | None:
if not artifact_key:
return None
cached = self._higgs_audio_v3_ref_code_cache.get(artifact_key)
if cached is None:
return None
self._higgs_audio_v3_ref_code_cache.move_to_end(artifact_key)
return cached[0].clone()
def _put_higgs_audio_v3_ref_codes(self, artifact_key: str, codes: torch.Tensor) -> None:
if _REF_CODE_CACHE_MAX_ENTRIES <= 0 or _REF_CODE_CACHE_MAX_BYTES <= 0 or not artifact_key:
return
cached_codes = codes.detach().to("cpu", dtype=torch.long).contiguous()
size = int(cached_codes.numel() * cached_codes.element_size())
if size > _REF_CODE_CACHE_MAX_BYTES:
return
previous = self._higgs_audio_v3_ref_code_cache.pop(artifact_key, None)
if previous is not None:
self._higgs_audio_v3_ref_code_cache_bytes -= previous[1]
self._higgs_audio_v3_ref_code_cache[artifact_key] = (cached_codes, size)
self._higgs_audio_v3_ref_code_cache_bytes += size
while len(self._higgs_audio_v3_ref_code_cache) > _REF_CODE_CACHE_MAX_ENTRIES:
_, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False)
self._higgs_audio_v3_ref_code_cache_bytes -= old_size
while self._higgs_audio_v3_ref_code_cache_bytes > _REF_CODE_CACHE_MAX_BYTES:
_, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False)
self._higgs_audio_v3_ref_code_cache_bytes -= old_size
async def _resolve_higgs_audio_v3_adapter(self):
"""Lazy-load the tokenizer adapter for higgs_audio_v3."""
cached = getattr(self, "_higgs_audio_v3_adapter", None)
if cached is not None:
return cached
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import (
HiggsAudioV3TokenizerAdapter,
)
model_path = None
for stage in self.engine_client.stage_configs:
model_path = getattr(getattr(stage, "engine_args", None), "model", None)
if model_path:
break
if model_path is None:
model_path = getattr(self.engine_client, "model", None)
if model_path is None:
raise RuntimeError("higgs_audio_v3 serving could not resolve model path")
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
adapter = HiggsAudioV3TokenizerAdapter(tokenizer)
self._higgs_audio_v3_adapter = adapter
return adapter
stage_keys = frozenset({"higgs_audio_v3"})
name = "higgs_audio_v3"
@@ -34,7 +202,7 @@ class HiggsAudioV3Adapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_higgs_audio_v3_params(request)
prompt = await self._build_higgs_audio_v3_params(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v3")
def apply_sampling_overrides(
@@ -1,12 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MingFlashOmniTTS serving adapter."""
from typing import TYPE_CHECKING
from vllm.inputs import tokens_input
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import parse_ming_instruction_fields
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@@ -61,8 +64,36 @@ class MingFlashOmniTTSAdapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
"""Build the standalone Ming Flash TTS prompt."""
from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import (
DEFAULT_PROMPT,
create_instruction,
)
server = self.ctx.server
prompt = server._build_ming_flash_omni_prompt(request)
# ``instructions`` accepts either plain style text or a JSON object.
# Unknown caption keys are filtered by ``create_instruction``.
caption_fields = parse_ming_instruction_fields(server, request) or {}
has_spk_emb = request.speaker_embedding is not None
# Standalone TTS uses the Ming ``instruct`` task. ``voice_name`` lets
# the talker resolve registered presets such as DB30.
info = {
"ming_task": "instruct",
"prompt": DEFAULT_PROMPT,
"text": request.input,
"instruction": create_instruction(caption_fields),
"voice_name": request.voice or None,
"use_zero_spk_emb": not has_spk_emb,
"max_decode_steps": request.max_new_tokens or self.max_new_tokens_max,
"cfg": 2.0,
"sigma": 0.25,
"temperature": 0.0,
}
if has_spk_emb:
# Keep embeddings as plain floats for EngineCore serialization.
info["spk_emb"] = list(request.speaker_embedding)
prompt = tokens_input(prompt_token_ids=[0])
prompt["additional_information"] = info
return PreparedRequest(prompt=prompt, tts_params={}, model_type="ming_flash_omni_tts")
def _load_supported_speakers(self) -> set[str]:
@@ -1,12 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Ming-TTS (dense) serving adapter."""
import json
from typing import TYPE_CHECKING, Any
import torch
from vllm.inputs import tokens_input
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import DEFAULT_PROMPT as MING_DEFAULT_PROMPT
from vllm_omni.model_executor.models.ming_tts.constants import SPEAKER_EMBEDDING_DIM
if TYPE_CHECKING:
@@ -15,8 +20,140 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
def parse_ming_instruction_fields(server, request, *, include_voice=False, plain_text_passthrough=False):
instruction_text = request.instructions.strip() if isinstance(request.instructions, str) else None
instruction_dict: dict[str, Any] = {}
voice_lower = request.voice.lower() if isinstance(request.voice, str) else None
if include_voice and request.voice and not (voice_lower and voice_lower in server.uploaded_speakers):
instruction_dict["IP"] = request.voice
if instruction_text:
try:
parsed = json.loads(instruction_text)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
instruction_dict.update(parsed)
elif instruction_dict or not plain_text_passthrough:
instruction_dict["风格"] = instruction_text
else:
return instruction_text
return instruction_dict or None
@register_tts_adapter
class MingTTSAdapter(ARTTSAdapter):
def _coerce_ming_prompt_waveform(self, wav_samples, sample_rate):
from torchaudio.functional import resample as resample_audio
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import SAMPLE_RATE
waveform = torch.as_tensor(wav_samples, dtype=torch.float32).reshape(1, -1)
if int(sample_rate) != SAMPLE_RATE:
waveform = resample_audio(waveform, int(sample_rate), SAMPLE_RATE)
return waveform
def _build_ming_reference_waveforms(
self,
ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None,
) -> tuple[list[torch.Tensor], list[int]]:
if ref_audio_data is None:
return [], []
items = ref_audio_data if isinstance(ref_audio_data, list) else [ref_audio_data]
waveforms = [torch.as_tensor(samples, dtype=torch.float32).reshape(1, -1) for samples, _ in items]
sample_rates = [int(sample_rate) for _, sample_rate in items]
if any(sample_rate <= 0 for sample_rate in sample_rates):
raise ValueError(f"Ming reference audio sample rates must be positive, got {sample_rates}")
return waveforms, sample_rates
def _parse_ming_instruction(self, request: "OpenAICreateSpeechRequest") -> Any:
"""Build a Ming instruction payload from OpenAI speech fields."""
return parse_ming_instruction_fields(
self.ctx.server,
request,
include_voice=True,
plain_text_passthrough=True,
)
def _build_ming_dense_prompt(
self,
request: "OpenAICreateSpeechRequest",
*,
ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None = None,
voice_name: str | None = None,
voice_created_at: int = 0,
) -> dict[str, Any]:
"""Build a Ming dense prompt directly from the OpenAI speech request."""
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import (
KEY_MAX_DECODE_STEPS,
KEY_SPEAKER_SAMPLE_RATES,
KEY_SPEAKER_WAVEFORM,
KEY_SPEAKER_WAVEFORM_LENGTHS,
)
from vllm_omni.model_executor.models.ming_tts.prompt_assembly import build_ming_dense_prompt
if self.ctx.server._tts_tokenizer is None:
model_name = self.ctx.engine_client.model_config.model
trust_remote_code = bool(getattr(self.ctx.engine_client.model_config, "trust_remote_code", False))
self.ctx.server._tts_tokenizer = AutoTokenizer.from_pretrained(
model_name, trust_remote_code=trust_remote_code
)
ref_text = request.ref_text
reference_waveforms, reference_sample_rates = self._build_ming_reference_waveforms(ref_audio_data)
reference_waveform = torch.cat(reference_waveforms, dim=-1) if reference_waveforms else None
prompt_waveforms = (
[
self._coerce_ming_prompt_waveform(waveform, sample_rate)
for waveform, sample_rate in zip(reference_waveforms, reference_sample_rates, strict=True)
]
if ref_text is not None
else []
)
prompt_waveform = torch.cat(prompt_waveforms, dim=-1) if prompt_waveforms else None
speaker_embedding = request.speaker_embedding
pending_speaker_extraction = speaker_embedding is None and reference_waveform is not None
use_zero_spk_emb = not pending_speaker_extraction and prompt_waveform is None and speaker_embedding is None
runtime_controls = {}
if request.max_new_tokens is not None:
runtime_controls[KEY_MAX_DECODE_STEPS] = request.max_new_tokens
prompt_dict = build_ming_dense_prompt(
self.ctx.server._tts_tokenizer,
# bgm / music-prompt mode not supported online;
# requires prompt_mode API extension (deferred).
prompt=MING_DEFAULT_PROMPT,
text=request.input,
runtime_controls=runtime_controls or None,
instruction=self._parse_ming_instruction(request),
prompt_text=ref_text,
prompt_waveform=prompt_waveform,
speaker_embedding=speaker_embedding,
speaker_count=len(reference_waveforms) if pending_speaker_extraction else None,
use_zero_spk_emb=use_zero_spk_emb,
)
additional_information = prompt_dict["additional_information"]
if pending_speaker_extraction:
additional_information[KEY_SPEAKER_WAVEFORM] = reference_waveform
additional_information[KEY_SPEAKER_WAVEFORM_LENGTHS] = torch.tensor(
[int(waveform.shape[-1]) for waveform in reference_waveforms],
dtype=torch.int32,
)
additional_information[KEY_SPEAKER_SAMPLE_RATES] = torch.tensor(
reference_sample_rates,
dtype=torch.int32,
)
if voice_name:
additional_information["voice_name"] = voice_name
additional_information["voice_created_at"] = int(voice_created_at)
prompt = tokens_input(prompt_token_ids=prompt_dict["prompt_token_ids"])
prompt["prompt"] = prompt_dict["prompt"]
prompt["text"] = prompt_dict["text"]
prompt["additional_information"] = prompt_dict["additional_information"]
return prompt
# Ming dense has no dedicated model_stage value, so both stage discovery and
# model-type detection go through the architecture. ``ming_flash_omni_tts``
# is the model that owns the ``ming_tts`` *stage key*, and is matched first.
@@ -66,7 +203,7 @@ class MingTTSAdapter(ARTTSAdapter):
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr, _ = await server._resolve_ref_audio(ref_audio_source)
ref_audio_data = (wav_list, sr)
prompt = server._build_ming_dense_prompt(
prompt = self._build_ming_dense_prompt(
request,
ref_audio_data=ref_audio_data,
voice_name=uploaded_audio_voice,
@@ -6,7 +6,7 @@ Both variants share the same build/validate flow (``_build_moss_tts_params``
handles each); they are registered under distinct model-type names.
"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from vllm.inputs import tokens_input
@@ -23,6 +23,255 @@ if TYPE_CHECKING:
class _MossTTSAdapterBase(ARTTSAdapter):
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._moss_variant = None if self.name == "moss_tts_nano" else self._detect_moss_variant()
self._moss_processor_cache = None
@property
def engine_client(self):
return self.ctx.engine_client
@property
def uploaded_speakers(self):
return self.ctx.server.uploaded_speakers
@property
def _speaker_cache(self):
return self.ctx.server._speaker_cache
async def _resolve_ref_audio(self, ref_audio: str):
return await self.ctx.server._resolve_ref_audio(ref_audio)
def _voice_created_at(self, voice: str) -> int:
return self.ctx.server._voice_created_at(voice)
def _get_resolved_ref_audio_artifact_key(self, cache_key: str):
return self.ctx.server._get_resolved_ref_audio_artifact_key(cache_key)
def _get_moss_ref_encoder(self):
"""Lazily build the per-server MOSS reference-audio encoder once."""
cached = getattr(self, "_moss_ref_encoder", None)
if cached is not None:
return cached
from vllm_omni.model_executor.models.moss_tts.reference_encoder import build_reference_encoder
# The variant's encode geometry (n_vq, working sample rate) is derived
# inside the model package; this layer only supplies the processor and
# the process-wide speaker cache.
encoder = build_reference_encoder(
self._get_moss_processor(),
variant=self._moss_variant,
speaker_cache=self._speaker_cache,
)
self._moss_ref_encoder = encoder
return encoder
async def _encode_moss_references(
self,
request: "OpenAICreateSpeechRequest",
*,
has_inline_ref_audio: bool,
two_speaker: bool,
) -> tuple[list, dict[int, str]]:
"""Encode the request's reference clip(s) into MOSS RVQ code tensors.
Reference encoding + speaker caching lives in the model package
(moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 /
Qwen3-TTS which keep reference handling with the model rather than in
this shared serving file. This method only resolves the serving-side
inputs the model helper needs: the generic audio resolver/artifact-key
lookups and whether the request may use the named-voice cache.
Returns ``(codes_per_speaker, resolve_keys)`` where ``resolve_keys``
maps the reference slot (0 = ref_audio, 1 = ref_audio_2) to its
content-aware resolve key, for salting the KV prefix cache.
"""
from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_request_references
# Named-voice caching is only valid for uploaded speakers without an
# inline ref_audio: ``request.voice`` plus a file/URL would otherwise
# key on (name, created_at=0) and skip the content-aware resolve.
raw_voice = getattr(request, "voice", None)
raw_voice = raw_voice.strip() if isinstance(raw_voice, str) else ""
voice_lower = raw_voice.lower()
use_named_voice = bool(voice_lower) and voice_lower in self.uploaded_speakers and not has_inline_ref_audio
voice = voice_lower if use_named_voice else ""
voice_created = self._voice_created_at(voice) if voice else 0
return await encode_request_references(
self._get_moss_ref_encoder(),
request.ref_audio,
request.ref_audio_2 if two_speaker else None,
resolve_ref_audio=self._resolve_ref_audio,
get_artifact_key=self._get_resolved_ref_audio_artifact_key,
voice_name=voice or None,
voice_created_at=voice_created,
)
def _detect_moss_variant(self) -> str:
"""Sub-classify a ``moss_tts``-stage server into the actual MOSS-TTS
variant family (tts, ttsd, sound_effect, voice_generator, realtime).
Detection key is the HF repo path / model_name; matches
``_try_resolve_omni_model_type`` in entrypoints/utils.py so users get
consistent behaviour no matter how they launched the server (--model
OpenMOSS-Team/MOSS-TTSD-v1.0 vs --deploy-config moss_ttsd.yaml).
"""
try:
name = (self.engine_client.model_config.model or "").lower().replace("-", "").replace("_", "")
except Exception:
name = ""
if "realtime" in name:
return "realtime"
if "local" in name:
return "local"
if "ttsd" in name:
return "ttsd"
if "soundeffect" in name:
return "sound_effect"
if "voicegenerator" in name:
return "voice_generator"
return "tts"
def _get_moss_processor(self):
"""Lazily load the upstream MOSS-TTS processor once per server.
Cached on ``self._moss_processor_cache``. The processor owns its own
audio_tokenizer (~1.6 B params); we keep it on CPU so it doesn't
compete with the talker (~8 GiB) and codec (~7 GiB) for our 96 GiB
GPU — per-request ref-audio encoding is fast enough on CPU.
"""
cached = getattr(self, "_moss_processor_cache", None)
if cached is not None:
return cached
from transformers import AutoProcessor
model_id = self.engine_client.model_config.model
proc = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
if hasattr(proc, "audio_tokenizer"):
proc.audio_tokenizer = proc.audio_tokenizer.to("cpu").eval()
self._moss_processor_cache = proc
return proc
async def _build_moss_tts_params(
self,
request: "OpenAICreateSpeechRequest",
*,
has_inline_ref_audio: bool = False,
) -> dict[str, Any]:
"""Build the talker prompt + ``additional_information`` payload for any
MOSS-TTS-family request (nano + 5 full variants).
For the legacy ``moss_tts_nano`` model_type, keeps the original nano
contract (``{text, mode=voice_clone, prompt_audio_array}``); the
caller still uses a ``[1]`` placeholder prompt.
For the full MOSS-TTS family (``MossTTSDelay*`` / ``MossTTSRealtime*``)
we **call the upstream processor server-side** to produce the unified
``(text_ids, audio_codes)`` shape the talker actually consumes — same
flow as ``examples/.../moss_tts/end2end.py:_build_unified_codes``.
Returns ``{prompt_token_ids: list[int], codes.ref: torch.LongTensor,
max_new_frames, ...}``. The caller treats ``prompt_token_ids`` as the
prompt and forwards the rest as ``additional_information``.
"""
import torch # local to avoid pulling torch at module import time
v = self._moss_variant
# ---- Legacy nano path (unchanged) ----
if v is None: # moss_tts_nano
params: dict[str, Any] = {
"text": [request.input or ""],
"mode": ["voice_clone"],
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
params["prompt_audio_array"] = [[wav_list, sr]]
params["ref_audio_cache_key"] = cache_key
return params
# ---- MOSS-TTS-Realtime: keep the old prompt_audio_array path ----
# ``AutoProcessor.from_pretrained`` doesn't auto-discover
# ``MossTTSRealtimeProcessor`` (no ``processor_config.json`` in the
# snapshot), and Realtime's prompt format diverges from MossTTSDelay
# (16-channel grid, separate per-step text feed). The
# ``prompt_audio_array`` shape lines up well enough with what the
# talker reads for short prompts; full Realtime support needs a
# separate processor.from_module path which we don't wire here.
if v == "realtime":
params: dict[str, Any] = {
"text": [request.input or ""],
"mode": ["voice_clone"],
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
params["prompt_audio_array"] = [[wav_list, sr]]
params["ref_audio_cache_key"] = cache_key
return params
# ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator)
# and MOSS-TTS-Local-Transformer-v1.5: call the upstream processor
# server-side to produce unified codes. Local-v1.5 ships its own
# AutoProcessor (processor_config.json + processing_moss_tts.py) and
# reuses this exact build_user_message/encode_audios_from_wav path in
# the offline example (examples/.../moss_tts/end2end.py:
# _build_unified_codes) -- it is NOT in the same boat as Realtime
# (no processor_config.json there), so it must not fall back to the
# prompt_audio_array path above (which the talker's preprocess()
# never reads -- info_dict["codes"]["ref"] is the only thing it
# consumes, so skipping this path silently drops all voice-clone
# conditioning and produces unconditioned/garbage audio online). ----
proc = self._get_moss_processor()
user_kwargs: dict[str, Any] = {"text": request.input or ""}
resolve_keys: dict[int, str] = {}
if v in ("tts", "local", "ttsd"):
user_kwargs["reference"], resolve_keys = await self._encode_moss_references(
request,
has_inline_ref_audio=has_inline_ref_audio,
two_speaker=(v == "ttsd"),
)
elif v == "sound_effect":
user_kwargs["text"] = request.input or "" # may be empty
user_kwargs["ambient_sound"] = request.ambient_sound or ""
if request.duration_seconds is not None:
user_kwargs["tokens"] = max(1, int(float(request.duration_seconds) * 12.5))
elif request.max_new_tokens is not None:
user_kwargs["tokens"] = int(request.max_new_tokens)
elif v == "voice_generator":
user_kwargs["instruction"] = request.instructions or ""
# Optional language tag for the spoken-text variants. MOSS-TTS-v1.5's
# headline improvement is multilingual synthesis when the language is
# given (build_user_message(..., language=...)); 1.0 ignores it
# gracefully. Sound-effect output is non-verbal, so skip it there.
if v in ("tts", "local", "ttsd", "voice_generator") and getattr(request, "language", None):
user_kwargs["language"] = request.language
# Build the unified-codes prompt: (L, 1+n_vq) where col 0 is text/special
# tokens and cols 1..n_vq are the delay-pattern audio code grid (mostly
# audio_pad_code outside the reference block).
user_msg = proc.build_user_message(**user_kwargs)
batch = proc(conversations=[[user_msg]], mode="generation")
unified = batch["input_ids"][0] # torch.LongTensor (L, 1+n_vq)
text_ids: list[int] = unified[:, 0].tolist()
audio_codes: torch.Tensor = unified[:, 1:].contiguous().to(torch.int64)
params: dict[str, Any] = {
"prompt_token_ids": text_ids,
"codes": {"ref": audio_codes},
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
if 0 in resolve_keys:
params["ref_audio_cache_key"] = resolve_keys[0]
if 1 in resolve_keys:
params["ref_audio_2_cache_key"] = resolve_keys[1]
return params
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate any MOSS-TTS-family request (nano + 5 full variants).
@@ -42,10 +291,10 @@ class _MossTTSAdapterBase(ARTTSAdapter):
if not request.input or not request.input.strip():
# SoundEffect can legitimately have empty input (just ambient_sound).
if server._moss_variant != "sound_effect":
if self._moss_variant != "sound_effect":
return "Input text cannot be empty"
v = server._moss_variant
v = self._moss_variant
if v in (None, "tts", "realtime", "local"):
if request.ref_audio is None:
label = (
@@ -92,7 +341,7 @@ class _MossTTSAdapterBase(ARTTSAdapter):
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
tts_params = await server._build_moss_tts_params(request, has_inline_ref_audio=has_inline_ref_audio)
tts_params = await self._build_moss_tts_params(request, has_inline_ref_audio=has_inline_ref_audio)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
@@ -2,12 +2,15 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Qwen3-TTS serving adapter."""
import math
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
import regex as re
from vllm.inputs import tokens_input
from vllm.logger import init_logger
from vllm.utils.async_utils import make_async
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import (
@@ -15,6 +18,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import (
ARTTSAdapter,
PreparedRequest,
TTSGenerationError,
conditioning_cache_salt,
)
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_precomputed_speakers
from vllm_omni.utils.speaker_cache import validate_qwen3_tts_profile
@@ -23,6 +27,7 @@ if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
logger = init_logger(__name__)
_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key"
QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY = "_qwen3_tts_effective_max_tokens"
_MIN_CODEC_FRAMES = 192
@@ -45,6 +50,33 @@ class Qwen3TTSAdapter(ARTTSAdapter):
name = "qwen3_tts"
supported_output_sample_rates = frozenset({8000, 24000})
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._estimate_prompt_len_async = make_async(
self._estimate_prompt_len, executor=getattr(ctx.server, "_tts_executor", None)
)
def _estimate_ref_code_len(self, ref_audio: object) -> int | None:
codec_frame_rate = self.capabilities.codec_frame_rate
if codec_frame_rate is None:
return None
try:
item = ref_audio
while isinstance(item, list) and item:
if len(item) == 2 and isinstance(item[1], (int, float)):
break
item = item[0]
if not isinstance(item, (list, tuple)) or len(item) != 2:
return None
wav, sr = item
sr = int(sr)
n_samples = len(wav) if hasattr(wav, "__len__") else wav.shape[-1]
if sr <= 0 or n_samples <= 0:
return None
return math.ceil(n_samples / sr * codec_frame_rate)
except Exception:
return None
def _get_model_variant(self) -> str | None:
"""Return the task supported by the loaded Qwen3-TTS checkpoint.
@@ -223,15 +255,208 @@ class Qwen3TTSAdapter(ARTTSAdapter):
return None
def _build_tts_params(self, request: "OpenAICreateSpeechRequest") -> dict[str, Any]:
"""Build TTS parameters from request.
Processes each parameter if present, skips if not.
Values are wrapped in lists as required by the model.
"""
server = self.ctx.server
params: dict[str, Any] = {}
# Text content (always required)
params["text"] = [request.input]
# Task type
if request.task_type is not None:
params["task_type"] = [request.task_type]
else:
params["task_type"] = ["CustomVoice"]
# Language
if request.language is not None:
params["language"] = [request.language]
else:
params["language"] = ["Auto"]
# Speaker (voice)
if request.voice is not None:
voice_lower = request.voice.lower()
precomputed_speakers = self.capabilities.precomputed_speakers
params["speaker"] = [request.voice]
params["voice_created_at"] = [server._voice_created_at(voice_lower)]
# Uploaded voices use task_type="Base" (CustomVoice requires built-in spk_id).
# If ref_text was provided at upload time, use in-context cloning; otherwise x_vector only.
if voice_lower in server.uploaded_speakers and request.ref_audio is None:
speaker_info = server.uploaded_speakers[voice_lower]
# Check if this voice was uploaded with a pre-computed embedding.
# Populate request.speaker_embedding so the existing code path
# (below) handles voice_clone_prompt and x_vector_only_mode.
embedding = server._get_uploaded_speaker_embedding(request.voice)
if embedding is not None:
request.speaker_embedding = embedding
params["speaker"] = [voice_lower]
params["task_type"] = ["Base"]
logger.info("Auto-set speaker_embedding for uploaded voice: %s", request.voice)
else:
audio_data = server._get_uploaded_audio_data(request.voice)
if not audio_data:
raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted")
stored_ref_text = speaker_info.get("ref_text")
params["speaker"] = [voice_lower]
params["ref_audio"] = [audio_data]
params["task_type"] = ["Base"]
if stored_ref_text:
params["ref_text"] = [stored_ref_text]
params["x_vector_only_mode"] = [False]
else:
params["x_vector_only_mode"] = [True]
logger.info(
"Auto-set ref_audio for uploaded voice: %s (icl=%s)", request.voice, bool(stored_ref_text)
)
elif voice_lower in precomputed_speakers and request.ref_audio is None:
profile = precomputed_speakers[voice_lower]
mode = str(profile.get("mode") or "xvec").lower()
params["speaker"] = [voice_lower]
params["task_type"] = ["Base"]
params["x_vector_only_mode"] = [mode != "icl"]
ref_text = request.ref_text or profile.get("ref_text")
if isinstance(ref_text, str) and ref_text.strip():
params["ref_text"] = [ref_text]
ref_code_length = profile.get("ref_code_length")
if mode == "icl" and ref_code_length:
params["ref_code_length"] = [int(ref_code_length)]
logger.info("Using precomputed Qwen3-TTS custom voice profile: %s (mode=%s)", voice_lower, mode)
elif params["task_type"][0] == "CustomVoice":
params["speaker"] = ["Vivian"] # Default for CustomVoice
# Instructions for style/emotion control
if request.instructions is not None:
params["instruct"] = [request.instructions]
else:
params["instruct"] = [""]
# Voice clone: ref_audio resolved in create_speech(), not here.
if request.ref_text is not None:
params["ref_text"] = [request.ref_text]
if request.speaker_embedding is not None:
# Store as plain float list (not tensor) so it survives msgspec
# serialization through the EngineCore IPC boundary. The talker's
# _build_prompt_embeds converts it back to a tensor on the GPU.
params["voice_clone_prompt"] = [
{
"ref_spk_embedding": list(request.speaker_embedding),
}
]
# speaker_embedding implies x_vector_only_mode
params["x_vector_only_mode"] = [True]
elif request.x_vector_only_mode is not None:
params["x_vector_only_mode"] = [request.x_vector_only_mode]
# Generation parameters
if request.max_new_tokens is not None:
params["max_new_tokens"] = [request.max_new_tokens]
else:
params["max_new_tokens"] = [2048]
if request.initial_codec_chunk_frames is not None:
params["initial_codec_chunk_frames"] = [request.initial_codec_chunk_frames]
if request.non_streaming_mode is not None:
params["non_streaming_mode"] = [request.non_streaming_mode]
# Preserve the legacy VoiceDesign fallback when the request omits an
# explicit override. CustomVoice and Base rely on model defaults
# (True and False respectively).
elif params["task_type"][0] == "VoiceDesign":
params["non_streaming_mode"] = [True]
return params
def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int:
"""Estimate prompt length so the placeholder matches model-side embeddings."""
try:
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
Qwen3TTSPromptEmbedsBuilder,
)
server = self.ctx.server
if server._tts_tokenizer is None:
from transformers import AutoTokenizer
model_name = self.ctx.engine_client.model_config.model
server._tts_tokenizer = AutoTokenizer.from_pretrained(
model_name,
trust_remote_code=True,
padding_side="left",
)
hf_config = self.ctx.engine_client.model_config.hf_config
talker_config = hf_config.talker_config
task_type = (tts_params.get("task_type") or ["CustomVoice"])[0]
return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information=tts_params,
task_type=task_type,
tokenize_prompt=lambda t: server._tts_tokenizer(t, padding=False)["input_ids"],
codec_language_id=getattr(talker_config, "codec_language_id", None),
spk_is_dialect=getattr(talker_config, "spk_is_dialect", None),
estimate_ref_code_len=self._estimate_ref_code_len,
)
except Exception as e:
logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e)
return 2048
def _qwen3_tts_can_use_ref_audio_artifact_only(self, tts_params: dict[str, Any], artifact_key: str | None) -> bool:
server = self.ctx.server
x_vector_only = server._tts_x_vector_only(tts_params)
if not artifact_key or (artifact_key, x_vector_only) not in server._ref_audio_model_artifact_ready:
return False
return (tts_params.get("task_type") or ["CustomVoice"])[0] == "Base"
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt, tts_params, warmup_key = await self.ctx.server._build_qwen3_tts_request(request)
"""Build prompt + tts_params for Qwen3-TTS.
Called from ``Qwen3TTSAdapter.build``. Returns
``(prompt, tts_params, warmup_artifact_key)`` where the warmup key is the
Qwen3-TTS ref-audio artifact tracked after ``generate()``.
"""
server = self.ctx.server
qwen3_ref_audio_warmup_artifact_key: str | None = None
tts_params = self._build_tts_params(request)
# Resolve ref_audio (explicit or auto-set for uploaded voices)
# to [[wav_list, sr]] so the model doesn't re-decode base64.
ref_audio_source = request.ref_audio
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
ref_audio_source = tts_params["ref_audio"][0]
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr, cache_key = await server._resolve_ref_audio(ref_audio_source)
tts_params["ref_audio_cache_key"] = cache_key
artifact_key = server._get_resolved_ref_audio_artifact_key(cache_key)
if artifact_key:
tts_params[_REF_AUDIO_CACHE_KEY] = [artifact_key]
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
if ref_code_length is not None:
tts_params["ref_code_length"] = [int(ref_code_length)]
if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key):
logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key)
else:
tts_params["ref_audio"] = [[wav_list, sr]]
qwen3_ref_audio_warmup_artifact_key = artifact_key
ph_len = await self._estimate_prompt_len_async(tts_params)
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
prompt["additional_information"] = tts_params
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
return PreparedRequest(
prompt=prompt,
tts_params=tts_params,
model_type=tts_params.get("task_type", ["unknown"])[0],
warmup_artifact_key=warmup_key,
warmup_artifact_key=qwen3_ref_audio_warmup_artifact_key,
)
def _get_expected_speaker_embedding_dim(self) -> int:
@@ -340,6 +565,16 @@ class Qwen3TTSAdapter(ARTTSAdapter):
if isinstance(additional_information, dict) and effective_cap is not None:
additional_information[QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY] = [effective_cap]
# Propagate the deploy/YAML stage seed to residual MTP sampling. The
# generic serving layer applies an explicit request.seed afterwards,
# so the request value still has higher precedence.
stage0_params = sampling_params_list[0]
default_seed = getattr(stage0_params, "seed", None)
if default_seed is not None:
if stage0_params.extra_args is None:
stage0_params.extra_args = {}
stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed))
logger.debug(
"Qwen3-TTS codec budget: task_type=%s text_tokens=%s dynamic_cap=%s "
"configured_cap=%s request_cap=%s effective_cap=%s",
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Step-Audio2 serving adapter."""
from typing import TYPE_CHECKING
@@ -30,5 +31,17 @@ class StepAudio2Adapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = self.ctx.server._build_step_audio2_prompt(request)
"""Build a chat prompt ending at ``<tts_start>``.
The assistant turn deliberately omits ``<|im_end|>`` so the thinker
continues by generating audio tokens.
"""
system_prompt = request.instructions or "You are a voice assistant. Read the text aloud."
prompt = {
"prompt": (
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
f"<|im_start|>user\n{request.input}<|im_end|>\n"
"<|im_start|>assistant\n<tts_start>"
)
}
return PreparedRequest(prompt=prompt, tts_params={}, model_type="step_audio2")
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""VoxCPM2 serving adapter (AR base-LM + diffusion side-computation)."""
import time
from typing import Any
import numpy as np
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@@ -28,6 +30,63 @@ class VoxCPM2Adapter(ARTTSAdapter):
# claims still resolves to VoxCPM2.
detect_priority = 10
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._tokenizer = None
self._split_map: dict[int, list[int]] = {}
def _encode(self, text: str) -> list[int]:
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import (
build_cjk_split_map,
split_multichar_chinese,
)
if self._tokenizer is None:
self._tokenizer = AutoTokenizer.from_pretrained(
self.ctx.engine_client.model_config.model, trust_remote_code=True
)
self._split_map = build_cjk_split_map(self._tokenizer)
logger.info("VoxCPM2 serving: built multichar split map (%d entries)", len(self._split_map))
ids = self._tokenizer.encode(text, add_special_tokens=True)
return split_multichar_chinese(ids, self._split_map)
async def _build_prompt(
self,
request: "OpenAICreateSpeechRequest",
uploaded_ref: tuple[np.ndarray, int] | None = None,
) -> dict[str, Any]:
"""Build the full-length VoxCPM2 prefill prompt.
``uploaded_ref`` supplies an uploaded voice waveform when the request
has no explicit ``ref_audio`` so prefill-length accounting includes it.
"""
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import build_voxcpm2_prompt
server = self.ctx.server
self._encode("")
ref_audio = None
ref_sr = None
voice_profile = None
if request.ref_audio is not None:
ref_audio, ref_sr, _ = await server._resolve_ref_audio(request.ref_audio)
elif uploaded_ref is not None:
wav_np, ref_sr = uploaded_ref
ref_audio = wav_np.tolist()
elif request.voice is not None:
voice_profile = self.capabilities.precomputed_speakers.get(request.voice.lower())
return build_voxcpm2_prompt(
hf_config=self.ctx.engine_client.model_config.hf_config,
tokenizer=self._tokenizer,
split_map=self._split_map,
text=request.input,
ref_audio=ref_audio,
ref_sr=ref_sr,
ref_text=request.ref_text,
voice_profile=voice_profile,
)
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate VoxCPM2 request parameters. Returns error message or None."""
server = self.ctx.server
@@ -66,7 +125,7 @@ class VoxCPM2Adapter(ARTTSAdapter):
)
if request.ref_audio is None:
uploaded_ref = server._load_uploaded_audio(voice_lower)
prompt = await server._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref)
prompt = await self._build_prompt(request, uploaded_ref=uploaded_ref)
tts_params = {}
if request.voice:
voice_lower = request.voice.lower()
@@ -1,8 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Voxtral TTS serving adapter."""
from typing import TYPE_CHECKING, Any
from vllm.utils.async_utils import make_async
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers
@@ -16,6 +19,39 @@ class VoxtralTTSAdapter(ARTTSAdapter):
stage_keys = frozenset({"audio_generation"})
name = "voxtral_tts"
def __init__(self, ctx) -> None:
super().__init__(ctx)
self._build_prompt_async = make_async(self._build_prompt, executor=getattr(ctx.server, "_tts_executor", None))
def _build_prompt(self, request: "OpenAICreateSpeechRequest") -> dict[str, Any]:
"""Build a Voxtral prompt for either a preset voice or inline reference audio."""
from mistral_common.protocol.speech.request import SpeechRequest
from vllm.inputs import tokens_input
ref_audio = request.ref_audio
if not request.voice and not ref_audio:
raise ValueError("Voxtral requires either a voice name or ref_audio.")
if isinstance(ref_audio, str) and ref_audio.startswith("data:"):
_, _, ref_audio = ref_audio.partition(",")
server = self.ctx.server
if server._tts_tokenizer is None:
from vllm.tokenizers import cached_tokenizer_from_config
server._tts_tokenizer = cached_tokenizer_from_config(self.ctx.engine_client.model_config).instruct
if request.voice is not None:
tokenized = server._tts_tokenizer.encode_speech_request(
SpeechRequest(input=request.input, voice=request.voice)
)
prompt = tokens_input(prompt_token_ids=tokenized.tokens)
prompt["additional_information"] = {"voice": [request.voice]}
return prompt
tokenized = server._tts_tokenizer.encode_speech_request(SpeechRequest(input=request.input, ref_audio=ref_audio))
audio = tokenized.audios[0]
return {
"prompt_token_ids": tokenized.tokens,
"multi_modal_data": {"audio": [(audio.audio_array, audio.sampling_rate)]},
}
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
"""Validate Voxtral TTS request parameters. Returns error message or None."""
server = self.ctx.server
@@ -48,7 +84,7 @@ class VoxtralTTSAdapter(ARTTSAdapter):
async def build(
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
prompt = await self.ctx.server._build_voxtral_prompt_async(request)
prompt = await self._build_prompt_async(request)
return PreparedRequest(prompt=prompt, tts_params={}, model_type="voxtral_tts")
def _load_supported_speakers(self) -> set[str]:
@@ -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
"""VoxCPM2 AR talker — PagedAttention pipeline with per-request state.
Architecture:
@@ -158,7 +158,7 @@ def build_voxcpm2_prompt(
"""Build a VoxCPM2 prefill prompt whose ``prompt_token_ids`` length matches
the talker-side prefill length.
Used by both online serving (``serving_speech._build_voxcpm2_prompt``) and
Used by both online serving (``VoxCPM2Adapter._build_prompt``) and
the offline example, so the talker-side length assertion never fires.
"""
ids = split_multichar_chinese(tokenizer.encode(text, add_special_tokens=True), split_map)
@@ -2208,7 +2208,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
assert scaffold_len == tts_len, (
f"voxcpm2 prefill length mismatch: scaffold_len={scaffold_len} tts_len={tts_len}; "
"caller must pad prompt_token_ids to the full prefill length "
"(see serving_speech._build_voxcpm2_prompt or the offline example)."
"(see VoxCPM2Adapter._build_prompt or the offline example)."
)
enc_out = base_lm_out.unsqueeze(0)