[Qwen3TTS] [TTS] [Feat] Refactor voice cache manager (#2108)

Signed-off-by: JuanPZuluaga <juanz9312@gmal.com>
Signed-off-by: yiliu30 <yi4.liu@intel.com>
Signed-off-by: gcanlin <canlinguosdu@gmail.com>
Signed-off-by: Binh Tang <tangbinhna@gmail.com>
Signed-off-by: Binh Tang <binht@netflix.com>
Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Signed-off-by: Rein Yang <ruiruyang2@gmail.com>
Signed-off-by: CHEN <116010019@link.cuhk.edu.cn>
Signed-off-by: vraiti <vraiti@redhat.com>
Signed-off-by: Songrui625 <songrui625@gmail.com>
Signed-off-by: Lidang Jiang <lidangjiang@gmail.com>
Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com>
Signed-off-by: Alex Brooks <albrooks@redhat.com>
Co-authored-by: JuanPZuluaga <juanz9312@gmal.com>
Co-authored-by: Yi Liu <yi4.liu@intel.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
Co-authored-by: Binh Tang <tangbinhna@gmail.com>
Co-authored-by: Binh Tang <binht@netflix.com>
Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
Co-authored-by: rein yang <73573651+R2-Y@users.noreply.github.com>
Co-authored-by: zhumingjue138 <zhumingjue@huawei.com>
Co-authored-by: ChenWenjing <54166744+Shirley125@users.noreply.github.com>
Co-authored-by: vraiti <vraiti@redhat.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
Co-authored-by: Sy03 <1370724210@qq.com>
Co-authored-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Jerry Song <46962917+Songrui625@users.noreply.github.com>
Co-authored-by: Lidang Jiang <119769478+Lidang-Jiang@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alex Brooks <albrooks@redhat.com>
Co-authored-by: linyueqian <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
11 files changed
+442 -614

No files matched your search

@@ -233,17 +233,20 @@ def test_app(mocker: MockerFixture):
uploaded_voices = []
if hasattr(speech_server, "uploaded_speakers"):
for voice_name, info in speech_server.uploaded_speakers.items():
uploaded_voices.append(
{
"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"),
}
)
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"])
@@ -255,7 +258,8 @@ def test_app(mocker: MockerFixture):
speaker_embedding: str | None = Form(None),
consent: str = Form(...),
name: str = Form(...),
ref_text: str = Form(None),
ref_text: str | None = Form(None),
speaker_description: str | None = Form(None),
):
try:
if speaker_embedding is not None and audio_sample is not None:
@@ -263,7 +267,13 @@ def test_app(mocker: MockerFixture):
if speaker_embedding is not None:
result = await speech_server.upload_voice_embedding(speaker_embedding, consent, name)
elif audio_sample is not None:
result = await speech_server.upload_voice(audio_sample, consent, name, ref_text=ref_text)
result = await speech_server.upload_voice(
audio_sample,
consent,
name,
ref_text=ref_text,
speaker_description=speaker_description,
)
else:
raise ValueError("Either 'audio_sample' or 'speaker_embedding' must be provided")
return {"success": True, "voice": result}
@@ -397,6 +407,44 @@ class TestSpeechAPI:
assert result["voice"].get("ref_text") == "Hello world transcript"
response = client.delete("/v1/audio/voices/test_voice_rt")
def test_upload_voice_with_speaker_description(self, client, tmp_path):
"""Test voice upload with speaker_description stores and returns the description."""
# Pre-cleanup in case a previous test run left this voice behind
client.delete("/v1/audio/voices/test_voice_vd")
audio_content = b"fake audio content" * 1000
files = {"audio_sample": ("test.wav", audio_content, "audio/wav")}
data = {"consent": "c1", "name": "test_voice_vd", "speaker_description": " warm, energetic narrator "}
response = client.post("/v1/audio/voices", files=files, data=data)
try:
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["voice"]["name"] == "test_voice_vd"
assert result["voice"].get("speaker_description") == "warm, energetic narrator"
finally:
client.delete("/v1/audio/voices/test_voice_vd")
def test_upload_voice_speaker_description_in_listing(self, client):
"""Test that speaker_description survives the upload → list round-trip."""
client.delete("/v1/audio/voices/test_voice_sd_list")
audio_content = b"fake audio content" * 1000
files = {"audio_sample": ("test.wav", audio_content, "audio/wav")}
data = {"consent": "c1", "name": "test_voice_sd_list", "speaker_description": "calm female narrator"}
response = client.post("/v1/audio/voices", files=files, data=data)
try:
assert response.status_code == 200
listing = client.get("/v1/audio/voices").json()
uploaded = {v["name"]: v for v in listing["uploaded_voices"]}
assert "test_voice_sd_list" in uploaded
assert uploaded["test_voice_sd_list"]["speaker_description"] == "calm female narrator"
finally:
client.delete("/v1/audio/voices/test_voice_sd_list")
def test_upload_voice_file_too_large(self, client):
"""Test voice upload with file exceeding size limit."""
# Create a file larger than 10MB
@@ -850,6 +898,7 @@ class TestTTSMethods:
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
"ref_text": None,
"created_at": 1711234567.89,
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
@@ -862,6 +911,7 @@ class TestTTSMethods:
assert params["ref_audio"] == ["data:audio/wav;base64,ZmFrZWF1ZGlv"]
assert params["x_vector_only_mode"] == [True]
assert params["task_type"] == ["Base"]
assert params["voice_created_at"] == [1711234567.89]
assert "ref_text" not in params
def test_build_tts_params_with_uploaded_voice_ref_text(self, speech_server):
@@ -872,6 +922,7 @@ class TestTTSMethods:
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
"ref_text": "Hello world transcript",
"created_at": 1711234567.89,
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
@@ -885,6 +936,7 @@ class TestTTSMethods:
assert params["x_vector_only_mode"] == [False]
assert params["task_type"] == ["Base"]
assert params["ref_text"] == ["Hello world transcript"]
assert params["voice_created_at"] == [1711234567.89]
def test_build_tts_params_without_uploaded_voice(self, speech_server):
"""Test _build_tts_params does not auto-set ref_audio for non-uploaded voices."""
+129
View File
@@ -0,0 +1,129 @@
import threading
import pytest
from vllm_omni.utils.voice_cache import VoiceEmbeddingCache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def cache():
return VoiceEmbeddingCache(max_entries=4)
class TestVoiceEmbeddingCache:
def test_miss_returns_none(self, cache: VoiceEmbeddingCache):
assert cache.get("nonexistent") is None
assert cache.stats()["misses"] == 1
def test_put_and_hit(self, cache: VoiceEmbeddingCache):
cache.put("abc", {"val": 42})
result = cache.get("abc")
assert result is not None
assert result["val"] == 42
assert cache.stats()["hits"] == 1
def test_lru_eviction(self, cache: VoiceEmbeddingCache):
for i in range(5):
cache.put(f"key{i}", {"i": i})
# key0 should have been evicted (oldest, max_entries=4)
assert cache.get("key0") is None
# key1..key4 should still be present
for i in range(1, 5):
assert cache.get(f"key{i}") is not None
assert cache.stats()["entries"] == 4
def test_lru_access_promotes(self, cache: VoiceEmbeddingCache):
cache.put("a", {"v": 1})
cache.put("b", {"v": 2})
cache.put("c", {"v": 3})
cache.put("d", {"v": 4})
# Access "a" to promote it to MRU
cache.get("a")
# Insert "e" -- should evict "b" (now the oldest), not "a"
cache.put("e", {"v": 5})
assert cache.get("a") is not None
assert cache.get("b") is None
def test_put_overwrites(self, cache: VoiceEmbeddingCache):
cache.put("k", {"old": True})
cache.put("k", {"new": True})
result = cache.get("k")
assert result is not None
assert "new" in result
assert "old" not in result
assert cache.stats()["entries"] == 1
def test_make_cache_key_includes_mode(self):
k1 = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=True)
k2 = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False)
assert k1 != k2
assert "xvec" in k1
assert "icl" in k2
def test_make_cache_key_deterministic(self):
k1 = VoiceEmbeddingCache.make_cache_key("bob", xvec_only=True)
k2 = VoiceEmbeddingCache.make_cache_key("bob", xvec_only=True)
assert k1 == k2
def test_make_cache_key_created_at_isolation(self):
"""Different created_at timestamps must produce different keys (stale-cache protection)."""
k1 = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False, created_at=1000.0)
k2 = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False, created_at=2000.0)
assert k1 != k2
def test_stale_cache_protection(self, cache: VoiceEmbeddingCache):
"""Re-upload (new created_at) must produce a cache miss, not a stale hit."""
key_old = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False, created_at=1000.0)
key_new = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False, created_at=2000.0)
cache.put(key_old, {"ref_spk_embedding": "old_emb"})
# Re-upload produces a new created_at → different key → cold miss
assert cache.get(key_new) is None
# Old key still in cache (not yet evicted)
assert cache.get(key_old) is not None
def test_cache_mode_isolation(self, cache: VoiceEmbeddingCache):
"""xvec entry must NOT be served for an icl request (same voice)."""
key_xvec = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=True)
key_icl = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False)
cache.put(key_xvec, {"ref_code": None, "ref_spk_embedding": "emb"})
# icl request should miss — different key
assert cache.get(key_icl) is None
# xvec request should hit
assert cache.get(key_xvec) is not None
def test_stats_counters(self, cache: VoiceEmbeddingCache):
cache.put("x", {"v": 1})
cache.get("x") # hit
cache.get("x") # hit
cache.get("y") # miss
s = cache.stats()
assert s["hits"] == 2
assert s["misses"] == 1
assert s["entries"] == 1
assert s["max_entries"] == 4
def test_thread_safety(self):
cache = VoiceEmbeddingCache(max_entries=32)
errors = []
def worker(thread_id: int):
try:
for i in range(50):
key = f"t{thread_id}_k{i}"
cache.put(key, {"tid": thread_id, "i": i})
cache.get(key)
cache.get(f"t{(thread_id + 1) % 10}_k{i}")
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(t,)) for t in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"Thread safety errors: {errors}"
s = cache.stats()
assert s["entries"] <= 32