[Refactor] Move model-specific capability metadata into TTS adapters (#6138)
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
1 parent
42059f19d5
commit
3781214675
15 files changed
+722
-427
No files matched your search
@@ -192,6 +192,7 @@ select = [
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"TID251", # flake8-tidy-imports.banned-api
|
||||
"TC004", # flake8-type-checking: runtime-used imports
|
||||
]
|
||||
ignore = [
|
||||
"E203", # whitespace before ':' (conflicts with black)
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import os
|
||||
import struct
|
||||
import wave
|
||||
from dataclasses import FrozenInstanceError, replace
|
||||
from inspect import Signature, signature
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -38,12 +39,13 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
|
||||
StreamingSpeechSessionConfig,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
_TTS_LANGUAGES,
|
||||
OmniOpenAIServingSpeech,
|
||||
_create_wav_header,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest, SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import DEFAULT_TTS_LANGUAGES, PreparedRequest, SpeechServingContext
|
||||
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.voxtral import VoxtralTTSAdapter
|
||||
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
|
||||
FISH_TEXT_ONLY_SYSTEM_PROMPT,
|
||||
build_fish_voice_clone_prompt_ids,
|
||||
@@ -218,7 +220,8 @@ def _wav_data_url(samples: np.ndarray, sample_rate: int) -> str:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app(mocker: MockerFixture):
|
||||
def test_app(mocker: MockerFixture, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path))
|
||||
# Mock the engine client
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -241,6 +244,11 @@ def test_app(mocker: MockerFixture):
|
||||
models=mock_models,
|
||||
request_logger=mock_request_logger,
|
||||
)
|
||||
# Voice endpoint tests exercise generic embedding parsing/persistence with
|
||||
# multiple valid dimensions. Supply the adapter contract without imposing
|
||||
# one real model's fixed embedding dimension on this shared fixture.
|
||||
speech_server._adapter = mocker.MagicMock()
|
||||
speech_server._adapter.validate_tts_embedding_dim.return_value = None
|
||||
speech_server._tts_tokenizer = lambda text, padding=False: {"input_ids": text.split()}
|
||||
|
||||
# Skip TTS validation in tests (mock doesn't set up supported_speakers)
|
||||
@@ -263,31 +271,9 @@ def test_app(mocker: MockerFixture):
|
||||
speech_server.create_speech = awaitable_patched_create_speech
|
||||
|
||||
app = FastAPI()
|
||||
app.state.openai_serving_speech = speech_server
|
||||
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
|
||||
|
||||
# Add list_voices endpoint
|
||||
async def list_voices():
|
||||
speakers = sorted(speech_server.supported_speakers) if speech_server.supported_speakers else []
|
||||
uploaded_voices = []
|
||||
if hasattr(speech_server, "uploaded_speakers"):
|
||||
for voice_name, info in speech_server.uploaded_speakers.items():
|
||||
voice_entry = {
|
||||
"name": info.get("name", voice_name),
|
||||
"consent": info.get("consent", ""),
|
||||
"created_at": info.get("created_at", 0),
|
||||
"file_size": info.get("file_size", 0),
|
||||
"mime_type": info.get("mime_type", ""),
|
||||
"embedding_source": info.get("embedding_source", "audio"),
|
||||
"embedding_dim": info.get("embedding_dim"),
|
||||
}
|
||||
if info.get("ref_text"):
|
||||
voice_entry["ref_text"] = info["ref_text"]
|
||||
if info.get("speaker_description"):
|
||||
voice_entry["speaker_description"] = info["speaker_description"]
|
||||
uploaded_voices.append(voice_entry)
|
||||
return {"voices": speakers, "uploaded_voices": uploaded_voices}
|
||||
|
||||
app.add_api_route("/v1/audio/voices", list_voices, methods=["GET"])
|
||||
app.add_api_route("/v1/audio/voices", api_server_module.list_voices, methods=["GET"])
|
||||
app.add_api_route("/v1/audio/speech/batch", speech_server.create_speech_batch, methods=["POST"])
|
||||
|
||||
# Add upload_voice endpoint
|
||||
@@ -459,9 +445,31 @@ class TestSpeechAPI:
|
||||
assert audio_obj.speed == 2.5
|
||||
|
||||
def test_list_voices_endpoint(self, client):
|
||||
handler = client.app.state.openai_serving_speech
|
||||
handler._adapter.capabilities.supported_speakers = frozenset({"alice"})
|
||||
handler._adapter.capabilities.precomputed_speakers = {"bob": {"name": "Bob"}}
|
||||
handler.uploaded_speakers = {
|
||||
"carol": {
|
||||
"name": "Carol",
|
||||
"created_at": 1,
|
||||
}
|
||||
}
|
||||
|
||||
response = client.get("/v1/audio/voices")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "voices" in response.json()
|
||||
assert response.json()["voices"] == ["alice", "bob", "carol", "default"]
|
||||
assert response.json()["uploaded_voices"] == [
|
||||
{
|
||||
"name": "Carol",
|
||||
"consent": "",
|
||||
"created_at": 1,
|
||||
"file_size": 0,
|
||||
"mime_type": "",
|
||||
"embedding_source": "audio",
|
||||
"embedding_dim": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_upload_voice_success(self, client, tmp_path):
|
||||
"""Test successful voice upload without ref_text."""
|
||||
@@ -719,6 +727,20 @@ class TestSpeechAPI:
|
||||
assert response.status_code == 400
|
||||
assert "finite" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diffusion_create_speech_with_unknown_voice(self, mocker: MockerFixture):
|
||||
engine_client = mocker.MagicMock()
|
||||
server = OmniOpenAIServingSpeech.for_diffusion(
|
||||
diffusion_engine=engine_client,
|
||||
model_name="test-model",
|
||||
)
|
||||
assert server._adapter is None
|
||||
|
||||
response = await server.create_speech(OpenAICreateSpeechRequest(input="test-input", voice="test-voice"))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert b"Invalid voice" in response.body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_diffusion_speech_extra_params(self, mocker: MockerFixture):
|
||||
"""Test public diffusion speech success and extra_params propagation."""
|
||||
@@ -767,7 +789,8 @@ class TestTTSMethods:
|
||||
"""Unit tests for TTS validation and parameter building."""
|
||||
|
||||
@pytest.fixture
|
||||
def speech_server(self, mocker: MockerFixture):
|
||||
def speech_server(self, mocker, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path))
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.stage_configs = []
|
||||
@@ -990,12 +1013,18 @@ class TestTTSMethods:
|
||||
|
||||
def test_speaker_embedding_valid_base_task(self, speech_server):
|
||||
"""speaker_embedding with Base task, x_vector_only_mode, and no ref_audio is accepted."""
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(talker_config=SimpleNamespace(hidden_size=1024))
|
||||
)
|
||||
emb = [0.1] * 1024
|
||||
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb, x_vector_only_mode=True)
|
||||
assert speech_server._validate_tts_request(req) is None
|
||||
|
||||
def test_speaker_embedding_auto_sets_x_vector_only_mode(self, speech_server):
|
||||
"""speaker_embedding auto-implies x_vector_only_mode, so validation passes."""
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(talker_config=SimpleNamespace(hidden_size=1024))
|
||||
)
|
||||
emb = [0.1] * 1024
|
||||
req = OpenAICreateSpeechRequest(input="Hello", task_type="Base", speaker_embedding=emb)
|
||||
result = speech_server._validate_tts_request(req)
|
||||
@@ -1067,6 +1096,7 @@ class TestTTSMethods:
|
||||
"""Embedding uploads must match the loaded Qwen3-TTS model before being stored."""
|
||||
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(
|
||||
talker_config=SimpleNamespace(hidden_size=2048),
|
||||
@@ -1085,6 +1115,7 @@ class TestTTSMethods:
|
||||
def test_upload_ming_voice_embedding_wrong_dims_rejected_without_replacing_existing(self, speech_server):
|
||||
"""Ming embedding uploads must match the model dimension before replacement."""
|
||||
speech_server._tts_model_type = "ming_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
invalid_dim = SPEAKER_EMBEDDING_DIM - 1
|
||||
existing = {
|
||||
"name": "bad_emb_voice",
|
||||
@@ -1112,9 +1143,13 @@ class TestTTSMethods:
|
||||
def test_upload_ming_audio_voice_defers_speaker_extraction(self, speech_server, mocker: MockerFixture, tmp_path):
|
||||
"""Ming audio uploads stay model-agnostic and defer extraction to use."""
|
||||
speech_server._tts_model_type = "ming_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers_dir = tmp_path
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server.supported_speakers = set()
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
supported_speakers=frozenset(),
|
||||
)
|
||||
speech_server._speaker_cache.clear()
|
||||
|
||||
left = np.full(32000, 0.5, dtype=np.float32)
|
||||
@@ -1209,16 +1244,20 @@ class TestTTSMethods:
|
||||
def test_precomputed_qwen3_voice_infers_base_without_ref_audio(self, speech_server):
|
||||
"""Precomputed Qwen3 voices are reusable by name without per-request ref_audio."""
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server.precomputed_speakers = {
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "qwen3_tts",
|
||||
"mode": "icl",
|
||||
"ref_text": "reference transcript",
|
||||
"ref_code_length": 3,
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"alice"}
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "qwen3_tts",
|
||||
"mode": "icl",
|
||||
"ref_text": "reference transcript",
|
||||
"ref_code_length": 3,
|
||||
}
|
||||
},
|
||||
supported_speakers=frozenset({"alice"}),
|
||||
)
|
||||
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
|
||||
assert speech_server._validate_tts_request(req) is None
|
||||
@@ -1239,6 +1278,7 @@ class TestTTSMethods:
|
||||
uploaded_path = tmp_path / "alice.safetensors"
|
||||
save_file({"speaker_embedding": torch.tensor([0.1] * 4)}, str(uploaded_path))
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers_dir = tmp_path
|
||||
speech_server.uploaded_speakers = {
|
||||
"alice": {
|
||||
@@ -1248,15 +1288,18 @@ class TestTTSMethods:
|
||||
"embedding_source": "direct",
|
||||
}
|
||||
}
|
||||
speech_server.precomputed_speakers = {
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "qwen3_tts",
|
||||
"mode": "icl",
|
||||
"ref_text": "precomputed transcript",
|
||||
"ref_code_length": 3,
|
||||
}
|
||||
}
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "qwen3_tts",
|
||||
"mode": "icl",
|
||||
"ref_text": "precomputed transcript",
|
||||
"ref_code_length": 3,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
|
||||
assert speech_server._validate_tts_request(req) is None
|
||||
@@ -1283,13 +1326,16 @@ class TestTTSMethods:
|
||||
talker_config=SimpleNamespace(hidden_size=4),
|
||||
)
|
||||
)
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
|
||||
profiles = speech_server._load_precomputed_speakers()
|
||||
profiles = speech_server._adapter._load_precomputed_speakers()
|
||||
assert profiles == {}
|
||||
|
||||
speech_server.precomputed_speakers = profiles
|
||||
speech_server.supported_speakers = set(profiles)
|
||||
assert "alice" not in speech_server.supported_speakers
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers=profiles,
|
||||
)
|
||||
assert "alice" not in speech_server._adapter.capabilities.precomputed_speakers
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
|
||||
assert speech_server._validate_tts_request(req) is not None
|
||||
|
||||
@@ -1317,13 +1363,16 @@ class TestTTSMethods:
|
||||
talker_config=SimpleNamespace(hidden_size=4),
|
||||
)
|
||||
)
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
|
||||
profiles = speech_server._load_precomputed_speakers()
|
||||
profiles = speech_server._adapter._load_precomputed_speakers()
|
||||
assert profiles == {}
|
||||
|
||||
speech_server.precomputed_speakers = profiles
|
||||
speech_server.supported_speakers = set(profiles)
|
||||
assert "alice" not in speech_server.supported_speakers
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers=profiles,
|
||||
)
|
||||
assert "alice" not in speech_server._adapter.capabilities.precomputed_speakers
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="Alice")
|
||||
assert speech_server._validate_tts_request(req) is not None
|
||||
|
||||
@@ -1338,13 +1387,16 @@ class TestTTSMethods:
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(custom_voice_dir=str(tmp_path))
|
||||
)
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
|
||||
profiles = speech_server._load_precomputed_speakers()
|
||||
profiles = speech_server._adapter._load_precomputed_speakers()
|
||||
assert profiles == {}
|
||||
|
||||
speech_server.precomputed_speakers = profiles
|
||||
speech_server.supported_speakers = set(profiles)
|
||||
assert "bob" not in speech_server.supported_speakers
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers=profiles,
|
||||
)
|
||||
assert "bob" not in speech_server._adapter.capabilities.precomputed_speakers
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="Bob")
|
||||
assert speech_server._validate_tts_request(req) is not None
|
||||
assert speech_server._validate_tts_request(OpenAICreateSpeechRequest(input="Hello")) is None
|
||||
@@ -1352,9 +1404,12 @@ class TestTTSMethods:
|
||||
def test_prepare_voxcpm2_rejects_supported_speaker_without_voice_profile(self, speech_server, mocker):
|
||||
"""VoxCPM2 named voices must be uploaded or precomputed on the real request path."""
|
||||
speech_server._tts_model_type = "voxcpm2"
|
||||
speech_server.supported_speakers = {"bob", "default"}
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server.precomputed_speakers = {}
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={},
|
||||
)
|
||||
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(
|
||||
@@ -1370,9 +1425,12 @@ class TestTTSMethods:
|
||||
def test_prepare_voxcpm2_accepts_default_voice(self, speech_server, mocker):
|
||||
"""VoxCPM2 default voice preserves the built-in zero-shot request path."""
|
||||
speech_server._tts_model_type = "voxcpm2"
|
||||
speech_server.supported_speakers = {"default"}
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server.precomputed_speakers = {}
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={},
|
||||
)
|
||||
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(
|
||||
@@ -1387,23 +1445,26 @@ class TestTTSMethods:
|
||||
def test_prepare_voxcpm2_precomputed_voice_sets_model_cache_key(self, speech_server, mocker):
|
||||
"""VoxCPM2 precomputed voices must carry voice metadata to the model cache lookup."""
|
||||
speech_server._tts_model_type = "voxcpm2"
|
||||
speech_server.supported_speakers = {"alice"}
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server.precomputed_speakers = {
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "voxcpm2",
|
||||
"mode": "reference",
|
||||
"ref_audio_feat_len": 2,
|
||||
}
|
||||
}
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={
|
||||
"alice": {
|
||||
"name": "Alice",
|
||||
"model_type": "voxcpm2",
|
||||
"mode": "reference",
|
||||
"ref_audio_feat_len": 2,
|
||||
}
|
||||
},
|
||||
)
|
||||
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(
|
||||
return_value={
|
||||
"prompt_token_ids": [1],
|
||||
"additional_information": {
|
||||
"voice_profile": speech_server.precomputed_speakers["alice"],
|
||||
"voice_profile": speech_server._adapter.capabilities.precomputed_speakers["alice"],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1482,6 +1543,10 @@ class TestTTSMethods:
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
|
||||
mock_stage = mocker.MagicMock()
|
||||
mock_stage.engine_args.model_stage = "qwen3_tts"
|
||||
mock_engine_client.stage_configs = [mock_stage]
|
||||
|
||||
server = OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
@@ -1489,7 +1554,10 @@ class TestTTSMethods:
|
||||
)
|
||||
|
||||
# Verify speakers are normalized to lowercase
|
||||
assert server.supported_speakers == {"ryan", "vivian", "aiden"}
|
||||
assert server._adapter.capabilities.supported_speakers == {"ryan", "vivian", "aiden"}
|
||||
assert isinstance(server._adapter.capabilities.supported_speakers, frozenset)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(server._adapter.capabilities, "codec_frame_rate", 25.0)
|
||||
|
||||
def test_load_supported_speakers_skips_non_tts_omni_model(
|
||||
self,
|
||||
@@ -1502,7 +1570,7 @@ class TestTTSMethods:
|
||||
)
|
||||
|
||||
assert speech_server._is_tts is False
|
||||
assert speech_server._load_supported_speakers() == set()
|
||||
assert load_supported_speakers(speech_server.engine_client) == set()
|
||||
warning.assert_not_called()
|
||||
|
||||
def test_load_supported_languages_from_config(self, speech_server):
|
||||
@@ -1515,7 +1583,8 @@ class TestTTSMethods:
|
||||
)
|
||||
)
|
||||
)
|
||||
assert speech_server._load_supported_languages() == {
|
||||
adapter = speech_server._get_tts_adapter()
|
||||
assert adapter._load_supported_languages() == {
|
||||
"Chinese",
|
||||
"English",
|
||||
"Beijing_Dialect",
|
||||
@@ -1528,11 +1597,17 @@ class TestTTSMethods:
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(talker_config={"codec_language_id": {"chinese": 2055, "english": 2050}})
|
||||
)
|
||||
assert speech_server._load_supported_languages() == {"Chinese", "English", "Auto"}
|
||||
adapter = speech_server._get_tts_adapter()
|
||||
assert adapter._load_supported_languages() == {"Chinese", "English", "Auto"}
|
||||
|
||||
def test_validate_language_custom_dialect_accepted(self, speech_server):
|
||||
"""A language present in the model config passes validation, case-insensitively."""
|
||||
speech_server.supported_languages = {"Chinese", "English", "Beijing_Dialect", "Auto"}
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
supported_languages=frozenset({"Chinese", "English", "Beijing_Dialect", "Auto"}),
|
||||
)
|
||||
for language in ("Beijing_Dialect", "beijing_dialect", "English", "english", "Auto", "AUTO"):
|
||||
req = OpenAICreateSpeechRequest(input="Hello", language=language)
|
||||
result = speech_server._validate_tts_request(req)
|
||||
@@ -1542,7 +1617,12 @@ class TestTTSMethods:
|
||||
|
||||
def test_validate_language_unknown_rejected(self, speech_server):
|
||||
"""A language not in the configured set is rejected."""
|
||||
speech_server.supported_languages = {"Chinese", "English", "Auto"}
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
supported_languages=frozenset({"Chinese", "English", "Auto"}),
|
||||
)
|
||||
for language in ("Klingon", "klingon"):
|
||||
req = OpenAICreateSpeechRequest(input="Hello", language=language)
|
||||
assert "Invalid language" in speech_server._validate_tts_request(req)
|
||||
@@ -1553,18 +1633,21 @@ class TestTTSMethods:
|
||||
speech_server.engine_client.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_language_id={}))
|
||||
)
|
||||
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
|
||||
adapter = speech_server._get_tts_adapter()
|
||||
assert adapter._load_supported_languages() == DEFAULT_TTS_LANGUAGES
|
||||
|
||||
def test_load_supported_languages_default_on_config_error(self, speech_server):
|
||||
"""If the model config cannot be read, fall back to the default list."""
|
||||
speech_server._tts_model_type = "qwen3_tts"
|
||||
speech_server.engine_client = SimpleNamespace() # no model_config -> AttributeError
|
||||
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
|
||||
speech_server._adapter = None
|
||||
adapter = speech_server._get_tts_adapter()
|
||||
assert adapter._load_supported_languages() == DEFAULT_TTS_LANGUAGES
|
||||
|
||||
def test_load_supported_languages_default_for_non_qwen(self, speech_server):
|
||||
"""Non-qwen3_tts model types get the default language set."""
|
||||
speech_server._tts_model_type = None
|
||||
assert speech_server._load_supported_languages() == _TTS_LANGUAGES
|
||||
adapter = MingTTSAdapter(SpeechServingContext(server=speech_server, engine_client=speech_server.engine_client))
|
||||
assert adapter._load_supported_languages() == DEFAULT_TTS_LANGUAGES
|
||||
|
||||
def test_build_tts_params_with_uploaded_voice(self, speech_server, mocker: MockerFixture):
|
||||
"""Test _build_tts_params auto-sets ref_audio for uploaded voices (x_vector only)."""
|
||||
@@ -1577,7 +1660,6 @@ class TestTTSMethods:
|
||||
"created_at": 1711234567,
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
|
||||
|
||||
mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data")
|
||||
mock_get_audio.return_value = "data:audio/wav;base64,ZmFrZWF1ZGlv"
|
||||
@@ -1601,7 +1683,6 @@ class TestTTSMethods:
|
||||
"created_at": 1711234567,
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
|
||||
|
||||
mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data")
|
||||
mock_get_audio.return_value = "data:audio/wav;base64,ZmFrZWF1ZGlv"
|
||||
@@ -1618,7 +1699,6 @@ class TestTTSMethods:
|
||||
"""Test _build_tts_params does not auto-set ref_audio for non-uploaded voices."""
|
||||
# No uploaded speakers
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server.supported_speakers = {"ryan", "vivian"}
|
||||
|
||||
req = OpenAICreateSpeechRequest(input="Hello", voice="ryan", task_type="Base")
|
||||
|
||||
@@ -1638,7 +1718,6 @@ class TestTTSMethods:
|
||||
"mime_type": "audio/wav",
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
|
||||
|
||||
req = OpenAICreateSpeechRequest(
|
||||
input="Hello", voice="custom_voice", task_type="Base", ref_audio="data:audio/wav;base64,ZXhwbGljaXQ="
|
||||
@@ -1744,7 +1823,6 @@ class TestTTSMethods:
|
||||
"cache_file": "/tmp/voice_samples/emb_voice.safetensors",
|
||||
}
|
||||
}
|
||||
speech_server.supported_speakers = {"ryan", "vivian", "emb_voice"}
|
||||
|
||||
fake_embedding = [0.1] * 1024
|
||||
mock_get_emb = mocker.patch.object(speech_server, "_get_uploaded_speaker_embedding")
|
||||
@@ -2203,6 +2281,28 @@ class TestTTSMethods:
|
||||
assert request.ref_audio is not None
|
||||
assert call.kwargs["has_inline_ref_audio"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warmup_with_no_adapter(self, speech_server):
|
||||
assert speech_server._adapter is None
|
||||
await speech_server.warmup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voxcpm2_warmup_successfully(self, speech_server, mocker):
|
||||
speech_server._tts_model_type = "voxcpm2"
|
||||
speech_server._adapter = speech_server._get_tts_adapter()
|
||||
speech_server.uploaded_speakers = {}
|
||||
speech_server._adapter.capabilities = replace(
|
||||
speech_server._adapter.capabilities,
|
||||
precomputed_speakers={},
|
||||
)
|
||||
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(
|
||||
return_value={"prompt_token_ids": [1], "additional_information": {}}
|
||||
)
|
||||
|
||||
await speech_server.warmup()
|
||||
|
||||
|
||||
class TestFileValidationFunctions:
|
||||
"""Unit tests for file validation helper functions."""
|
||||
@@ -2973,6 +3073,39 @@ def test_api_server_upload_voice_exception_returns_500(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
|
||||
def test_api_server_upload_voice_with_no_adapter(
|
||||
mocker: MockerFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
):
|
||||
monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path))
|
||||
_patch_api_server_base(mocker)
|
||||
engine_client = mocker.MagicMock()
|
||||
engine_client.errored = False
|
||||
engine_client.stage_configs = []
|
||||
models = mocker.MagicMock()
|
||||
models.is_base_model.return_value = True
|
||||
handler = OmniOpenAIServingSpeech(
|
||||
engine_client=engine_client,
|
||||
models=models,
|
||||
request_logger=mocker.MagicMock(),
|
||||
)
|
||||
assert handler._adapter is None
|
||||
raw_request = _make_api_server_request(handler)
|
||||
|
||||
response = asyncio.run(
|
||||
api_server_module.upload_voice(
|
||||
raw_request,
|
||||
audio_sample=None,
|
||||
speaker_embedding=json.dumps([0.1] * 1024),
|
||||
consent="cons_test",
|
||||
name="probe",
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_server_delete_voice_without_speech_handler_returns_404(mocker: MockerFixture):
|
||||
_patch_api_server_base(mocker)
|
||||
raw_request = _make_api_server_request(None, method="DELETE", path="/v1/audio/voices/probe")
|
||||
@@ -3302,8 +3435,14 @@ class _FakeFishTokenizer:
|
||||
|
||||
@pytest.fixture
|
||||
def fish_speech_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -3573,8 +3712,14 @@ class TestWAVStreaming:
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice3_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -3709,8 +3854,14 @@ class TestCosyVoice3Serving:
|
||||
|
||||
@pytest.fixture
|
||||
def glm_tts_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -3732,7 +3883,6 @@ def glm_tts_server(mocker: MockerFixture):
|
||||
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
|
||||
return OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
@@ -3809,8 +3959,14 @@ class TestGLMTTSServing:
|
||||
|
||||
@pytest.fixture
|
||||
def ming_tts_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -3868,8 +4024,14 @@ class TestMingTTSServing:
|
||||
|
||||
@pytest.fixture
|
||||
def ming_flash_omni_tts_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
@@ -3932,8 +4094,14 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
@pytest.fixture
|
||||
def voxtral_server(self, mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.voxtral.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.model_config = mocker.MagicMock(model="mistralai/Voxtral")
|
||||
@@ -3958,8 +4126,14 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
@pytest.fixture
|
||||
def qwen3_tts_server(self, mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.model_config = mocker.MagicMock(model="Qwen/Qwen3-TTS", hf_config=mocker.MagicMock())
|
||||
@@ -3983,6 +4157,32 @@ class TestTTSAsyncOffloading:
|
||||
yield server
|
||||
server.shutdown()
|
||||
|
||||
def test_voxtral_loads_supported_speakers(self, mocker: MockerFixture):
|
||||
engine_client = mocker.MagicMock()
|
||||
engine_client.model_config.hf_config.audio_config = {
|
||||
"speaker_id": {
|
||||
"Alice": 0,
|
||||
"Bob": 1,
|
||||
}
|
||||
}
|
||||
|
||||
server = mocker.MagicMock()
|
||||
adapter = VoxtralTTSAdapter(
|
||||
SpeechServingContext(
|
||||
server=server,
|
||||
engine_client=engine_client,
|
||||
)
|
||||
)
|
||||
|
||||
adapter.load_capabilities()
|
||||
|
||||
assert adapter.capabilities.supported_speakers == frozenset(
|
||||
{
|
||||
"alice",
|
||||
"bob",
|
||||
}
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -4214,7 +4414,10 @@ class TestTTSAsyncOffloading:
|
||||
artifact_key,
|
||||
)
|
||||
qwen3_tts_server._ref_audio_model_artifact_ready.add((artifact_key, False))
|
||||
qwen3_tts_server._codec_frame_rate = 25.0
|
||||
qwen3_tts_server._adapter.capabilities = replace(
|
||||
qwen3_tts_server._adapter.capabilities,
|
||||
codec_frame_rate=25.0,
|
||||
)
|
||||
qwen3_tts_server._tts_tokenizer = lambda _text, padding=False: {"input_ids": list(range(10))}
|
||||
qwen3_tts_server.engine_client.model_config.hf_config.talker_config = SimpleNamespace(
|
||||
codec_language_id={},
|
||||
@@ -4319,8 +4522,14 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
def test_shutdown_is_idempotent(self, mocker: MockerFixture):
|
||||
"""Calling shutdown() twice should not raise."""
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_supported_speakers",
|
||||
return_value=set(),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.tts_adapters.base.load_codec_frame_rate",
|
||||
return_value=None,
|
||||
)
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.stage_configs = []
|
||||
|
||||
@@ -28,7 +28,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 = 20
|
||||
MAX_MODEL_TYPE_BRANCHES = 9
|
||||
|
||||
_GUIDANCE = """
|
||||
Move the per-model behaviour into its adapter under
|
||||
|
||||
@@ -9,7 +9,6 @@ import re
|
||||
import struct
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
@@ -20,7 +19,6 @@ import soundfile as sf
|
||||
import torch
|
||||
from fastapi import HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from transformers.utils.hub import cached_file
|
||||
from vllm.entrypoints.generate.base.serving import GenerateBaseServing as OpenAIServing
|
||||
from vllm.entrypoints.launcher import terminate_if_errored
|
||||
from vllm.entrypoints.openai.engine.protocol import (
|
||||
@@ -70,13 +68,8 @@ from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import (
|
||||
from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import (
|
||||
create_instruction as ming_create_instruction,
|
||||
)
|
||||
from vllm_omni.model_executor.models.ming_tts.constants import SPEAKER_EMBEDDING_DIM
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.utils.speaker_cache import (
|
||||
get_speaker_cache,
|
||||
iter_custom_voice_profiles,
|
||||
load_validated_profile_tensors,
|
||||
)
|
||||
from vllm_omni.utils.speaker_cache import get_speaker_cache
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -103,21 +96,6 @@ _COSYVOICE3_PROMPT_PREFIX = f"You are a helpful assistant.{_COSYVOICE3_PROMPT_DE
|
||||
# payloads and must fail the request, never serialize as a successful empty
|
||||
# WAV. Covers both the TTS ("audex") and TTA ("audex_tta") pipelines.
|
||||
_AUDEX_NO_AUDIO_GUARD_MODEL_TYPES = frozenset({"audex", "audex_tta"})
|
||||
_TTS_LANGUAGES = frozenset(
|
||||
{
|
||||
"Auto",
|
||||
"Chinese",
|
||||
"English",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"German",
|
||||
"French",
|
||||
"Russian",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Italian",
|
||||
}
|
||||
)
|
||||
_REF_AUDIO_MIN_DURATION = 1.0 # seconds
|
||||
_REF_AUDIO_MAX_DURATION = 30.0 # seconds
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256
|
||||
@@ -297,9 +275,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
except ValueError:
|
||||
logger.warning("Invalid SPEAKER_MAX_UPLOADED=%r; using default 1000", _raw_cap)
|
||||
self._max_uploaded_speakers = 1000
|
||||
self.uploaded_speakers: dict[str, dict] = {}
|
||||
self.precomputed_speakers: dict[str, dict[str, Any]] = {}
|
||||
self.supported_speakers: set[str] = set()
|
||||
self.uploaded_speakers: dict[str, dict[str, Any]] = {}
|
||||
self._ref_audio_data_url_cache: dict[str, str] = {}
|
||||
self._ref_audio_resolve_cache: OrderedDict[str, tuple[list[float], int, int, str]] = OrderedDict()
|
||||
self._ref_audio_resolve_cache_bytes = 0
|
||||
@@ -391,7 +367,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
speaker_data.setdefault("name", voice_name_lower)
|
||||
speaker_data.setdefault("file_size", int(path.stat().st_size))
|
||||
self.uploaded_speakers[voice_name_lower] = speaker_data
|
||||
self.supported_speakers.add(voice_name_lower)
|
||||
self._last_upload_ts = max(self._last_upload_ts, int(speaker_data.get("created_at", 0)))
|
||||
restored += 1
|
||||
if restored:
|
||||
@@ -419,6 +394,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
# Diffusion-only instances don't have a TTS stage; set None so any
|
||||
# ``_is_tts_model()`` / ``_tts_stage`` access doesn't raise AttributeError.
|
||||
instance._tts_stage = None
|
||||
instance._adapter = None
|
||||
instance._init_speaker_storage()
|
||||
return instance
|
||||
|
||||
@@ -443,7 +419,24 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
# Determine TTS model type or None
|
||||
self._tts_model_type = self._detect_tts_model_type()
|
||||
self.precomputed_speakers = self._load_precomputed_speakers()
|
||||
# Resolve the per-model serving adapter (RFC #4327), keyed on the
|
||||
# detected model-type. Every dedicated TTS model has an adapter; the
|
||||
# adapter owns request validation, prompt/param building, capability
|
||||
# metadata, and sampling overrides. The model-type label remains in the
|
||||
# orchestrator for compatibility during this incremental migration.
|
||||
self._adapter = None
|
||||
if self._tts_stage is not None:
|
||||
adapter_cls = resolve_adapter(self._tts_model_type)
|
||||
if adapter_cls is not None:
|
||||
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
|
||||
self._adapter = adapter_cls(ctx)
|
||||
logger.info("Resolved TTS serving adapter: %s", adapter_cls.__name__)
|
||||
|
||||
adapter = self._adapter
|
||||
if adapter is not None:
|
||||
adapter.load_capabilities()
|
||||
available_speakers = self._get_available_speakers()
|
||||
logger.info("Loaded %d supported speakers: %s", len(available_speakers), sorted(available_speakers))
|
||||
|
||||
# Sub-variant inside the full MOSS-TTS family. We collapse all five
|
||||
# variants onto the same _tts_model_type="moss_tts" because they share
|
||||
@@ -459,33 +452,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
# Cache TTS configuration values (computed once, reused per request)
|
||||
self._max_instructions_length = self._compute_max_instructions_length()
|
||||
|
||||
# Merge built-in speakers into the set initialized by _init_speaker_storage.
|
||||
self.supported_speakers |= self._load_supported_speakers()
|
||||
self.supported_speakers |= set(self.precomputed_speakers)
|
||||
|
||||
self.supported_languages = self._load_supported_languages()
|
||||
|
||||
self._tts_tokenizer = None
|
||||
self._voxcpm2_tokenizer = None
|
||||
self._audex_tokenizer = None
|
||||
self._audex_tta_rvq = None
|
||||
self._voxcpm2_split_map: dict[int, list[int]] = {}
|
||||
|
||||
if self.supported_speakers:
|
||||
logger.info(
|
||||
"Loaded %d supported speakers: %s", len(self.supported_speakers), sorted(self.supported_speakers)
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"No built-in speakers configured; only '%s' and uploaded voices are available", _DEFAULT_VOICE_NAME
|
||||
)
|
||||
|
||||
# Batch configuration
|
||||
self._batch_max_items: int = getattr(self.engine_client, "tts_batch_max_items", 32)
|
||||
|
||||
# Load speech tokenizer codec parameters for prompt length estimation
|
||||
self._codec_frame_rate: float | None = self._load_codec_frame_rate()
|
||||
|
||||
# Shared thread pool executor for blocking TTS preprocessing
|
||||
# operations. max_workers=1 serializes tokenizer access to avoid
|
||||
# Rust RefCell "Already borrowed" errors from concurrent use.
|
||||
@@ -494,18 +469,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
self._build_fish_speech_prompt_async = make_async(self._build_fish_speech_prompt, executor=self._tts_executor)
|
||||
self._estimate_prompt_len_async = make_async(self._estimate_prompt_len, executor=self._tts_executor)
|
||||
|
||||
# Resolve the per-model serving adapter (RFC #4327), keyed on the
|
||||
# detected model-type. Every dedicated TTS model has an adapter; the
|
||||
# adapter owns request validation, prompt/param building, and sampling
|
||||
# overrides; the model-type label remains available for compatibility.
|
||||
self._adapter = None
|
||||
if self._tts_stage is not None:
|
||||
adapter_cls = resolve_adapter(self._tts_model_type)
|
||||
if adapter_cls is not None:
|
||||
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
|
||||
self._adapter = adapter_cls(ctx)
|
||||
logger.info("Resolved TTS serving adapter: %s", adapter_cls.__name__)
|
||||
|
||||
def _get_tts_adapter(self):
|
||||
"""Return the per-model serving adapter for the current ``_tts_model_type``.
|
||||
|
||||
@@ -527,145 +490,44 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if self._adapter is None or type(self._adapter) is not adapter_cls:
|
||||
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
|
||||
self._adapter = adapter_cls(ctx)
|
||||
self._adapter.load_capabilities()
|
||||
return self._adapter
|
||||
|
||||
def _uses_native_speed_control(self) -> bool:
|
||||
adapter = self._get_tts_adapter()
|
||||
return bool(adapter is not None and adapter.native_speed_control)
|
||||
|
||||
def _get_available_speakers(self) -> set[str]:
|
||||
"""Return all built-in, precomputed, and runtime-uploaded speakers."""
|
||||
available_speakers = set(self.uploaded_speakers)
|
||||
if self._adapter is not None:
|
||||
available_speakers.update(self._adapter.capabilities.supported_speakers)
|
||||
available_speakers.update(self._adapter.capabilities.precomputed_speakers)
|
||||
return available_speakers
|
||||
|
||||
def _audio_encode_speed(self, request: OpenAICreateSpeechRequest) -> float:
|
||||
if self._uses_native_speed_control():
|
||||
return 1.0
|
||||
return float(request.speed or 1.0)
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Run a synthetic speech request to trigger all first-request warmup.
|
||||
"""Run model-specific startup warmup through the resolved adapter.
|
||||
|
||||
Unlike qwen3-tts, whose CUDA Graph warmup targets a standalone tokenizer
|
||||
decoder (no vLLM dependencies) and can complete entirely at model-init
|
||||
time, VoxCPM2 needs to warm up PagedAttention scaffold/residual LLMs.
|
||||
Their CUDA Graph capture requires a vLLM ``ForwardContext``
|
||||
(attn_metadata, slot_mapping, etc.) that only exists during real
|
||||
inference steps. The same request also pays the one-time torch.compile
|
||||
JIT tax for the LocDiT estimator, feat_encoder, AudioVAE decoder, and
|
||||
projection helpers.
|
||||
|
||||
For VoxCPM2 this shifts ~15s of torch.compile + CUDA Graph capture from
|
||||
the first user request to server startup.
|
||||
Warmup requirements differ by model. For example, Qwen3-TTS can warm up
|
||||
its standalone tokenizer decoder during model initialization, while
|
||||
VoxCPM2 requires a real inference request with an active vLLM
|
||||
``ForwardContext``. The adapter owns that model-specific lifecycle logic.
|
||||
"""
|
||||
if self._tts_model_type != "voxcpm2":
|
||||
if self._adapter is None:
|
||||
return
|
||||
|
||||
t0 = time.time()
|
||||
logger.info("Running warmup speech request for model_type=%s", self._tts_model_type)
|
||||
# VoxCPM2 has no predefined speaker presets — "default" means zero-shot
|
||||
# mode (no voice cloning). The voice field is required by the OpenAI
|
||||
# API schema but semantically ignored by the model.
|
||||
warmup_req = OpenAICreateSpeechRequest(
|
||||
input="Warmup.",
|
||||
voice="default",
|
||||
response_format="wav",
|
||||
speed=1.0,
|
||||
stream=False,
|
||||
model=self.model_name,
|
||||
)
|
||||
try:
|
||||
_audio_bytes, _media_type = await self._generate_audio_bytes(warmup_req, request_id="speech-warmup")
|
||||
except Exception as exc:
|
||||
logger.warning("Speech warmup failed (non-fatal): %s", exc)
|
||||
return
|
||||
|
||||
elapsed = time.time() - t0
|
||||
logger.info("Speech warmup complete in %.1fs", elapsed)
|
||||
|
||||
def _get_qwen_tts_expected_speaker_embedding_dim(self) -> int | None:
|
||||
"""Return the loaded Qwen3-TTS speaker embedding dim, if known.
|
||||
|
||||
The user-provided speaker embedding is concatenated directly with
|
||||
talker codec embeddings, so the real compatibility requirement is the
|
||||
talker hidden size.
|
||||
"""
|
||||
if self._tts_model_type != "qwen3_tts":
|
||||
return None
|
||||
hf_config = self.engine_client.model_config.hf_config
|
||||
talker_config = hf_config.talker_config
|
||||
return int(talker_config.hidden_size)
|
||||
|
||||
def _validate_qwen_tts_speaker_embedding_dim(self, emb_dim: int) -> str | None:
|
||||
expected_dim = self._get_qwen_tts_expected_speaker_embedding_dim()
|
||||
if expected_dim is None:
|
||||
return None
|
||||
if emb_dim != expected_dim:
|
||||
return f"speaker_embedding has {emb_dim} dimensions; expected {expected_dim} for the loaded Qwen3-TTS model"
|
||||
return None
|
||||
|
||||
def _load_codec_frame_rate(self) -> float | None:
|
||||
"""Load codec frame rate from speech tokenizer config for prompt length estimation."""
|
||||
if self._tts_model_type == "ming_tts":
|
||||
try:
|
||||
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import MingTTSConfig
|
||||
|
||||
hf_config = self.engine_client.model_config.hf_config
|
||||
ming_cfg = MingTTSConfig.from_hf_config(hf_config)
|
||||
patch_size = int(ming_cfg.patch_size)
|
||||
audio_frame_hop = int(ming_cfg.audio_frame_hop)
|
||||
sample_rate = int(ming_cfg.sample_rate)
|
||||
if patch_size <= 0 or audio_frame_hop <= 0 or sample_rate <= 0:
|
||||
raise ValueError(
|
||||
"Ming config has invalid tokenizer timing values: "
|
||||
f"patch_size={patch_size}, audio_frame_hop={audio_frame_hop}, sample_rate={sample_rate}"
|
||||
)
|
||||
rate = float(sample_rate) / float(audio_frame_hop * patch_size)
|
||||
logger.info(
|
||||
"Derived Ming codec frame rate: %.1f Hz (sample_rate=%s, audio_frame_hop=%s, patch_size=%s)",
|
||||
rate,
|
||||
sample_rate,
|
||||
audio_frame_hop,
|
||||
patch_size,
|
||||
)
|
||||
return rate
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to derive Ming codec frame rate from hf_config: {e}")
|
||||
|
||||
try:
|
||||
model_path = self.engine_client.model_config.model
|
||||
st_config_path = os.path.join(model_path, "speech_tokenizer", "config.json")
|
||||
if not os.path.exists(st_config_path):
|
||||
st_config_path = cached_file(model_path, "speech_tokenizer/config.json")
|
||||
if st_config_path is not None and os.path.exists(st_config_path):
|
||||
with open(st_config_path) as f:
|
||||
st_config = json.load(f)
|
||||
output_sr = st_config.get("output_sample_rate")
|
||||
downsample = st_config.get("encode_downsample_rate")
|
||||
if output_sr and downsample and downsample > 0:
|
||||
rate = float(output_sr) / float(downsample)
|
||||
logger.info(
|
||||
"Loaded codec frame rate: %.1f Hz (output_sample_rate=%s, encode_downsample_rate=%s)",
|
||||
rate,
|
||||
output_sr,
|
||||
downsample,
|
||||
)
|
||||
return rate
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load codec frame rate from speech tokenizer config: %s", e)
|
||||
|
||||
# Fallback: try codec_frame_rate_hz from hf_config
|
||||
try:
|
||||
hf_config = self.engine_client.model_config.hf_config
|
||||
rate = getattr(hf_config, "codec_frame_rate_hz", None)
|
||||
if rate is not None:
|
||||
logger.info("Using codec frame rate from hf_config: %s Hz", rate)
|
||||
return float(rate)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
await self._adapter.warmup()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the TTS thread pool executor."""
|
||||
if self._tts_executor is not None:
|
||||
self._tts_executor.shutdown(wait=False, cancel_futures=True)
|
||||
self._tts_executor = None
|
||||
for name in list(self.uploaded_speakers.keys()):
|
||||
for name in list(self.uploaded_speakers):
|
||||
self._speaker_cache.clear(name)
|
||||
|
||||
def _find_tts_stage(self):
|
||||
@@ -708,45 +570,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
getattr(self._tts_stage.engine_args, "model_arch", None),
|
||||
)
|
||||
|
||||
def _get_custom_voice_dir(self) -> str | None:
|
||||
try:
|
||||
value = getattr(self.engine_client.model_config.hf_config, "custom_voice_dir", None)
|
||||
except AttributeError:
|
||||
return None
|
||||
if isinstance(value, os.PathLike):
|
||||
return os.fspath(value)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _load_precomputed_speakers(self) -> dict[str, dict[str, Any]]:
|
||||
"""Load precomputed voice names from ``custom_voice_dir`` for API validation."""
|
||||
if self._tts_model_type not in ("qwen3_tts", "voxcpm2"):
|
||||
return {}
|
||||
custom_voice_dir = self._get_custom_voice_dir()
|
||||
if not custom_voice_dir:
|
||||
return {}
|
||||
|
||||
profiles: dict[str, dict[str, Any]] = {}
|
||||
qwen3_embedding_dim = self._get_qwen_tts_expected_speaker_embedding_dim()
|
||||
for profile in iter_custom_voice_profiles(custom_voice_dir, expected_model_type=self._tts_model_type):
|
||||
tensors = load_validated_profile_tensors(
|
||||
profile,
|
||||
expected_model_type=self._tts_model_type,
|
||||
qwen3_embedding_dim=qwen3_embedding_dim,
|
||||
)
|
||||
if tensors is None:
|
||||
continue
|
||||
profiles[profile["voice_name_lower"]] = profile
|
||||
if profiles:
|
||||
logger.info(
|
||||
"Loaded %d precomputed %s voice profile(s) from %s",
|
||||
len(profiles),
|
||||
self._tts_model_type,
|
||||
custom_voice_dir,
|
||||
)
|
||||
return profiles
|
||||
|
||||
def _compute_max_instructions_length(self) -> int:
|
||||
"""Compute max instructions length with precedence: CLI > stage config > default.
|
||||
|
||||
@@ -768,64 +591,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
def _get_available_voices(self) -> set[str]:
|
||||
"""Get all voice names accepted by the API, including the placeholder default."""
|
||||
return self.supported_speakers | self.uploaded_speakers.keys() | {_DEFAULT_VOICE_NAME}
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
"""Load supported speakers (case-insensitive) from the model configuration."""
|
||||
if self._tts_model_type == "ming_flash_omni_tts":
|
||||
# Ming-flash-omni drives speaker selection via the caption JSON
|
||||
# (audio_sequence[0]["说话人"]) rather than a spk_id table, so there
|
||||
# is no static speaker list to surface here.
|
||||
return set()
|
||||
try:
|
||||
if self._tts_model_type == "glm_tts":
|
||||
return set()
|
||||
if self._tts_model_type == "ming_tts":
|
||||
return set()
|
||||
if self._tts_model_type == "voxcpm2":
|
||||
return {"default"}
|
||||
if self._tts_model_type == "voxtral_tts":
|
||||
config = self.engine_client.model_config.hf_config.audio_config
|
||||
else:
|
||||
# Default is qwen3_tts path
|
||||
config = getattr(self.engine_client.model_config.hf_config, "talker_config", None)
|
||||
if config is None:
|
||||
return set()
|
||||
|
||||
# Check for speakers in either spk_id or speaker_id
|
||||
for attr_name in ["spk_id", "speaker_id"]:
|
||||
if isinstance(config, dict):
|
||||
speakers_dict = config.get(attr_name)
|
||||
else:
|
||||
speakers_dict = getattr(config, attr_name, None)
|
||||
if speakers_dict and isinstance(speakers_dict, dict):
|
||||
return {speaker.lower() for speaker in speakers_dict.keys()}
|
||||
|
||||
logger.warning("No speakers found in config (checked spk_id and speaker_id)")
|
||||
except Exception as e:
|
||||
logger.warning("Could not load speakers from model config: %s", e)
|
||||
|
||||
return set()
|
||||
|
||||
def _load_supported_languages(self) -> frozenset[str]:
|
||||
"""Load supported languages (title-cased) from the model configuration"""
|
||||
if self._tts_model_type != "qwen3_tts":
|
||||
return _TTS_LANGUAGES
|
||||
try:
|
||||
config = self.engine_client.model_config.hf_config.talker_config
|
||||
|
||||
if isinstance(config, dict):
|
||||
codec_language_id = config.get("codec_language_id")
|
||||
else:
|
||||
codec_language_id = getattr(config, "codec_language_id", None)
|
||||
|
||||
if codec_language_id and isinstance(codec_language_id, Mapping):
|
||||
return frozenset(str(language).title() for language in codec_language_id) | {"Auto"}
|
||||
|
||||
logger.warning("No codec_language_id found in talker_config; falling back to default languages")
|
||||
except Exception as e:
|
||||
logger.warning("Could not load languages from model config: %s", e)
|
||||
return _TTS_LANGUAGES
|
||||
return self._get_available_speakers() | {_DEFAULT_VOICE_NAME}
|
||||
|
||||
def _estimate_ref_code_len(self, ref_audio: object) -> int | None:
|
||||
"""Estimate ref_code length from ref_audio waveform without running the codec.
|
||||
@@ -833,7 +599,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
The codec produces one frame per (output_sample_rate / encode_downsample_rate)
|
||||
audio samples, so ref_code_len = ceil(duration_seconds * codec_frame_rate).
|
||||
"""
|
||||
if self._codec_frame_rate is None:
|
||||
codec_frame_rate = self._adapter.capabilities.codec_frame_rate
|
||||
if codec_frame_rate is None:
|
||||
return None
|
||||
try:
|
||||
# ref_audio comes from tts_params as [[wav_array, sr]] or similar nested structure
|
||||
@@ -858,7 +625,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if sr <= 0 or n_samples <= 0:
|
||||
return None
|
||||
duration = n_samples / sr
|
||||
return math.ceil(duration * self._codec_frame_rate)
|
||||
return math.ceil(duration * codec_frame_rate)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1041,7 +808,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
wav_np, ref_sr = uploaded_ref
|
||||
ref_audio = wav_np.tolist()
|
||||
elif request.voice is not None:
|
||||
voice_profile = self.precomputed_speakers.get(request.voice.lower())
|
||||
voice_profile = self._adapter.capabilities.precomputed_speakers.get(request.voice.lower())
|
||||
return build_voxcpm2_prompt(
|
||||
hf_config=self.engine_client.model_config.hf_config,
|
||||
tokenizer=self._voxcpm2_tokenizer,
|
||||
@@ -1204,7 +971,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if voice_name_lower not in self.uploaded_speakers:
|
||||
return
|
||||
old = self.uploaded_speakers.pop(voice_name_lower)
|
||||
self.supported_speakers.discard(voice_name_lower)
|
||||
self._ref_audio_data_url_cache.pop(voice_name_lower, None)
|
||||
old_path = old.get("file_path")
|
||||
if old_path:
|
||||
@@ -1346,7 +1112,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
raise ValueError(f"Failed to save voice file: {e}")
|
||||
|
||||
self.uploaded_speakers[voice_name_lower] = speaker_data
|
||||
self.supported_speakers.add(voice_name_lower)
|
||||
|
||||
logger.info("Uploaded new voice '%s' with consent ID '%s'", name, consent)
|
||||
|
||||
@@ -1397,13 +1162,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)")
|
||||
|
||||
emb_dim = len(embedding)
|
||||
if self._tts_model_type == "ming_tts":
|
||||
if emb_dim != SPEAKER_EMBEDDING_DIM:
|
||||
raise ValueError(f"Ming speaker embedding must have {SPEAKER_EMBEDDING_DIM} dims, got {emb_dim}")
|
||||
else:
|
||||
dim_err = self._validate_qwen_tts_speaker_embedding_dim(emb_dim)
|
||||
if dim_err is not None:
|
||||
raise ValueError(dim_err)
|
||||
dim_err = self._adapter.validate_tts_embedding_dim(emb_dim) if self._adapter is not None else None
|
||||
if dim_err is not None:
|
||||
raise ValueError(dim_err)
|
||||
|
||||
async with self._upload_lock:
|
||||
voice_name_lower = name.lower()
|
||||
@@ -1443,7 +1204,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
speaker_data["file_size"] = file_path.stat().st_size
|
||||
|
||||
self.uploaded_speakers[voice_name_lower] = speaker_data
|
||||
self.supported_speakers.add(voice_name_lower)
|
||||
|
||||
logger.info("Uploaded voice '%s' from speaker embedding (%d-dim)", name, emb_dim)
|
||||
|
||||
@@ -1473,7 +1233,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return False
|
||||
|
||||
speaker_info = self.uploaded_speakers.pop(voice_name_lower)
|
||||
self.supported_speakers.discard(voice_name_lower)
|
||||
self._ref_audio_data_url_cache.pop(voice_name_lower, None)
|
||||
|
||||
file_path = speaker_info.get("file_path")
|
||||
@@ -2507,6 +2266,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
# Speaker (voice)
|
||||
if request.voice is not None:
|
||||
voice_lower = request.voice.lower()
|
||||
precomputed_speakers = self._adapter.capabilities.precomputed_speakers if self._adapter is not None else {}
|
||||
params["speaker"] = [request.voice]
|
||||
params["voice_created_at"] = [self._voice_created_at(voice_lower)]
|
||||
|
||||
@@ -2540,8 +2300,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
logger.info(
|
||||
"Auto-set ref_audio for uploaded voice: %s (icl=%s)", request.voice, bool(stored_ref_text)
|
||||
)
|
||||
elif voice_lower in self.precomputed_speakers and request.ref_audio is None:
|
||||
profile = self.precomputed_speakers[voice_lower]
|
||||
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"]
|
||||
@@ -3349,7 +3109,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
"""
|
||||
if voice is not None:
|
||||
voice = voice.lower()
|
||||
if voice not in self.uploaded_speakers and voice not in self.supported_speakers:
|
||||
available_speakers = self._get_available_speakers()
|
||||
if voice not in available_speakers:
|
||||
raise ValueError(
|
||||
f"Invalid voice '{voice}'. Supported: {', '.join(sorted(self._get_available_voices()))}"
|
||||
)
|
||||
@@ -3534,7 +3295,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
decoded. Raw WAV streaming emits a header with placeholder size values first.
|
||||
"""
|
||||
if request.voice is not None:
|
||||
if _is_default_voice(request.voice.lower(), self.supported_speakers):
|
||||
if _is_default_voice(request.voice.lower(), self._get_available_speakers()):
|
||||
request.voice = None
|
||||
|
||||
if self._diffusion_mode:
|
||||
|
||||
@@ -15,9 +15,26 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_codec_frame_rate, load_supported_speakers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
DEFAULT_TTS_LANGUAGES = frozenset(
|
||||
{
|
||||
"Auto",
|
||||
"Chinese",
|
||||
"English",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"German",
|
||||
"French",
|
||||
"Russian",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Italian",
|
||||
}
|
||||
)
|
||||
|
||||
_conditioning_cache_salt_fn: "Callable[..., str] | None" = None
|
||||
|
||||
@@ -101,6 +118,14 @@ class SpeechServingContext:
|
||||
diffusion_engine: Any | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TTSCapabilities:
|
||||
precomputed_speakers: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
supported_speakers: frozenset[str] = frozenset()
|
||||
supported_languages: frozenset[str] = DEFAULT_TTS_LANGUAGES
|
||||
codec_frame_rate: float | None = None
|
||||
|
||||
|
||||
class TTSModelAdapter(ABC):
|
||||
"""Mandatory base class for a TTS model served via ``/v1/audio/speech``.
|
||||
|
||||
@@ -137,6 +162,7 @@ class TTSModelAdapter(ABC):
|
||||
|
||||
def __init__(self, ctx: SpeechServingContext) -> None:
|
||||
self.ctx = ctx
|
||||
self.capabilities = TTSCapabilities()
|
||||
|
||||
@classmethod
|
||||
def matches(cls, model_stage: str | None, model_arch: str | None) -> bool:
|
||||
@@ -207,6 +233,34 @@ class TTSModelAdapter(ABC):
|
||||
"""
|
||||
return sampling_params_list
|
||||
|
||||
async def warmup(self) -> None:
|
||||
return
|
||||
|
||||
def validate_tts_embedding_dim(self, emb_dim: int) -> str | None:
|
||||
return None
|
||||
|
||||
def load_capabilities(self) -> TTSCapabilities:
|
||||
self.capabilities = TTSCapabilities(
|
||||
precomputed_speakers=self._load_precomputed_speakers(),
|
||||
supported_speakers=frozenset(self._load_supported_speakers()),
|
||||
supported_languages=self._load_supported_languages(),
|
||||
codec_frame_rate=self._load_codec_frame_rate(),
|
||||
)
|
||||
return self.capabilities
|
||||
|
||||
def _load_precomputed_speakers(self) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
# Preserve the legacy default path, which reads talker_config.
|
||||
return load_supported_speakers(self.ctx.engine_client)
|
||||
|
||||
def _load_supported_languages(self) -> frozenset[str]:
|
||||
return DEFAULT_TTS_LANGUAGES
|
||||
|
||||
def _load_codec_frame_rate(self) -> float | None:
|
||||
return load_codec_frame_rate(self.ctx.engine_client)
|
||||
|
||||
|
||||
class ARTTSAdapter(TTSModelAdapter):
|
||||
"""Adapter for models served through the AR ``engine_client`` path.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Shared loading mechanisms for TTS model capability metadata."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from transformers.utils.hub import cached_file
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.utils.speaker_cache import iter_custom_voice_profiles, load_validated_profile_tensors
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def load_supported_speakers(engine_client: Any, config: Any = None) -> set[str]:
|
||||
"""Extract supported speaker names from a model configuration."""
|
||||
try:
|
||||
if config is None:
|
||||
config = getattr(engine_client.model_config.hf_config, "talker_config", None)
|
||||
if config is None:
|
||||
return set()
|
||||
|
||||
for attr_name in ("spk_id", "speaker_id"):
|
||||
speakers = config.get(attr_name) if isinstance(config, dict) else getattr(config, attr_name, None)
|
||||
if speakers and isinstance(speakers, dict):
|
||||
return {speaker.lower() for speaker in speakers}
|
||||
|
||||
logger.warning("No speakers found in config (checked spk_id and speaker_id)")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load speakers from model config: %s", exc)
|
||||
return set()
|
||||
|
||||
|
||||
def load_codec_frame_rate(engine_client: Any) -> float | None:
|
||||
"""Load codec frame rate from speech-tokenizer or HF configuration."""
|
||||
try:
|
||||
model_path = engine_client.model_config.model
|
||||
config_path = os.path.join(model_path, "speech_tokenizer", "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
config_path = cached_file(model_path, "speech_tokenizer/config.json")
|
||||
if config_path is not None and os.path.exists(config_path):
|
||||
with open(config_path) as file:
|
||||
config = json.load(file)
|
||||
output_sr = config.get("output_sample_rate")
|
||||
downsample = config.get("encode_downsample_rate")
|
||||
if output_sr and downsample and downsample > 0:
|
||||
rate = float(output_sr) / float(downsample)
|
||||
logger.info(
|
||||
"Loaded codec frame rate: %.1f Hz (output_sample_rate=%s, encode_downsample_rate=%s)",
|
||||
rate,
|
||||
output_sr,
|
||||
downsample,
|
||||
)
|
||||
return rate
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load codec frame rate from speech tokenizer config: %s", exc)
|
||||
|
||||
try:
|
||||
rate = getattr(engine_client.model_config.hf_config, "codec_frame_rate_hz", None)
|
||||
if rate is not None:
|
||||
logger.info("Using codec frame rate from hf_config: %s Hz", rate)
|
||||
return float(rate)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def load_precomputed_speakers(
|
||||
engine_client: Any,
|
||||
*,
|
||||
expected_model_type: str,
|
||||
validate_profile: Callable[[dict[str, Any], dict[str, torch.Tensor]], str | None],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Load and validate precomputed profiles from ``custom_voice_dir``."""
|
||||
try:
|
||||
custom_voice_dir = getattr(engine_client.model_config.hf_config, "custom_voice_dir", None)
|
||||
except AttributeError:
|
||||
return {}
|
||||
if isinstance(custom_voice_dir, os.PathLike):
|
||||
custom_voice_dir = os.fspath(custom_voice_dir)
|
||||
if not isinstance(custom_voice_dir, str) or not custom_voice_dir:
|
||||
return {}
|
||||
|
||||
profiles: dict[str, dict[str, Any]] = {}
|
||||
for profile in iter_custom_voice_profiles(custom_voice_dir, expected_model_type=expected_model_type):
|
||||
tensors = load_validated_profile_tensors(
|
||||
profile,
|
||||
expected_model_type=expected_model_type,
|
||||
validate_profile=validate_profile,
|
||||
)
|
||||
if tensors is not None:
|
||||
profiles[profile["voice_name_lower"]] = profile
|
||||
if profiles:
|
||||
logger.info(
|
||||
"Loaded %d precomputed %s voice profile(s) from %s",
|
||||
len(profiles),
|
||||
expected_model_type,
|
||||
Path(custom_voice_dir).expanduser(),
|
||||
)
|
||||
return profiles
|
||||
@@ -49,6 +49,9 @@ class GlmTTSAdapter(ARTTSAdapter):
|
||||
prompt = await self.ctx.server._build_glm_tts_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]:
|
||||
return set()
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
|
||||
@@ -16,6 +16,9 @@ logger = init_logger(__name__)
|
||||
|
||||
@register_tts_adapter
|
||||
class MingFlashOmniTTSAdapter(ARTTSAdapter):
|
||||
# Ming-flash-omni drives speaker selection via the caption JSON
|
||||
# (audio_sequence[0]["说话人"]) rather than a spk_id table, so there
|
||||
# is no static speaker list to surface here.
|
||||
stage_keys = frozenset({"ming_tts"})
|
||||
name = "ming_flash_omni_tts"
|
||||
|
||||
@@ -61,3 +64,7 @@ class MingFlashOmniTTSAdapter(ARTTSAdapter):
|
||||
server = self.ctx.server
|
||||
prompt = server._build_ming_flash_omni_prompt(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="ming_flash_omni_tts")
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
# Speaker selection is driven by caption JSON rather than a static table.
|
||||
return set()
|
||||
@@ -184,3 +184,44 @@ class MingTTSAdapter(ARTTSAdapter):
|
||||
return f"max_new_tokens cannot exceed {self.max_new_tokens_max}"
|
||||
|
||||
return None
|
||||
|
||||
def validate_tts_embedding_dim(self, emb_dim: int) -> str | None:
|
||||
if emb_dim != SPEAKER_EMBEDDING_DIM:
|
||||
return f"Ming speaker embedding must have {SPEAKER_EMBEDDING_DIM} dims, got {emb_dim}"
|
||||
return None
|
||||
|
||||
def _load_ming_tts_codec_frame_rate(self) -> float | None:
|
||||
server = self.ctx.server
|
||||
try:
|
||||
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import MingTTSConfig
|
||||
|
||||
hf_config = server.engine_client.model_config.hf_config
|
||||
ming_cfg = MingTTSConfig.from_hf_config(hf_config)
|
||||
patch_size = int(ming_cfg.patch_size)
|
||||
audio_frame_hop = int(ming_cfg.audio_frame_hop)
|
||||
sample_rate = int(ming_cfg.sample_rate)
|
||||
if patch_size <= 0 or audio_frame_hop <= 0 or sample_rate <= 0:
|
||||
raise ValueError(
|
||||
"Ming config has invalid tokenizer timing values: "
|
||||
f"patch_size={patch_size}, audio_frame_hop={audio_frame_hop}, sample_rate={sample_rate}"
|
||||
)
|
||||
rate = float(sample_rate) / float(audio_frame_hop * patch_size)
|
||||
logger.info(
|
||||
"Derived Ming codec frame rate: %.1f Hz (sample_rate=%s, audio_frame_hop=%s, patch_size=%s)",
|
||||
rate,
|
||||
sample_rate,
|
||||
audio_frame_hop,
|
||||
patch_size,
|
||||
)
|
||||
return rate
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to derive Ming codec frame rate from hf_config: {e}")
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def _load_codec_frame_rate(self) -> float | None:
|
||||
codec_frame_rate = self._load_ming_tts_codec_frame_rate()
|
||||
if codec_frame_rate is None:
|
||||
codec_frame_rate = super()._load_codec_frame_rate()
|
||||
return codec_frame_rate
|
||||
@@ -1,13 +1,21 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3-TTS serving adapter."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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, apply_max_new_tokens
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import (
|
||||
DEFAULT_TTS_LANGUAGES,
|
||||
ARTTSAdapter,
|
||||
PreparedRequest,
|
||||
apply_max_new_tokens,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_precomputed_speakers
|
||||
from vllm_omni.utils.speaker_cache import validate_qwen3_tts_profile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
@@ -37,7 +45,7 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
# Normalize voice to lowercase for case-insensitive matching
|
||||
if request.voice is not None:
|
||||
request.voice = request.voice.lower()
|
||||
if request.task_type is None and request.voice in server.precomputed_speakers:
|
||||
if request.task_type is None and request.voice in self.capabilities.precomputed_speakers:
|
||||
request.task_type = "Base"
|
||||
task_type = request.task_type or "CustomVoice"
|
||||
|
||||
@@ -48,21 +56,23 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
# Validate language (case-insensitive; normalized to the title-cased config form)
|
||||
if request.language is not None:
|
||||
request.language = request.language.title()
|
||||
if request.language not in server.supported_languages:
|
||||
if request.language not in self.capabilities.supported_languages:
|
||||
return (
|
||||
f"Invalid language '{request.language}'. Supported: {', '.join(sorted(server.supported_languages))}"
|
||||
f"Invalid language '{request.language}'. Supported: "
|
||||
f"{', '.join(sorted(self.capabilities.supported_languages))}"
|
||||
)
|
||||
|
||||
# Validate speaker for CustomVoice task
|
||||
if task_type == "CustomVoice":
|
||||
if not server.supported_speakers:
|
||||
available_speakers = server._get_available_speakers()
|
||||
if not available_speakers:
|
||||
return (
|
||||
"This model does not support CustomVoice task (no speakers configured). "
|
||||
"Use task_type='Base' with ref_audio/ref_text for voice cloning, "
|
||||
"or use a CustomVoice model."
|
||||
)
|
||||
if request.voice is not None and request.voice not in server.supported_speakers:
|
||||
return f"Invalid voice '{request.voice}'. Supported: {', '.join(sorted(server.supported_speakers))}"
|
||||
if request.voice is not None and request.voice not in available_speakers:
|
||||
return f"Invalid voice '{request.voice}'. Supported: {', '.join(sorted(available_speakers))}"
|
||||
|
||||
# Validate speaker_embedding constraints
|
||||
if request.speaker_embedding is not None:
|
||||
@@ -74,7 +84,7 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
# Base task validation so callers don't need to pass it explicitly.
|
||||
request.x_vector_only_mode = True
|
||||
emb_len = len(request.speaker_embedding)
|
||||
dim_err = server._validate_qwen_tts_speaker_embedding_dim(emb_len)
|
||||
dim_err = self.validate_tts_embedding_dim(emb_len)
|
||||
if dim_err is not None:
|
||||
return dim_err
|
||||
# Validate Base task requirements
|
||||
@@ -103,8 +113,8 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
file_path = Path(speaker_info["file_path"])
|
||||
if not file_path.exists():
|
||||
return f"Data file for uploaded speaker '{request.voice}' not found on disk"
|
||||
elif voice_lower in server.precomputed_speakers:
|
||||
profile = server.precomputed_speakers[voice_lower]
|
||||
elif voice_lower in self.capabilities.precomputed_speakers:
|
||||
profile = self.capabilities.precomputed_speakers[voice_lower]
|
||||
mode = str(profile.get("mode") or "xvec").lower()
|
||||
ref_text = request.ref_text or profile.get("ref_text")
|
||||
if mode == "icl" and (not isinstance(ref_text, str) or not ref_text.strip()):
|
||||
@@ -164,6 +174,51 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
warmup_artifact_key=warmup_key,
|
||||
)
|
||||
|
||||
def _get_expected_speaker_embedding_dim(self) -> int:
|
||||
"""Return the loaded Qwen3-TTS speaker embedding dim, if known.
|
||||
|
||||
The user-provided speaker embedding is concatenated directly with
|
||||
talker codec embeddings, so the real compatibility requirement is the
|
||||
talker hidden size.
|
||||
"""
|
||||
hf_config = self.ctx.engine_client.model_config.hf_config
|
||||
talker_config = hf_config.talker_config
|
||||
return int(talker_config.hidden_size)
|
||||
|
||||
def _load_precomputed_speakers(self) -> dict[str, dict]:
|
||||
return load_precomputed_speakers(
|
||||
self.ctx.engine_client,
|
||||
expected_model_type=self.name,
|
||||
validate_profile=lambda profile, tensors: validate_qwen3_tts_profile(
|
||||
profile,
|
||||
tensors,
|
||||
expected_embedding_dim=self._get_expected_speaker_embedding_dim(),
|
||||
),
|
||||
)
|
||||
|
||||
def _load_supported_languages(self) -> frozenset[str]:
|
||||
try:
|
||||
config = self.ctx.engine_client.model_config.hf_config.talker_config
|
||||
|
||||
if isinstance(config, dict):
|
||||
codec_language_id = config.get("codec_language_id")
|
||||
else:
|
||||
codec_language_id = getattr(config, "codec_language_id", None)
|
||||
|
||||
if codec_language_id and isinstance(codec_language_id, Mapping):
|
||||
return frozenset(str(language).title() for language in codec_language_id) | {"Auto"}
|
||||
|
||||
logger.warning("No codec_language_id found in talker_config; falling back to default languages")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load languages from model config: %s", exc)
|
||||
return DEFAULT_TTS_LANGUAGES
|
||||
|
||||
def validate_tts_embedding_dim(self, emb_dim: int) -> str | None:
|
||||
expected_dim = self._get_expected_speaker_embedding_dim()
|
||||
if emb_dim != expected_dim:
|
||||
return f"speaker_embedding has {emb_dim} dimensions; expected {expected_dim} for the loaded Qwen3-TTS model"
|
||||
return None
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""VoxCPM2 serving adapter (AR base-LM + diffusion side-computation)."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
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_precomputed_speakers
|
||||
from vllm_omni.utils.speaker_cache import validate_voxcpm2_profile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
@@ -31,7 +36,7 @@ class VoxCPM2Adapter(ARTTSAdapter):
|
||||
|
||||
if request.voice is not None:
|
||||
request.voice = request.voice.lower()
|
||||
available_voices = set(server.uploaded_speakers) | set(server.precomputed_speakers) | {"default"}
|
||||
available_voices = server._get_available_speakers()
|
||||
if request.voice not in available_voices:
|
||||
supported = ", ".join(sorted(available_voices)) or "none"
|
||||
return f"Invalid voice '{request.voice}'. Supported: {supported}"
|
||||
@@ -65,12 +70,54 @@ class VoxCPM2Adapter(ARTTSAdapter):
|
||||
tts_params = {}
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers or voice_lower in server.precomputed_speakers:
|
||||
if voice_lower in server.uploaded_speakers or voice_lower in self.capabilities.precomputed_speakers:
|
||||
additional = prompt.setdefault("additional_information", {})
|
||||
additional["voice_name"] = voice_lower
|
||||
additional["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="voxcpm2")
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Warm up VoxCPM2 through a synthetic serving request.
|
||||
|
||||
VoxCPM2 needs to warm up its PagedAttention scaffold/residual LLMs. CUDA
|
||||
Graph capture requires a vLLM ``ForwardContext`` containing attention
|
||||
metadata and slot mappings, which is only available during inference. The
|
||||
request also pays the one-time torch.compile cost for LocDiT, feat_encoder,
|
||||
AudioVAE, and projection helpers.
|
||||
"""
|
||||
server = self.ctx.server
|
||||
t0 = time.time()
|
||||
logger.info("Running warmup speech request for model_type=%s", self.name)
|
||||
# VoxCPM2 has no predefined speaker presets — "default" means zero-shot
|
||||
# mode (no voice cloning). The voice field is required by the OpenAI
|
||||
# API schema but semantically ignored by the model.
|
||||
warmup_req = OpenAICreateSpeechRequest(
|
||||
input="Warmup.",
|
||||
voice="default",
|
||||
response_format="wav",
|
||||
speed=1.0,
|
||||
stream=False,
|
||||
model=server.model_name,
|
||||
)
|
||||
try:
|
||||
_audio_bytes, _media_type = await server._generate_audio_bytes(warmup_req, request_id="speech-warmup")
|
||||
except Exception as exc:
|
||||
logger.warning("Speech warmup failed (non-fatal): %s", exc)
|
||||
return
|
||||
|
||||
elapsed = time.time() - t0
|
||||
logger.info("Speech warmup complete in %.1fs", elapsed)
|
||||
|
||||
def _load_precomputed_speakers(self) -> dict[str, dict]:
|
||||
return load_precomputed_speakers(
|
||||
self.ctx.engine_client,
|
||||
expected_model_type=self.name,
|
||||
validate_profile=validate_voxcpm2_profile,
|
||||
)
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
return {"default"}
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
@@ -32,8 +33,9 @@ class VoxtralTTSAdapter(ARTTSAdapter):
|
||||
|
||||
if request.voice is not None:
|
||||
request.voice = request.voice.lower()
|
||||
if server.supported_speakers and request.voice not in server.supported_speakers:
|
||||
return f"Invalid speaker '{request.voice}'. Supported: {', '.join(sorted(server.supported_speakers))}"
|
||||
available_speakers = server._get_available_speakers()
|
||||
if available_speakers and request.voice not in available_speakers:
|
||||
return f"Invalid speaker '{request.voice}'. Supported: {', '.join(sorted(available_speakers))}"
|
||||
|
||||
if request.max_new_tokens is not None:
|
||||
if request.max_new_tokens < self.max_new_tokens_min:
|
||||
@@ -49,6 +51,10 @@ class VoxtralTTSAdapter(ARTTSAdapter):
|
||||
prompt = await self.ctx.server._build_voxtral_prompt_async(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="voxtral_tts")
|
||||
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
config = self.ctx.engine_client.model_config.hf_config.audio_config
|
||||
return load_supported_speakers(self.ctx.engine_client, config)
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
|
||||
@@ -27,6 +27,7 @@ from vllm_omni.utils.speaker_cache import (
|
||||
get_speaker_cache,
|
||||
iter_custom_voice_profiles,
|
||||
load_validated_profile_tensors,
|
||||
validate_qwen3_tts_profile,
|
||||
)
|
||||
|
||||
from .configuration_qwen3_tts import Qwen3TTSConfig, Qwen3TTSSpeakerEncoderConfig, Qwen3TTSTalkerConfig
|
||||
@@ -527,7 +528,11 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
tensors = load_validated_profile_tensors(
|
||||
profile,
|
||||
expected_model_type="qwen3_tts",
|
||||
qwen3_embedding_dim=expected_dim,
|
||||
validate_profile=lambda profile, tensors: validate_qwen3_tts_profile(
|
||||
profile,
|
||||
tensors,
|
||||
expected_embedding_dim=expected_dim,
|
||||
),
|
||||
)
|
||||
if tensors is None:
|
||||
continue
|
||||
|
||||
@@ -42,6 +42,7 @@ from vllm_omni.utils.speaker_cache import (
|
||||
get_speaker_cache,
|
||||
iter_custom_voice_profiles,
|
||||
load_validated_profile_tensors,
|
||||
validate_voxcpm2_profile,
|
||||
)
|
||||
from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest
|
||||
|
||||
@@ -963,7 +964,11 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
|
||||
|
||||
loaded = 0
|
||||
for profile in iter_custom_voice_profiles(custom_voice_dir, expected_model_type="voxcpm2"):
|
||||
tensors = load_validated_profile_tensors(profile, expected_model_type="voxcpm2")
|
||||
tensors = load_validated_profile_tensors(
|
||||
profile,
|
||||
expected_model_type="voxcpm2",
|
||||
validate_profile=validate_voxcpm2_profile,
|
||||
)
|
||||
if tensors is None:
|
||||
continue
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -131,7 +132,7 @@ def _first_dim(tensor: torch.Tensor | None) -> int:
|
||||
return int(tensor.shape[0])
|
||||
|
||||
|
||||
def _validate_qwen3_tts_profile(
|
||||
def validate_qwen3_tts_profile(
|
||||
profile: dict[str, Any],
|
||||
tensors: dict[str, torch.Tensor],
|
||||
*,
|
||||
@@ -164,7 +165,7 @@ def _validate_qwen3_tts_profile(
|
||||
return None
|
||||
|
||||
|
||||
def _validate_voxcpm2_profile(profile: dict[str, Any], tensors: dict[str, torch.Tensor]) -> str | None:
|
||||
def validate_voxcpm2_profile(profile: dict[str, Any], tensors: dict[str, torch.Tensor]) -> str | None:
|
||||
# Successful validation also normalizes/augments `profile` for serving metadata.
|
||||
ref_audio_feat = tensors.get("ref_audio_feat")
|
||||
audio_feat = tensors.get("audio_feat")
|
||||
@@ -207,7 +208,7 @@ def load_validated_profile_tensors(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
expected_model_type: str,
|
||||
qwen3_embedding_dim: int | None = None,
|
||||
validate_profile: Callable[[dict[str, Any], dict[str, torch.Tensor]], str | None],
|
||||
) -> dict[str, torch.Tensor] | None:
|
||||
tensors = _load_profile_tensors(profile)
|
||||
error = None
|
||||
@@ -215,12 +216,8 @@ def load_validated_profile_tensors(
|
||||
error = "safetensors file could not be loaded"
|
||||
elif str(profile.get("model_type") or expected_model_type or "") != expected_model_type:
|
||||
error = f"model_type={profile.get('model_type')!r}, expected={expected_model_type!r}"
|
||||
elif expected_model_type == "qwen3_tts":
|
||||
error = _validate_qwen3_tts_profile(profile, tensors, expected_embedding_dim=qwen3_embedding_dim)
|
||||
elif expected_model_type == "voxcpm2":
|
||||
error = _validate_voxcpm2_profile(profile, tensors)
|
||||
else:
|
||||
error = f"unsupported custom voice model_type={expected_model_type!r}"
|
||||
error = validate_profile(profile, tensors)
|
||||
|
||||
if error is not None:
|
||||
logger.warning("Skipping custom %s voice %s: %s", expected_model_type, profile.get("name"), error)
|
||||
|
||||
Reference in new issue
Block a user