[Feature] Add precomputed custom voices and Qwen3-TTS ref-context cache (#3492)

Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Dan250124 <416947747@qq.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-05-31 17:51:24 +00:00
1 parent 57e5f0fe69
commit d4c139508f
23 files changed
+2362 -145

No files matched your search

@@ -25,6 +25,7 @@ class _FakeDecoder(nn.Module):
self.total_upsample = total_upsample
self.decode_calls: list[dict[str, int]] = []
self.batched_decode_calls: list[dict[str, int]] = []
self.decode_codes: list[torch.Tensor] = []
self.cudagraph_calls: list[dict[str, int | torch.device]] = []
def to(self, *args, **kwargs):
@@ -37,6 +38,7 @@ class _FakeDecoder(nn.Module):
chunk_size: int = 300,
left_context_size: int = 25,
) -> torch.Tensor:
self.decode_codes.append(codes.detach().cpu().clone())
self.decode_calls.append(
{
"chunk_size": chunk_size,
@@ -181,6 +183,136 @@ def test_forward_trims_trailing_padding_without_context():
torch.testing.assert_close(audio, expected)
def test_forward_reuses_cached_ref_context_for_followup_chunk():
model = _make_model()
first_codes = torch.tensor(
[
9,
8,
1,
2,
9,
8,
3,
4,
],
dtype=torch.long,
)
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": True,
}
}
],
)
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
"finished": torch.tensor(True),
}
}
],
)
torch.testing.assert_close(
model.decoder.decode_codes[-1],
torch.tensor(
[
[
[9, 8, 5, 6, 7],
[9, 8, 15, 16, 17],
]
],
dtype=torch.long,
),
)
assert "rid" not in model._ref_context_cache
def test_forward_fails_fast_when_ref_context_cache_is_missing():
model = _make_model()
followup_codes = torch.tensor([5, 6, 7, 15, 16, 17], dtype=torch.long)
with pytest.raises(ValueError, match="Missing Qwen3-TTS ref context cache"):
model.forward(
input_ids=followup_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 3,
"ref_context_size": 2,
"ref_context_request_id": "rid",
"ref_context_included": False,
}
}
],
)
def test_ref_context_cache_evicts_lru_entries_when_requests_abort():
model = _make_model()
model._ref_context_cache_max_entries = 2
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b", "c"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b", "c"]
assert model._ref_context_cache_bytes == sum(model._tensor_nbytes(t) for t in model._ref_context_cache.values())
def test_ref_context_cache_evicts_to_byte_cap():
model = _make_model()
model._ref_context_cache_max_entries = 100
model._ref_context_cache_max_bytes = 33
first_codes = torch.tensor([9, 8, 1, 2, 9, 8, 3, 4], dtype=torch.long)
for rid in ("a", "b"):
model.forward(
input_ids=first_codes,
runtime_additional_information=[
{
"meta": {
"left_context_size": 2,
"ref_context_size": 2,
"ref_context_request_id": rid,
"ref_context_included": True,
}
}
],
)
assert list(model._ref_context_cache) == ["b"]
assert model._ref_context_cache_bytes == 32
def test_connector_codec_chunking_does_not_override_decode_chunking():
model = _make_model(
async_chunk=True,
@@ -2,6 +2,7 @@ from collections import OrderedDict
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
@@ -9,6 +10,7 @@ from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
PRECOMPUTED_REF_CODE_KEY,
PRECOMPUTED_REF_IDS_KEY,
PRECOMPUTED_TEXT_IDS_KEY,
REF_AUDIO_CACHE_KEY,
Qwen3TTSPromptEmbedsBuilder,
)
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
@@ -493,6 +495,41 @@ def test_base_voice_clone_batch_preprocess_skips_after_initial_prefill_state_exi
assert NORMALIZED_REF_AUDIO_KEY not in buf["r1"]
def test_base_voice_clone_batch_preprocess_uses_serving_artifact_cache_key_without_normalize():
builder = _make_minimal_builder()
ref_code = torch.full((2, 2), 5, dtype=torch.long)
builder.put_ref_audio_artifacts("same-ref", ref_code=ref_code)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder._encode_ref_audio_batch_fn = lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("speech tokenizer not expected")
)
class FakeTextTokenizer:
def __call__(self, texts, *, padding=False):
return {"input_ids": [[7, 8, 9] for _ in texts]}
builder._text_tokenizer = FakeTextTokenizer()
buf = {
"r1": {
"task_type": ["Base"],
"text": ["one"],
"ref_audio": ["a.wav"],
"ref_text": ["hello"],
"x_vector_only_mode": [False],
REF_AUDIO_CACHE_KEY: ["same-ref"],
}
}
builder.preprocess_batch(
req_ids=["r1"],
model_intermediate_buffer=buf,
device=torch.device("cpu"),
)
assert torch.equal(buf["r1"]["codes"][PRECOMPUTED_REF_CODE_KEY], ref_code)
assert NORMALIZED_REF_AUDIO_KEY not in buf["r1"]
def test_base_voice_clone_uses_batched_ref_code_without_serial_encode():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
@@ -536,3 +573,112 @@ def test_base_voice_clone_uses_batched_ref_code_without_serial_encode():
assert speaker_wav_ids == [id(ref_audio)]
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_voice_clone_prompt_artifacts_do_not_resolve_ref_audio_for_cache_fill():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder.encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(AssertionError("encode not expected"))
builder.extract_speaker_embedding = lambda _wav, _sr: (_ for _ in ()).throw(
AssertionError("speaker embedding not expected")
)
ref_code = torch.arange(4, dtype=torch.long).reshape(2, 2)
_prompt, _trailing, ref_code_len, out_ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_audio": ["ref.wav"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
"voice_clone_prompt": {
"ref_code": ref_code,
"ref_spk_embedding": torch.ones(4, dtype=torch.bfloat16),
"icl_mode": True,
},
},
)
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_ref_audio_artifact_only_uses_cache_without_ref_audio_payload():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
builder._generate_icl_prompt = lambda **kwargs: (
torch.ones((1, 2, 4), device=kwargs["ref_code"].device),
torch.ones((1, 4), device=kwargs["ref_code"].device),
)
builder.normalize_ref_audio = lambda _raw: (_ for _ in ()).throw(AssertionError("normalize not expected"))
builder.encode_ref_audio_to_code = lambda _wav, _sr: (_ for _ in ()).throw(AssertionError("encode not expected"))
builder.extract_speaker_embedding = lambda _wav, _sr: (_ for _ in ()).throw(
AssertionError("speaker embedding not expected")
)
ref_code = torch.arange(4, dtype=torch.long).reshape(2, 2)
builder.put_ref_audio_artifacts(
"same-ref",
ref_code=ref_code,
ref_spk_embedding=torch.ones(4, dtype=torch.bfloat16),
)
_prompt, _trailing, ref_code_len, out_ref_code = builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
REF_AUDIO_CACHE_KEY: ["same-ref"],
},
)
assert ref_code_len == 2
assert torch.equal(out_ref_code, ref_code)
def test_ref_audio_artifact_only_cache_miss_fails_fast():
builder = _make_minimal_builder()
device_param = torch.nn.Parameter(torch.empty(0))
builder._text_embedding = _stub_text_embedding(device_param)
builder._text_projection = lambda embeds: embeds
builder._codec_embed = lambda ids: torch.zeros((*ids.shape, 4), device=ids.device)
class FakeTokenizer:
def __call__(self, *_args, **_kwargs):
return {"input_ids": torch.arange(8, dtype=torch.long).reshape(1, -1)}
builder._text_tokenizer = FakeTokenizer()
with pytest.raises(RuntimeError, match="artifact cache miss"):
builder.build_prompt_embeds(
task_type="Base",
info_dict={
"text": ["hello"],
"ref_ids": torch.arange(8, dtype=torch.long).reshape(1, -1),
"non_streaming_mode": [False],
REF_AUDIO_CACHE_KEY: ["missing-ref"],
},
)
@@ -79,6 +79,27 @@ def test_estimate_prompt_len_handles_1d_ref_code() -> None:
assert est > 50, f"got {est}; 1D ref_code must contribute its own length"
def test_estimate_prompt_len_uses_ref_code_length_without_ref_audio() -> None:
est = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
additional_information={
"task_type": ["Base"],
"text": ["hello"],
"ref_text": ["reference transcript"],
"x_vector_only_mode": [False],
"ref_code_length": [6],
"_qwen3_tts_ref_audio_cache_key": ["same-ref"],
"language": ["English"],
},
task_type="Base",
tokenize_prompt=lambda _text: list(range(10)),
codec_language_id=None,
spk_is_dialect=None,
estimate_ref_code_len=lambda _ref_audio: None,
)
assert est == 15
def test_estimate_prompt_len_uses_list_of_int_ref_ids_from_voice_clone_prompt() -> None:
"""`voice_clone_prompt.ref_ids` as a list-of-int (the natural pre-tokenized shape)
must be read as a sequence, not unwrapped to its first element. Without the fix