[TTS][SpeakerCacheManager] A global speaker cache manager for Voice Cloning (#2630)

Signed-off-by: JuanPZuluaga <juanz9312@gmal.com>
Co-authored-by: JuanPZuluaga <juanz9312@gmal.com>
This commit is contained in:
Juan Pablo ZuluagaandJuanPZuluaga authored and GitHub committed 2026-05-05 10:53:45 -04:00
1 parent a0918ce583
commit b64ab05d09
21 files changed
+1353 -1036

No files matched your search

@@ -3,11 +3,11 @@
Measures TTFP improvement from DAC-code caching when using uploaded voices.
Setup:
1. Start vllm-omni with Fish Speech S2 Pro (use our feat branch)
1. Start vllm-omni with Fish Speech S2 Pro
2. Provide a reference audio file for voice cloning
Usage:
python bench_voice_cache.py \
python bench_speaker_cache.py \
--ref-audio /path/to/reference.wav \
--ref-text "Transcript of the reference audio." \
--num-prompts 20 \
+49
View File
@@ -358,6 +358,31 @@ curl -X POST http://localhost:8091/v1/audio/speech \
}' --output cloned.wav
```
### Voice Storage & Caching
Uploaded voices are persisted to disk as a single `.safetensors` file per voice
(audio samples + metadata — name, consent, ref_text, sample_rate, created_at —
in the file header). On server restart the directory is scanned and all
previously uploaded voices are restored automatically, so uploads survive
process restarts.
Uploading an existing name overwrites the previous entry (a warning is logged).
Feature extraction artifacts (ref_code, speaker_embedding, DAC codes, etc.)
are cached in-process with a shared LRU so repeated requests with the same
`voice=...` skip the extraction pipeline. The cache is a true singleton across
all TTS model types; deleting a voice invalidates every model-type slot at
once.
**Configuration (environment variables):**
| Variable | Default | Description |
|----------|---------|-------------|
| `SPEAKER_SAMPLES_DIR` | `~/.cache/vllm-omni/speakers` | Directory for persisted uploaded speakers (`.safetensors` files). |
| `SPEAKER_MAX_UPLOADED` | `1000` | Maximum number of uploaded speakers kept on disk. Upload requests past the cap return 400. |
The in-memory LRU has a fixed 512 MiB byte budget.
## Batch Speech Generation
The batch endpoint synthesizes multiple texts in a single request, returning all results as JSON with base64-encoded audio.
@@ -543,6 +568,30 @@ Fish Speech uses `ref_audio` and `ref_text` for voice cloning (no `task_type` ne
|-------|-------------|
| `mistralai/Voxtral-4B-TTS-2603` | 3B AR + FlowMatching TTS. Supports text-to-speech with preset voices. |
### CosyVoice3
| Model | Description |
|-------|-------------|
| `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | Voice cloning from `ref_audio` + `ref_text`. No built-in voice presets — upload a voice or pass `ref_audio`/`ref_text` per request. |
### OmniVoice
| Model | Description |
|-------|-------------|
| `k2-fsa/OmniVoice` | Pure-diffusion TTS. Supports voice cloning via `ref_audio` (with optional `ref_text`); no built-in voice presets. |
### VoxCPM2
| Model | Description |
|-------|-------------|
| `openbmb/VoxCPM2` | TTS + voice cloning with built-in speaker presets and uploaded-voice support. Accepts `voice` (preset or uploaded) or `ref_audio` + optional `ref_text`. |
### MOSS-TTS-Nano
| Model | Description |
|-------|-------------|
| `OpenMOSS-Team/MOSS-TTS-Nano` | Voice cloning only. Requires `ref_audio` (or an uploaded `voice`); no built-in voice presets. `ref_text` is accepted but ignored — upstream's `voice_clone` mode does not consume a transcript. |
## Error Responses
### 400 Bad Request
@@ -0,0 +1,68 @@
# Voxtral TTS Offline Inference
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/voxtral_tts>.
`end2end.py` runs Voxtral TTS end-to-end offline inference using vLLM. It supports both blocking (`Omni`) and streaming (`AsyncOmni`) generation, batched prompts with configurable concurrency, and voice selection via preset name or reference audio file.
When `mistral_common` has `SpeechRequest` support, prompt token IDs are built via `encode_speech_request`. Otherwise, the script falls back to manual token construction.
## Usage Examples
```bash
# Basic single-prompt with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 replicate prompts with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--num-prompts 32 --write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Streaming with neutral_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 prompts, 8 concurrent requests per wave, streaming with neutral_female voice
python3 examples/offline_inference/voxtral_tts/end2end.py \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Short debug prompt with reference audio
python3 examples/offline_inference/voxtral_tts/end2end.py \
--write-audio \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "This is a test message." \
--audio-path path/to/reference_audio.wav
```
## Arguments
| Argument | Description |
|---|---|
| `--model PATH` | HuggingFace repo ID or local directory path (default: `mistralai/Voxtral-4B-TTS-2603`) |
| `--text TEXT` | Text to synthesize (default: `"This is a test message."`) |
| `--audio-path PATH` | Path to reference audio file for voice cloning |
| `--output-dir DIR` | Directory to write output WAV files (default: `output_audio`) |
| `--deploy-config PATH` | Override the deploy config path. If unset, auto-loads `vllm_omni/deploy/voxtral_tts.yaml` from the HF `model_type`. |
| `--num-prompts N` | Number of replicate prompts to run for measuring performance (default: 1) |
| `--streaming` | Use streaming generation via `AsyncOmni` (default: blocking `Omni`) |
| `--concurrency N` | Max concurrent requests per wave (must be used with `--streaming`, must evenly divide `--num-prompts`) |
| `--voice NAME` | Voice preset to use instead of reference audio file (e.g., casual_female, casual_male, cheerful_female, neutral_female, neutral_male) |
| `--write-audio` | Write generated audio to WAV files |
| `--profiling-mode` | Enable profiling mode (reduces max tokens to 50) |
| `--log-stats` | Enable detailed statistics logging |
## Example materials
??? abstract "end2end.py"
``````py
--8<-- "examples/offline_inference/voxtral_tts/end2end.py"
``````
@@ -0,0 +1,58 @@
# Voxtral TTS Offline Inference
`end2end.py` runs Voxtral TTS end-to-end offline inference using vLLM. It supports both blocking (`Omni`) and streaming (`AsyncOmni`) generation, batched prompts with configurable concurrency, and voice selection via preset name or reference audio file.
When `mistral_common` has `SpeechRequest` support, prompt token IDs are built via `encode_speech_request`. Otherwise, the script falls back to manual token construction.
## Usage Examples
```bash
# Basic single-prompt with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 replicate prompts with cheerful_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--num-prompts 32 --write-audio --voice cheerful_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Streaming with neutral_female voice preset
python3 examples/offline_inference/voxtral_tts/end2end.py \
--streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# 32 prompts, 8 concurrent requests per wave, streaming with neutral_female voice
python3 examples/offline_inference/voxtral_tts/end2end.py \
--num-prompts 32 --concurrency 8 --streaming --write-audio --voice neutral_female \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "That eerie silence after the first storm was just the calm before another round of chaos, wasn't it?"
# Short debug prompt with reference audio
python3 examples/offline_inference/voxtral_tts/end2end.py \
--write-audio \
--model mistralai/Voxtral-4B-TTS-2603 \
--text "This is a test message." \
--audio-path path/to/reference_audio.wav
```
## Arguments
| Argument | Description |
|---|---|
| `--model PATH` | HuggingFace repo ID or local directory path (default: `mistralai/Voxtral-4B-TTS-2603`) |
| `--text TEXT` | Text to synthesize (default: `"This is a test message."`) |
| `--audio-path PATH` | Path to reference audio file for voice cloning |
| `--output-dir DIR` | Directory to write output WAV files (default: `output_audio`) |
| `--deploy-config PATH` | Override the deploy config path. If unset, auto-loads `vllm_omni/deploy/voxtral_tts.yaml` from the HF `model_type`. |
| `--num-prompts N` | Number of replicate prompts to run for measuring performance (default: 1) |
| `--streaming` | Use streaming generation via `AsyncOmni` (default: blocking `Omni`) |
| `--concurrency N` | Max concurrent requests per wave (must be used with `--streaming`, must evenly divide `--num-prompts`) |
| `--voice NAME` | Voice preset to use instead of reference audio file. Check Huggingface `mistralai/Voxtral-4B-TTS-2603` to get the list of available voices |
| `--write-audio` | Write generated audio to WAV files |
| `--profiling-mode` | Enable profiling mode (reduces max tokens to 50) |
| `--log-stats` | Enable detailed statistics logging |
+1
View File
@@ -13,6 +13,7 @@ pytest_plugins = (
"tests.helpers.fixtures.log",
"tests.helpers.fixtures.run_args",
"tests.helpers.fixtures.runtime",
"tests.helpers.fixtures.speaker_cache",
)
@@ -300,6 +300,24 @@ def client(test_app):
class TestSpeechAPI:
@pytest.fixture(autouse=True)
def _mock_upload_io(self, mocker: MockerFixture):
"""Mock soundfile/safetensors so upload accepts fake audio bytes."""
samples = np.zeros(88200, dtype=np.float32) # 2s @ 44.1 kHz
mocker.patch("soundfile.read", return_value=(samples, 44100))
def _fake_save_file(tensors, path, metadata=None):
Path(path).touch()
mocker.patch("safetensors.torch.save_file", side_effect=_fake_save_file)
mock_ctx = mocker.MagicMock()
mock_ctx.keys.return_value = ["audio"]
mock_ctx.get_tensor.return_value = torch.zeros(88200)
mock_ctx.metadata.return_value = {"sample_rate": "44100"}
mock_safe_open = mocker.MagicMock()
mock_safe_open.return_value.__enter__.return_value = mock_ctx
mocker.patch("safetensors.safe_open", mock_safe_open)
def test_create_speech_success(self, client):
payload = {
"input": "Hello world",
@@ -470,27 +488,17 @@ class TestSpeechAPI:
assert "MIME type" in result["detail"]
def test_upload_voice_name_collision(self, client):
"""Test voice upload with duplicate name."""
# First upload
"""Re-uploading the same name overwrites the previous entry (no 400)."""
audio_content = b"fake audio content"
files = {
"audio_sample": ("test.wav", audio_content, "audio/wav"),
}
data = {
"consent": "user_consent_123",
"name": "test_voice",
}
files = {"audio_sample": ("test.wav", audio_content, "audio/wav")}
data = {"consent": "user_consent_123", "name": "test_voice"}
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 200
# Second upload with same name
response = client.post("/v1/audio/voices", files=files, data=data)
assert response.status_code == 400
result = response.json()
assert "detail" in result
assert "already exists" in result["detail"]
response = client.delete("/v1/audio/voices/test_voice")
assert response.status_code == 200
client.delete("/v1/audio/voices/test_voice")
def test_upload_voice_missing_parameters(self, client):
"""Test voice upload with missing required parameters."""
@@ -970,7 +978,7 @@ class TestTTSMethods:
"file_path": "/tmp/voice_samples/custom_voice_consent_123.wav",
"mime_type": "audio/wav",
"ref_text": None,
"created_at": 1711234567.89,
"created_at": 1711234567,
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
@@ -983,7 +991,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 params["voice_created_at"] == [1711234567]
assert "ref_text" not in params
def test_build_tts_params_with_uploaded_voice_ref_text(self, speech_server, mocker: MockerFixture):
@@ -994,7 +1002,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,
"created_at": 1711234567,
}
}
speech_server.supported_speakers = {"ryan", "vivian", "custom_voice"}
@@ -1008,7 +1016,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]
assert params["voice_created_at"] == [1711234567]
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."""
@@ -1051,28 +1059,29 @@ class TestTTSMethods:
assert "x_vector_only_mode" not in params
def test_get_uploaded_audio_data(self, speech_server, mocker: MockerFixture):
"""Test _get_uploaded_audio_data function."""
# Mock file operations
mock_open = mocker.patch("builtins.open", create=True)
mock_b64encode = mocker.patch("base64.b64encode")
mock_exists = mocker.patch("pathlib.Path.exists")
mock_exists.return_value = True
mock_b64encode.return_value = b"ZmFrZWF1ZGlv"
"""Returns a data URL by loading audio via safetensors + re-encoding WAV."""
mocker.patch("pathlib.Path.exists", return_value=True)
mocker.patch("soundfile.write")
mocker.patch("base64.b64encode", return_value=b"ZmFrZWF1ZGlv")
mock_ctx = mocker.MagicMock()
mock_ctx.keys.return_value = ["audio"]
mock_ctx.get_tensor.return_value = torch.zeros(88200)
mock_ctx.metadata.return_value = {"sample_rate": "44100"}
mock_safe_open = mocker.MagicMock()
mock_safe_open.return_value.__enter__.return_value = mock_ctx
mocker.patch("safetensors.safe_open", mock_safe_open)
# Setup mock file
mock_file = mocker.MagicMock()
mock_file.read.return_value = b"fakeaudio"
mock_open.return_value.__enter__.return_value = mock_file
# Setup uploaded speaker
speech_server.uploaded_speakers = {
"test_voice": {"name": "test_voice", "file_path": "/tmp/test.wav", "mime_type": "audio/wav"}
"test_voice": {
"name": "test_voice",
"file_path": "/tmp/test.safetensors",
"mime_type": "audio/wav",
"embedding_source": "audio",
"sample_rate": 44100,
}
}
result = speech_server._get_uploaded_audio_data("test_voice")
assert result == "data:audio/wav;base64,ZmFrZWF1ZGlv"
mock_open.assert_called_once_with(Path("/tmp/test.wav"), "rb")
mock_b64encode.assert_called_once_with(b"fakeaudio")
def test_get_uploaded_audio_data_missing_file(self, speech_server, mocker: MockerFixture):
"""Test _get_uploaded_audio_data when file is missing."""
@@ -1230,24 +1239,6 @@ class TestTTSMethods:
# Must NOT have ref_audio — that would fail for safetensors files
assert "ref_audio" not in params
def test_validate_rejects_embedding_voice_with_pending_cache(self, speech_server, mocker: MockerFixture):
"""Validation should reject embedding voices whose cache is not yet ready."""
speech_server.uploaded_speakers = {
"myvoice": {
"name": "myvoice",
"file_path": "/tmp/myvoice.safetensors",
"mime_type": "application/x-safetensors",
"embedding_source": "direct",
"cache_status": "pending",
"cache_file": None,
}
}
req = OpenAICreateSpeechRequest.model_validate({"input": "Hello", "speaker": "myvoice", "task_type": "Base"})
mocker.patch("pathlib.Path.exists", return_value=True)
err = speech_server._validate_qwen_tts_request(req)
assert err is not None
assert "not yet ready" in err
def test_x_vector_only_mode_not_overwritten_for_uploaded_embedding(self, speech_server, mocker: MockerFixture):
"""x_vector_only_mode set by uploaded embedding must not be overwritten by request field."""
speech_server.uploaded_speakers = {
@@ -2294,6 +2285,7 @@ class TestCosyVoice3Serving:
"mm_processor_kwargs": {"prompt_text": "ref text", "sample_rate": 24000},
}
)
cosyvoice3_server._apply_cosyvoice3_dynamic_tokens = mocker.MagicMock(side_effect=lambda spl, req: spl)
request = OpenAICreateSpeechRequest(
input="Hello",
+21
View File
@@ -0,0 +1,21 @@
"""Fixtures for the process-wide speaker cache singleton."""
from __future__ import annotations
import pytest
@pytest.fixture
def fresh_speaker_cache():
"""Reset the process-wide speaker cache singleton before and after the test."""
import vllm_omni.utils.speaker_cache as _sc
def _reset():
with _sc._SINGLETON_LOCK:
if _sc._SINGLETON is not None:
_sc._SINGLETON.clear()
_sc._SINGLETON = None
_reset()
yield
_reset()
@@ -1,218 +0,0 @@
"""Tests for Fish Speech DAC-code caching via VoiceEmbeddingCache.
Covers:
- Cache miss → DAC encode → store
- Cache hit → skip DAC encode, reuse cached ref_codes_fq
- Inline ref_audio (no voice name) → no caching, full encode path
- Stale-cache protection via created_at
- Temp file cleanup on cache hit
"""
import os
import tempfile
import numpy as np
import pytest
import torch
from pytest_mock import MockerFixture
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_info_dict(
*,
text: str = "Hello world",
ref_text: str = "Reference transcript",
ref_audio_sr: int = 44100,
voice_name: str | None = None,
voice_created_at: float | None = None,
ref_audio_path: str | None = None,
) -> dict:
"""Build a minimal info_dict for _build_structured_voice_clone_prefill_embeds."""
d: dict = {
"text": text,
"ref_text": ref_text,
"ref_audio_sr": ref_audio_sr,
"fish_structured_voice_clone": True,
}
if ref_audio_path is not None:
d["ref_audio_path"] = ref_audio_path
if voice_name is not None:
d["voice_name"] = voice_name
if voice_created_at is not None:
d["voice_created_at"] = voice_created_at
return d
def _write_temp_npy(wav: np.ndarray | None = None) -> str:
"""Write a temporary .npy file with dummy audio and return its path."""
if wav is None:
wav = np.random.randn(44100).astype(np.float32) # 1 second @ 44.1kHz
with tempfile.NamedTemporaryFile(prefix="fish_test_", suffix=".npy", delete=False) as f:
np.save(f, wav)
return f.name
# Fake ref_codes_fq: [frames, codebooks]
_FAKE_REF_CODES = torch.randint(0, 1024, (10, 10), dtype=torch.long)
class TestFishSpeechVoiceCacheIntegration:
"""Test the cache-hit / cache-miss / no-cache paths in the model."""
@pytest.fixture
def mock_model(self, mocker: MockerFixture):
"""Create a mock FishSpeechSlowARForConditionalGeneration with cache."""
from vllm_omni.utils.voice_cache import VoiceEmbeddingCache
model = mocker.MagicMock()
model._voice_cache = VoiceEmbeddingCache(max_entries=4)
model._semantic_begin_id = 151678
model._num_codebooks = 10
model._codebook_size = 4096
model.model_path = "/fake/model"
model.codebook_embeddings = mocker.MagicMock()
model.codebook_embeddings.weight = mocker.MagicMock()
model.codebook_embeddings.weight.device = torch.device("cpu")
return model
def test_cache_miss_stores_codes(self, mock_model):
"""First request with a named voice should encode and store in cache."""
cache = mock_model._voice_cache
voice_name = "alice"
created_at = 1712345678.0
# Verify cache starts empty.
key = cache.make_cache_key(voice_name, xvec_only=False, created_at=created_at)
assert cache.get(key) is None
# Simulate a cache store (what the model does on miss).
cache.put(key, {"ref_codes_fq": _FAKE_REF_CODES.detach().cpu()})
# Verify it's now cached.
cached = cache.get(key)
assert cached is not None
assert torch.equal(cached["ref_codes_fq"], _FAKE_REF_CODES)
def test_cache_hit_returns_cached_codes(self, mock_model):
"""Second request with same voice should hit cache."""
cache = mock_model._voice_cache
voice_name = "alice"
created_at = 1712345678.0
key = cache.make_cache_key(voice_name, xvec_only=False, created_at=created_at)
cache.put(key, {"ref_codes_fq": _FAKE_REF_CODES.detach().cpu()})
# Hit.
cached = cache.get(key)
assert cached is not None
ref_codes = cached["ref_codes_fq"].to(device=torch.device("cpu"), dtype=torch.long)
assert torch.equal(ref_codes, _FAKE_REF_CODES)
assert cache.stats()["hits"] >= 1
def test_no_voice_name_skips_cache(self, mock_model):
"""Inline ref_audio without voice_name should not use cache."""
cache = mock_model._voice_cache
# Without voice_name, the model should not interact with cache at all.
info = _make_info_dict(voice_name=None, ref_audio_path=_write_temp_npy())
assert info.get("voice_name") is None
# Cache should remain untouched.
assert cache.stats()["hits"] == 0
assert cache.stats()["misses"] == 0
def test_stale_cache_on_reupload(self, mock_model):
"""Re-uploading a voice (new created_at) should not hit old cache."""
cache = mock_model._voice_cache
voice_name = "alice"
key_old = cache.make_cache_key(voice_name, xvec_only=False, created_at=1000.0)
cache.put(key_old, {"ref_codes_fq": _FAKE_REF_CODES})
# Re-upload produces a different created_at.
key_new = cache.make_cache_key(voice_name, xvec_only=False, created_at=2000.0)
assert cache.get(key_new) is None # miss
assert cache.get(key_old) is not None # old still there
def test_temp_file_cleaned_on_cache_hit(self):
"""On cache hit, the temp .npy file written by the entrypoint should be deleted."""
tmp_path = _write_temp_npy()
assert os.path.exists(tmp_path)
# Simulate what the model does on cache hit: remove the temp file.
try:
os.remove(tmp_path)
except OSError:
pass
assert not os.path.exists(tmp_path)
def test_created_at_zero_disables_cache(self, mock_model):
"""created_at=0 should not create a cache key (caching disabled)."""
cache = mock_model._voice_cache
info = _make_info_dict(
voice_name="bob",
voice_created_at=0.0,
ref_audio_path=_write_temp_npy(),
)
# The model checks: if _created_at > 0 → enable cache.
# With 0.0, no cache interaction should happen.
_created_at = float(info.get("voice_created_at", 0))
assert _created_at <= 0
assert cache.stats()["hits"] == 0
assert cache.stats()["misses"] == 0
class TestFishSpeechValidatorUploadedVoice:
"""Test _validate_fish_tts_request uploaded voice resolution."""
def test_uploaded_voice_resolves_ref_audio(self, mocker: MockerFixture):
"""When voice matches an uploaded speaker, ref_audio should be auto-set."""
request = mocker.MagicMock()
request.input = "Hello"
request.voice = "alice"
request.ref_audio = None
request.ref_text = None
request.max_new_tokens = None
# Uploaded speaker with ref_text.
uploaded_speakers = {
"alice": {
"file_path": "/tmp/fake_audio.wav",
"ref_text": "Hi this is Alice",
"created_at": 1712345678,
},
}
# Simulate: voice in uploaded_speakers, file exists, get_audio returns data URL.
mocker.patch("pathlib.Path.exists", return_value=True)
voice_lower = request.voice.lower()
assert voice_lower in uploaded_speakers
speaker_info = uploaded_speakers[voice_lower]
ref_text_from_upload = speaker_info.get("ref_text")
assert ref_text_from_upload == "Hi this is Alice"
def test_uploaded_voice_without_ref_text_uses_request_ref_text(self, mocker: MockerFixture):
"""If upload has no ref_text but request provides it, use request's."""
request = mocker.MagicMock()
request.input = "Hello"
request.voice = "bob"
request.ref_audio = None
request.ref_text = "Request-level transcript"
request.max_new_tokens = None
uploaded_speakers = {
"bob": {
"file_path": "/tmp/fake_audio.wav",
"ref_text": None,
"created_at": 1712345678,
},
}
voice_lower = request.voice.lower()
speaker_info = uploaded_speakers[voice_lower]
upload_ref_text = speaker_info.get("ref_text")
# Upload has no ref_text, so request.ref_text should remain.
assert upload_ref_text is None
assert request.ref_text == "Request-level transcript"
-227
View File
@@ -1,227 +0,0 @@
"""Tests for Fish Speech DAC-code caching via VoiceEmbeddingCache.
Covers:
- Cache miss → DAC encode → store
- Cache hit → skip DAC encode, reuse cached ref_codes_fq
- Inline ref_audio (no voice name) → no caching, full encode path
- Stale-cache protection via created_at
- Temp file cleanup on cache hit
"""
import os
import tempfile
from pathlib import Path
import numpy as np
import pytest
import torch
from pytest_mock import MockerFixture
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_info_dict(
*,
text: str = "Hello world",
ref_text: str = "Reference transcript",
ref_audio_sr: int = 44100,
voice_name: str | None = None,
voice_created_at: float | None = None,
ref_audio_path: str | None = None,
) -> dict:
"""Build a minimal info_dict for _build_structured_voice_clone_prefill_embeds."""
d: dict = {
"text": text,
"ref_text": ref_text,
"ref_audio_sr": ref_audio_sr,
"fish_structured_voice_clone": True,
}
if ref_audio_path is not None:
d["ref_audio_path"] = ref_audio_path
if voice_name is not None:
d["voice_name"] = voice_name
if voice_created_at is not None:
d["voice_created_at"] = voice_created_at
return d
def _write_temp_npy(wav: np.ndarray | None = None) -> str:
"""Write a temporary .npy file with dummy audio and return its path."""
if wav is None:
wav = np.random.randn(44100).astype(np.float32) # 1 second @ 44.1kHz
with tempfile.NamedTemporaryFile(prefix="fish_test_", suffix=".npy", delete=False) as f:
np.save(f, wav)
return f.name
# Fake ref_codes_fq: [frames, codebooks]
_FAKE_REF_CODES = torch.randint(0, 1024, (10, 10), dtype=torch.long)
class TestFishSpeechVoiceCacheIntegration:
"""Test the cache-hit / cache-miss / no-cache paths in the model."""
@pytest.fixture
def mock_model(self, mocker: MockerFixture):
"""Create a mock FishSpeechSlowARForConditionalGeneration with cache."""
from vllm_omni.utils.voice_cache import VoiceEmbeddingCache
model = mocker.MagicMock()
model._voice_cache = VoiceEmbeddingCache(max_entries=4)
model._semantic_begin_id = 151678
model._num_codebooks = 10
model._codebook_size = 4096
model.model_path = "/fake/model"
model.codebook_embeddings = mocker.MagicMock()
model.codebook_embeddings.weight = mocker.MagicMock()
model.codebook_embeddings.weight.device = torch.device("cpu")
return model
def test_cache_miss_stores_codes(self, mock_model):
"""First request with a named voice should encode and store in cache."""
cache = mock_model._voice_cache
voice_name = "alice"
created_at = 1712345678.0
# Verify cache starts empty.
key = cache.make_cache_key(voice_name, xvec_only=False, created_at=created_at)
assert cache.get(key) is None
# Simulate a cache store (what the model does on miss).
cache.put(key, {"ref_codes_fq": _FAKE_REF_CODES.detach().cpu()})
# Verify it's now cached.
cached = cache.get(key)
assert cached is not None
assert torch.equal(cached["ref_codes_fq"], _FAKE_REF_CODES)
def test_cache_hit_returns_cached_codes(self, mock_model):
"""Second request with same voice should hit cache."""
cache = mock_model._voice_cache
voice_name = "alice"
created_at = 1712345678.0
key = cache.make_cache_key(voice_name, xvec_only=False, created_at=created_at)
cache.put(key, {"ref_codes_fq": _FAKE_REF_CODES.detach().cpu()})
# Hit.
cached = cache.get(key)
assert cached is not None
ref_codes = cached["ref_codes_fq"].to(device=torch.device("cpu"), dtype=torch.long)
assert torch.equal(ref_codes, _FAKE_REF_CODES)
assert cache.stats()["hits"] >= 1
def test_no_voice_name_skips_cache(self, mock_model):
"""Inline ref_audio without voice_name should not use cache."""
cache = mock_model._voice_cache
# Without voice_name, the model should not interact with cache at all.
info = _make_info_dict(voice_name=None, ref_audio_path=_write_temp_npy())
assert info.get("voice_name") is None
# Cache should remain untouched.
assert cache.stats()["hits"] == 0
assert cache.stats()["misses"] == 0
def test_stale_cache_on_reupload(self, mock_model):
"""Re-uploading a voice (new created_at) should not hit old cache."""
cache = mock_model._voice_cache
voice_name = "alice"
key_old = cache.make_cache_key(voice_name, xvec_only=False, created_at=1000.0)
cache.put(key_old, {"ref_codes_fq": _FAKE_REF_CODES})
# Re-upload produces a different created_at.
key_new = cache.make_cache_key(voice_name, xvec_only=False, created_at=2000.0)
assert cache.get(key_new) is None # miss
assert cache.get(key_old) is not None # old still there
def test_temp_file_cleaned_on_cache_hit(self):
"""On cache hit, the temp .npy file written by the entrypoint should be deleted."""
tmp_path = _write_temp_npy()
assert os.path.exists(tmp_path)
# Simulate what the model does on cache hit: remove the temp file.
try:
os.remove(tmp_path)
except OSError:
pass
assert not os.path.exists(tmp_path)
def test_created_at_zero_disables_cache(self, mock_model):
"""created_at=0 should not create a cache key (caching disabled)."""
cache = mock_model._voice_cache
info = _make_info_dict(
voice_name="bob",
voice_created_at=0.0,
ref_audio_path=_write_temp_npy(),
)
# The model checks: if _created_at > 0 → enable cache.
# With 0.0, no cache interaction should happen.
_created_at = float(info.get("voice_created_at", 0))
assert _created_at <= 0
assert cache.stats()["hits"] == 0
assert cache.stats()["misses"] == 0
class TestFishSpeechValidatorUploadedVoice:
"""Test _validate_fish_tts_request uploaded voice resolution."""
def test_uploaded_voice_resolves_ref_audio(
self,
monkeypatch: pytest.MonkeyPatch,
mocker: MockerFixture,
):
"""When voice matches an uploaded speaker, ref_audio should be auto-set."""
request = mocker.MagicMock()
request.input = "Hello"
request.voice = "alice"
request.ref_audio = None
request.ref_text = None
request.max_new_tokens = None
# Uploaded speaker with ref_text.
uploaded_speakers = {
"alice": {
"file_path": "/tmp/fake_audio.wav",
"ref_text": "Hi this is Alice",
"created_at": 1712345678,
},
}
# Simulate: voice in uploaded_speakers, file exists, get_audio returns data URL.
monkeypatch.setattr(Path, "exists", lambda self: True)
voice_lower = request.voice.lower()
assert voice_lower in uploaded_speakers
speaker_info = uploaded_speakers[voice_lower]
ref_text_from_upload = speaker_info.get("ref_text")
assert ref_text_from_upload == "Hi this is Alice"
def test_uploaded_voice_without_ref_text_uses_request_ref_text(
self,
mocker: MockerFixture,
):
"""If upload has no ref_text but request provides it, use request's."""
request = mocker.MagicMock()
request.input = "Hello"
request.voice = "bob"
request.ref_audio = None
request.ref_text = "Request-level transcript"
request.max_new_tokens = None
uploaded_speakers = {
"bob": {
"file_path": "/tmp/fake_audio.wav",
"ref_text": None,
"created_at": 1712345678,
},
}
voice_lower = request.voice.lower()
speaker_info = uploaded_speakers[voice_lower]
upload_ref_text = speaker_info.get("ref_text")
# Upload has no ref_text, so request.ref_text should remain.
assert upload_ref_text is None
assert request.ref_text == "Request-level transcript"
+150
View File
@@ -0,0 +1,150 @@
import threading
import pytest
import torch
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache, get_speaker_cache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def cache():
return SpeakerEmbeddingCache(max_bytes=10 * 1024**2)
def _k(model: str, name: str, created_at: int = 0) -> tuple[str, str, int]:
return (model, name, created_at)
class TestSpeakerEmbeddingCacheBehavior:
def test_miss_returns_none(self, cache):
assert cache.get(_k("voxcpm2", "nonexistent")) is None
def test_put_and_hit(self, cache):
cache.put(_k("voxcpm2", "alice"), {"val": 42})
assert cache.get(_k("voxcpm2", "alice"))["val"] == 42
def test_lru_access_promotes(self):
c = SpeakerEmbeddingCache(max_bytes=4 * 4096)
for k in ("a", "b", "c", "d"):
c.put(_k("m", k), {"emb": torch.zeros(1024, dtype=torch.float32)})
c.get(_k("m", "a"))
c.put(_k("m", "e"), {"emb": torch.zeros(1024, dtype=torch.float32)})
assert c.get(_k("m", "a")) is not None
assert c.get(_k("m", "b")) is None
def test_put_overwrites(self, cache):
cache.put(_k("m", "k"), {"old": True})
cache.put(_k("m", "k"), {"new": True})
assert "new" in cache.get(_k("m", "k"))
assert cache.stats()["entries"] == 1
def test_make_cache_key_namespaces_model_type(self):
k1 = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2")
k2 = SpeakerEmbeddingCache.make_cache_key("alice", model_type="fish_speech")
assert k1 != k2
assert k1 == ("voxcpm2", "alice", 0)
assert k2 == ("fish_speech", "alice", 0)
def test_make_cache_key_created_at_isolation(self):
k_old = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2", created_at=1712000000)
k_new = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2", created_at=1712000042)
assert k_old != k_new
def test_make_cache_key_requires_fields(self):
with pytest.raises(ValueError):
SpeakerEmbeddingCache.make_cache_key("", model_type="voxcpm2")
with pytest.raises(ValueError):
SpeakerEmbeddingCache.make_cache_key("alice", model_type="")
def test_clear_all(self, cache):
cache.put(_k("m", "a"), {"v": 1})
cache.put(_k("m", "b"), {"v": 2})
assert cache.clear() == 2
assert cache.stats()["entries"] == 0
def test_clear_matches_speaker_across_model_types(self, cache):
cache.put(_k("voxcpm2", "alice", 1), {"v": 1})
cache.put(_k("fish_speech", "alice", 2), {"v": 2})
cache.put(_k("cosyvoice3", "bob", 3), {"v": 3})
assert cache.clear("alice") == 2
assert cache.get(_k("voxcpm2", "alice", 1)) is None
assert cache.get(_k("fish_speech", "alice", 2)) is None
assert cache.get(_k("cosyvoice3", "bob", 3)) is not None
def test_stale_cache_on_reupload(self, cache):
cache.put(_k("voxcpm2", "alice", 1712000000), {"emb": torch.zeros(4), "gen": "old"})
assert cache.get(_k("voxcpm2", "alice", 1712000042)) is None
def test_memory_bytes(self, cache):
assert cache.memory_bytes() == 0
t = torch.zeros(1024, dtype=torch.float32) # 4096 bytes
cache.put(_k("m", "k"), {"emb": t})
assert cache.memory_bytes() == 4096
def test_memory_bytes_ignores_non_tensors(self, cache):
cache.put(_k("m", "k"), {"flag": True, "name": "test", "nothing": None})
assert cache.memory_bytes() == 0
def test_byte_budget_evicts(self):
c = SpeakerEmbeddingCache(max_bytes=8192)
c.put(_k("m", "a"), {"emb": torch.zeros(1024, dtype=torch.float32)})
c.put(_k("m", "b"), {"emb": torch.zeros(1024, dtype=torch.float32)})
c.put(_k("m", "c"), {"emb": torch.zeros(1024, dtype=torch.float32)})
assert c.get(_k("m", "a")) is None
assert c.get(_k("m", "b")) is not None
assert c.get(_k("m", "c")) is not None
assert c.memory_bytes() <= 8192
def test_oversize_entry_skipped(self):
c = SpeakerEmbeddingCache(max_bytes=1024)
c.put(_k("m", "huge"), {"emb": torch.zeros(2048, dtype=torch.float32)})
assert c.get(_k("m", "huge")) is None
assert c.stats()["entries"] == 0
def test_stats(self, cache):
cache.put(_k("m", "x"), {"v": 1})
cache.get(_k("m", "x"))
cache.get(_k("m", "y"))
s = cache.stats()
assert s["hits"] == 1
assert s["misses"] >= 1
assert s["entries"] == 1
def test_thread_safety(self):
cache = SpeakerEmbeddingCache()
errors = []
def worker(tid):
try:
for i in range(50):
cache.put(_k("m", f"t{tid}_v{i}"), {"tid": tid})
cache.get(_k("m", f"t{tid}_v{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
assert cache.stats()["entries"] == 500
def test_empty_speaker_name_raises_error(self, cache):
with pytest.raises(ValueError, match="speaker_name cannot be an empty string"):
cache.clear("")
def test_cpu_storage_verification(self, cache):
tensor = torch.randn(10, 128)
cache.put(_k("m", "alice"), {"emb": tensor})
cached = cache.get(_k("m", "alice"))
assert cached["emb"].device.type == "cpu"
class TestSingleton:
def test_singleton_identity(self, fresh_speaker_cache):
a = get_speaker_cache()
b = get_speaker_cache()
assert a is b
+59
View File
@@ -0,0 +1,59 @@
"""Integration tests for the process-wide speaker cache across serving + models."""
import pytest
import torch
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache, get_speaker_cache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestSpeakerCacheIntegration:
def test_delete_propagates_across_model_types(self):
cache = SpeakerEmbeddingCache()
voxcpm_key = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2")
fish_key = SpeakerEmbeddingCache.make_cache_key("alice", model_type="fish_speech")
bob_key = SpeakerEmbeddingCache.make_cache_key("bob", model_type="voxcpm2")
cache.put(voxcpm_key, {"emb": torch.zeros(4)})
cache.put(fish_key, {"emb": torch.zeros(4)})
cache.put(bob_key, {"emb": torch.zeros(4)})
removed = cache.clear("alice")
assert removed == 2
assert cache.get(voxcpm_key) is None
assert cache.get(fish_key) is None
assert cache.get(bob_key) is not None
def test_singleton_shared_across_call_sites(self, fresh_speaker_cache):
cache_a = get_speaker_cache()
cache_b = get_speaker_cache()
assert cache_a is cache_b
key = SpeakerEmbeddingCache.make_cache_key("carol", model_type="voxcpm2")
cache_a.put(key, {"tag": "from_a"})
got = cache_b.get(key)
assert got is not None
assert got["tag"] == "from_a"
def test_shutdown_clears_all_entries(self):
cache = SpeakerEmbeddingCache()
for i in range(3):
k = SpeakerEmbeddingCache.make_cache_key(f"voice{i}", model_type="voxcpm2")
cache.put(k, {"emb": torch.zeros(2)})
assert cache.stats()["entries"] == 3
cache.clear()
assert cache.stats()["entries"] == 0
def test_stale_cache_protection_delete_then_reupload(self):
cache = SpeakerEmbeddingCache()
old_key = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2", created_at=1712000000)
cache.put(old_key, {"emb": torch.ones(4) * 3.14, "gen": "old"})
new_key = SpeakerEmbeddingCache.make_cache_key("alice", model_type="voxcpm2", created_at=1712000042)
assert cache.get(new_key) is None
assert cache.get(old_key) is not None
cache.clear("alice")
assert cache.get(old_key) is None
assert cache.get(new_key) is None
+100
View File
@@ -0,0 +1,100 @@
"""Tests for speaker metadata (created_at, consent, ref_text, ...) round-tripping
through the safetensors header.
These cover the helpers that back ``_restore_uploaded_speakers`` — the logic
that rebuilds ``uploaded_speakers`` on server start by reading the
``.safetensors`` file written during upload.
"""
import pytest
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestSpeakerMetadataRoundTrip:
def test_str_only_header(self):
"""The safetensors metadata dict must be ``dict[str, str]`` — every value
serialized as a string (ints and None handled)."""
data = {
"name": "Alice",
"voice_name_lower": "alice",
"consent": "xxx",
"created_at": 1712345678,
"sample_rate": 24000,
"embedding_source": "audio",
"ref_text": None, # None values must be dropped
"file_path": "/tmp/should/be/stripped.safetensors", # re-derived, not persisted
}
header = OmniOpenAIServingSpeech._speaker_metadata_to_header(data)
assert all(isinstance(k, str) and isinstance(v, str) for k, v in header.items())
assert "ref_text" not in header # None stripped
assert "file_path" not in header # never persisted
def test_int_fields_coerce_back(self):
"""Int fields (created_at, file_size, sample_rate, embedding_dim) survive
the string round-trip with their type preserved."""
data = {
"name": "Bob",
"voice_name_lower": "bob",
"consent": "yyy",
"created_at": 1712345678,
"sample_rate": 44100,
"file_size": 12345,
"embedding_dim": 1024,
"embedding_source": "direct",
}
header = OmniOpenAIServingSpeech._speaker_metadata_to_header(data)
back = OmniOpenAIServingSpeech._speaker_metadata_from_header(header, "/some/path.safetensors")
assert isinstance(back["created_at"], int)
assert back["created_at"] == 1712345678
assert isinstance(back["sample_rate"], int)
assert back["sample_rate"] == 44100
assert isinstance(back["file_size"], int)
assert isinstance(back["embedding_dim"], int)
def test_file_path_reinjected_on_load(self):
"""file_path is not persisted in the header; restore derives it from the
actual file location on disk."""
data = {
"name": "Carol",
"voice_name_lower": "carol",
"consent": "zzz",
"created_at": 1234,
"embedding_source": "audio",
}
header = OmniOpenAIServingSpeech._speaker_metadata_to_header(data)
back = OmniOpenAIServingSpeech._speaker_metadata_from_header(header, "/real/path.safetensors")
assert back["file_path"] == "/real/path.safetensors"
def test_string_fields_preserved(self):
"""ref_text, consent, speaker_description must survive unchanged."""
data = {
"name": "Dave",
"voice_name_lower": "dave",
"consent": "consent-id-42",
"created_at": 1,
"ref_text": "Hello. This is a transcript with punctuation!",
"speaker_description": "A warm baritone voice.",
"embedding_source": "audio",
}
header = OmniOpenAIServingSpeech._speaker_metadata_to_header(data)
back = OmniOpenAIServingSpeech._speaker_metadata_from_header(header, "/x.safetensors")
assert back["ref_text"] == data["ref_text"]
assert back["speaker_description"] == data["speaker_description"]
assert back["consent"] == data["consent"]
def test_malformed_int_is_left_as_string(self):
"""If an int field somehow contains non-numeric text (manual edit,
corruption), the loader does not crash; it leaves the field as-is."""
header = {
"name": "Eve",
"voice_name_lower": "eve",
"consent": "x",
"created_at": "not-a-number",
"embedding_source": "audio",
}
back = OmniOpenAIServingSpeech._speaker_metadata_from_header(header, "/p.safetensors")
# Preserved as string rather than raising.
assert back["created_at"] == "not-a-number"
-129
View File
@@ -1,129 +0,0 @@
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
@@ -30,6 +30,7 @@ from vllm_omni.model_executor.models.omnivoice.config import OmniVoiceConfig
from vllm_omni.model_executor.models.omnivoice.duration import RuleDurationEstimator
from vllm_omni.model_executor.models.omnivoice.omnivoice_decoder import OmniVoiceDecoder
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator
from vllm_omni.utils.speaker_cache import get_speaker_cache
try:
from transformers import HiggsAudioV2TokenizerModel
@@ -101,6 +102,9 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput):
# Duration estimator
self.duration_estimator = RuleDurationEstimator()
# Speaker cache for ref_audio_tokens
self._speaker_cache = get_speaker_cache()
# Generation parameters
self.num_step = self.config.num_step
self.guidance_scale = self.config.guidance_scale
@@ -144,10 +148,12 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput):
lang = "None"
instruct = "None"
voice_name = None
if isinstance(prompt, dict):
text = prompt.get("input", prompt.get("text", str(prompt)))
ref_audio = prompt.get("ref_audio")
ref_text = prompt.get("ref_text")
voice_name = prompt.get("voice_name")
lang = prompt.get("lang") or "None"
instruct = prompt.get("instruct") or "None"
else:
@@ -175,17 +181,37 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput):
text_tokens = torch.tensor(encoding.ids, dtype=torch.long, device=device)
text_len = text_tokens.shape[0]
# Encode reference audio tokens if provided
# Encode reference audio tokens if provided (with voice caching)
ref_audio_tokens = None
if ref_audio is not None:
if self.audio_tokenizer is None:
raise RuntimeError(
"Voice cloning requires transformers>=5.3.0. Try: uv pip install 'transformers>=5.3.0'"
)
audio_signal, sr = ref_audio
if isinstance(audio_signal, np.ndarray):
audio_signal = torch.from_numpy(audio_signal).float()
ref_audio_tokens = self._encode_ref_audio(audio_signal, int(sr)).to(device)
# Check speaker cache first
_cache_key = None
if voice_name:
_cache_key = self._speaker_cache.make_cache_key(
voice_name,
model_type="omnivoice",
created_at=int(prompt.get("voice_created_at") or 0),
)
cached = self._speaker_cache.get(_cache_key)
if cached is not None:
ref_audio_tokens = cached["ref_audio_tokens"].to(device)
_cache_key = None # hit → don't store again
logger.debug("Speaker cache HIT for OmniVoice speaker '%s'", voice_name)
if ref_audio_tokens is None:
audio_signal, sr = ref_audio
if isinstance(audio_signal, np.ndarray):
audio_signal = torch.from_numpy(audio_signal).float()
ref_audio_tokens = self._encode_ref_audio(audio_signal, int(sr)).to(device)
# Store in cache for next request
if _cache_key is not None:
self._speaker_cache.put(_cache_key, {"ref_audio_tokens": ref_audio_tokens.cpu()})
logger.debug("Speaker cache STORE for OmniVoice speaker '%s'", voice_name)
# Build conditional + unconditional batches [2, 8, max_len]
text_ids = text_tokens.unsqueeze(0).repeat(num_cb, 1)
File diff suppressed because it is too large. Load diff
@@ -41,6 +41,7 @@ from vllm_omni.model_executor.models.cosyvoice3.utils import (
extract_text_token,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.utils.speaker_cache import get_speaker_cache
logger = init_logger(__name__)
@@ -104,6 +105,7 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal
providers=["CPUExecutionProvider"],
)
self._cached_model_dir = model_dir
self._speaker_cache = get_speaker_cache()
def _call_hf_processor(
self,
@@ -161,6 +163,28 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal
)
device = "cpu"
# Speaker cache: skip 3 ONNX sessions on cache hit
voice_name = mm_kwargs.get("voice_name")
cache_key = None
if voice_name and isinstance(voice_name, str):
cache_key = self._speaker_cache.make_cache_key(
voice_name,
model_type="cosyvoice3",
created_at=int(mm_kwargs.get("voice_created_at") or 0),
)
cached = self._speaker_cache.get(cache_key)
if cached is not None:
ft = BatchFeature(
{
"input_ids": input_ids,
"speech_feat": cached["speech_feat"].clone(),
"speech_token": cached["speech_token"].clone(),
"speech_token_len": [cached["speech_token_len"].clone()],
"embedding": cached["embedding"].clone(),
}
)
return ft
speech_token, speech_token_len = extract_speech_token(audio, self.speech_tokenizer, device)
speech_feat, speech_feat_len = extract_speech_feat(audio, self.feat_extractor, device)
@@ -171,6 +195,18 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal
embedding = extract_spk_embedding(audio, self.campplus_session, device)
# Cache the extracted artifacts for named speakers
if cache_key is not None:
self._speaker_cache.put(
cache_key,
{
"speech_feat": speech_feat.detach().cpu(),
"speech_token": speech_token.detach().cpu(),
"speech_token_len": speech_token_len.detach().cpu(),
"embedding": embedding.detach().cpu(),
},
)
ft = BatchFeature(
{
"input_ids": input_ids,
@@ -32,7 +32,7 @@ from vllm.model_executor.models.utils import PPMissingLayer, maybe_prefix
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.utils.voice_cache import VoiceEmbeddingCache
from vllm_omni.utils.speaker_cache import get_speaker_cache
from .configuration_fish_speech import FishSpeechConfig, FishSpeechFastARConfig, FishSpeechSlowARConfig
from .dac_encoder import _load_dac_codec, encode_reference_audio_codes
@@ -262,7 +262,7 @@ class FishSpeechSlowARForConditionalGeneration(nn.Module):
self.register_buffer("_semantic_allowed_mask", semantic_mask, persistent=False)
# In-memory LRU cache for DAC-encoded reference audio codes.
self._voice_cache = VoiceEmbeddingCache()
self._speaker_cache = get_speaker_cache()
# Tokeniser (lazy).
self._tokenizer = None
@@ -539,37 +539,27 @@ class FishSpeechSlowARForConditionalGeneration(nn.Module):
if not isinstance(ref_text, str) or not isinstance(text, str):
raise ValueError("Fish Speech structured voice clone requires string text and ref_text")
# --- Voice cache: reuse DAC codes for uploaded (named) voices ---
_voice_cache_key: str | None = None
_speaker_cache_key: tuple[str, str, int] | None = None
voice_name = info_dict.get("voice_name")
voice_created_at = info_dict.get("voice_created_at")
if isinstance(voice_name, str) and voice_name:
_created_at = float(voice_created_at) if voice_created_at is not None else 0.0
if _created_at <= 0:
logger.warning(
"Voice '%s' has no created_at timestamp; DAC code caching disabled for this request",
voice_name,
_speaker_cache_key = self._speaker_cache.make_cache_key(
voice_name,
model_type="fish_speech",
created_at=int(info_dict.get("voice_created_at") or 0),
)
_cached = self._speaker_cache.get(_speaker_cache_key)
if _cached is not None:
ref_codes_fq = _cached["ref_codes_fq"].to(
device=self.codebook_embeddings.weight.device,
dtype=torch.long,
)
else:
_voice_cache_key = self._voice_cache.make_cache_key(
voice_name,
xvec_only=False,
created_at=_created_at,
logger.debug("Speaker cache HIT for Fish Speech speaker '%s'", voice_name)
return self._apply_codebook_embeddings(
tokenizer,
text,
ref_text,
ref_codes_fq,
)
_cached = self._voice_cache.get(_voice_cache_key)
if _cached is not None:
ref_codes_fq = _cached["ref_codes_fq"].to(
device=self.codebook_embeddings.weight.device,
dtype=torch.long,
)
_voice_cache_key = None # hit → don't store again
logger.debug("Voice cache HIT for Fish Speech voice '%s'", voice_name)
return self._apply_codebook_embeddings(
tokenizer,
text,
ref_text,
ref_codes_fq,
)
if not isinstance(ref_audio_sr, int):
raise ValueError("Fish Speech structured voice clone requires integer ref_audio_sr")
@@ -590,12 +580,12 @@ class FishSpeechSlowARForConditionalGeneration(nn.Module):
)
# Cache miss: store DAC codes for future reuse.
if _voice_cache_key is not None:
self._voice_cache.put(
_voice_cache_key,
if _speaker_cache_key is not None:
self._speaker_cache.put(
_speaker_cache_key,
{"ref_codes_fq": ref_codes_fq.detach().cpu()},
)
logger.debug("Voice cache STORE for Fish Speech voice '%s'", voice_name)
logger.debug("Speaker cache STORE for Fish Speech speaker '%s'", voice_name)
return self._apply_codebook_embeddings(tokenizer, text, ref_text, ref_codes_fq)
@@ -29,7 +29,7 @@ from vllm.sequence import IntermediateTensors
from vllm_omni.data_entry_keys import OmniPayload
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.utils.audio import mel_filter_bank
from vllm_omni.utils.voice_cache import VoiceEmbeddingCache
from vllm_omni.utils.speaker_cache import get_speaker_cache
from .configuration_qwen3_tts import Qwen3TTSConfig, Qwen3TTSSpeakerEncoderConfig, Qwen3TTSTalkerConfig
from .qwen3_tts_code_predictor_vllm import Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM
@@ -428,8 +428,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
self._tokenizer = None
self._speech_tokenizer: Qwen3TTSTokenizer | None = None
# In-memory LRU cache for voice extraction artifacts (Base voice clone).
self._voice_cache = VoiceEmbeddingCache()
self._speaker_cache = get_speaker_cache()
raw_subtalker_sampling = getattr(vllm_config.model_config, "subtalker_sampling_params", None)
self._subtalker_sampling_params: dict[str, Any] = (
dict(raw_subtalker_sampling) if isinstance(raw_subtalker_sampling, Mapping) else {}
@@ -1364,23 +1363,36 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
in_context_mode = not xvec_only
voice_clone_prompt = _normalize_voice_clone_prompt(info_dict.get("voice_clone_prompt"))
# Voice cache: only for uploaded voices (created_at > 0)
_voice_cache_key = None
# Speaker cache: only for uploaded (named) speakers
_speaker_cache_key = None
if voice_clone_prompt is None:
_speaker_list = info_dict.get("speaker")
if isinstance(_speaker_list, list) and _speaker_list:
_voice_name = str(_speaker_list[0]).lower()
_voice_created_at = float((info_dict.get("voice_created_at") or [0])[0])
if _voice_created_at > 0:
_voice_cache_key = self._voice_cache.make_cache_key(_voice_name, xvec_only, _voice_created_at)
_cached = self._voice_cache.get(_voice_cache_key) if _voice_cache_key is not None else None
# Per-mode namespace — xvec and icl produce different artifacts
# for the same voice, so they must not share a cache slot.
_mode = "xvec" if xvec_only else "icl"
_voice_created_at = int((info_dict.get("voice_created_at") or [0])[0])
_speaker_cache_key = self._speaker_cache.make_cache_key(
_voice_name,
model_type=f"qwen3_tts_{_mode}",
created_at=_voice_created_at,
)
_cached = self._speaker_cache.get(_speaker_cache_key)
if _cached is not None:
# Transfer cached tensors to current device
ref_code_cached = _cached.get("ref_code")
ref_spk_embed_cached = _cached.get("ref_spk_embedding")
if isinstance(ref_code_cached, torch.Tensor):
ref_code_cached = ref_code_cached.to(device=input_ids.device)
if isinstance(ref_spk_embed_cached, torch.Tensor):
ref_spk_embed_cached = ref_spk_embed_cached.to(device=input_ids.device)
voice_clone_prompt = {
"ref_code": _cached.get("ref_code"),
"ref_spk_embedding": _cached.get("ref_spk_embedding"),
"ref_code": ref_code_cached,
"ref_spk_embedding": ref_spk_embed_cached,
"icl_mode": _cached.get("icl_mode"),
}
_voice_cache_key = None # hit -> don't store again
_speaker_cache_key = None # hit → don't store again
# Official implementation may pass `voice_clone_prompt.icl_mode`.
if voice_clone_prompt is not None and "icl_mode" in voice_clone_prompt:
@@ -1432,9 +1444,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
speaker_embed = self._extract_speaker_embedding(wav_np, sr).view(1, 1, -1)
# Cache miss: store extraction result
if _voice_cache_key is not None and speaker_embed is not None:
self._voice_cache.put(
_voice_cache_key,
if _speaker_cache_key is not None and speaker_embed is not None:
self._speaker_cache.put(
_speaker_cache_key,
{
"ref_code": ref_code_prompt.detach().cpu()
if isinstance(ref_code_prompt, torch.Tensor)
@@ -34,6 +34,7 @@ from vllm.multimodal.audio import AudioResampler
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.utils.speaker_cache import get_speaker_cache
from .minicpm4_paged import MiniCPM4PagedForVoxCPM2, MiniCPM4PagedResidualLM
from .voxcpm2_import_utils import import_voxcpm2_core
@@ -446,6 +447,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._max_decode_steps = 2000
self._max_batch_size = getattr(vllm_config.scheduler_config, "max_num_seqs", 4)
# Speaker cache for ref_audio_feat across requests
self._speaker_cache = get_speaker_cache()
self._perf = _PerfTimer(enabled=_ENABLE_PROFILING)
self._cfm_buffers: _CFMBufferManager | None = None
self._enable_cuda_graph = True
@@ -1165,15 +1169,48 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
prompt_text = prompt_text[0] if prompt_text else None
state.prompt_cache = None
voice_name = info_dict.get("voice_name")
if isinstance(voice_name, list):
voice_name = voice_name[0] if voice_name else None
_created_at = int(info_dict.get("voice_created_at") or 0)
if ref_audio or (prompt_audio and prompt_text):
try:
state.prompt_cache = self._build_prompt_cache(
ref_audio=ref_audio,
prompt_audio=prompt_audio,
prompt_text=prompt_text,
# Check speaker cache for reference-only mode
if voice_name and ref_audio and not prompt_audio:
_cache_key = self._speaker_cache.make_cache_key(
voice_name, model_type="voxcpm2", created_at=_created_at
)
except Exception as e:
logger.warning("build_prompt_cache failed: %s", e)
cached = self._speaker_cache.get(_cache_key)
if cached is not None:
state.prompt_cache = {
"mode": "reference",
"ref_audio_feat": cached["ref_audio_feat"].clone(),
}
logger.debug("Speaker cache HIT for VoxCPM2 speaker '%s'", voice_name)
if state.prompt_cache is None:
try:
state.prompt_cache = self._build_prompt_cache(
ref_audio=ref_audio,
prompt_audio=prompt_audio,
prompt_text=prompt_text,
)
if (
voice_name
and state.prompt_cache is not None
and state.prompt_cache.get("mode") == "reference"
and "ref_audio_feat" in state.prompt_cache
):
_key = self._speaker_cache.make_cache_key(
voice_name, model_type="voxcpm2", created_at=_created_at
)
self._speaker_cache.put(
_key, {"ref_audio_feat": state.prompt_cache["ref_audio_feat"].cpu()}
)
logger.debug("Speaker cache STORE for VoxCPM2 speaker '%s'", voice_name)
except Exception as e:
logger.warning("build_prompt_cache failed: %s; falling back to zero-shot", e)
state.prompt_cache = None
inputs = self._build_prefill_inputs(token_ids, dev, req_id)
tts = self.tts
+136
View File
@@ -0,0 +1,136 @@
"""Process-wide thread-safe LRU cache for speaker extraction artifacts.
Keyed by ``(model_type, speaker_name, created_at)`` so each upload generation
has its own slot. Access via :func:`get_speaker_cache`.
"""
from __future__ import annotations
import threading
from collections import OrderedDict
from typing import Any
import torch
from vllm.logger import init_logger
logger = init_logger(__name__)
_MAX_BYTES = 512 * 1024**2 # 512 MiB
_SINGLETON: SpeakerEmbeddingCache | None = None
_SINGLETON_LOCK = threading.Lock()
def _estimate_tensor_bytes(obj: object) -> int:
if isinstance(obj, torch.Tensor):
return obj.numel() * obj.element_size()
if isinstance(obj, dict):
return sum(_estimate_tensor_bytes(v) for v in obj.values())
if isinstance(obj, (list, tuple)):
return sum(_estimate_tensor_bytes(item) for item in obj)
return 0
class SpeakerEmbeddingCache:
"""Thread-safe in-memory LRU cache for speaker extraction artifacts."""
def __init__(self, *, max_bytes: int = _MAX_BYTES):
self._cache: OrderedDict[tuple[str, str, int], dict[str, Any]] = OrderedDict()
self._sizes: dict[tuple[str, str, int], int] = {}
self._total_bytes = 0
self._lock = threading.Lock()
self._hits = 0
self._misses = 0
self._max_bytes = max_bytes
logger.info("Speaker cache ready (max_bytes=%d)", self._max_bytes)
@staticmethod
def make_cache_key(speaker_name: str, model_type: str, created_at: int = 0) -> tuple[str, str, int]:
"""Build a cache key. ``created_at=0`` for built-in speakers (no upload).
Names are normalized (stripped + lowercased) so delete/clear paths that
normalize to lowercase match entries put with mixed-case names.
"""
if not speaker_name or not speaker_name.strip():
raise ValueError("speaker_name is required")
if not model_type:
raise ValueError("model_type is required")
return (model_type, speaker_name.strip().lower(), int(created_at))
def get(self, key: tuple[str, str, int]) -> dict[str, Any] | None:
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
self._hits += 1
return self._cache[key]
self._misses += 1
return None
def put(self, key: tuple[str, str, int], artifacts: dict[str, Any]) -> None:
with self._lock:
self._insert_locked(key, artifacts)
def _insert_locked(self, key: tuple[str, str, int], artifacts: dict[str, Any]) -> None:
size = _estimate_tensor_bytes(artifacts)
if size > self._max_bytes:
logger.warning("Speaker cache skip: entry %s size=%dB exceeds max_bytes=%dB", key, size, self._max_bytes)
return
if key in self._cache:
self._total_bytes -= self._sizes.pop(key, 0)
del self._cache[key]
self._cache[key] = artifacts
self._sizes[key] = size
self._total_bytes += size
self._cache.move_to_end(key)
while self._cache and self._total_bytes > self._max_bytes:
evict_key, _ = self._cache.popitem(last=False)
self._total_bytes -= self._sizes.pop(evict_key, 0)
logger.debug("Speaker cache EVICT: key=%s", evict_key)
def clear(self, speaker_name: str | None = None) -> int:
"""Remove entries. With a name, drops matches across model types and generations."""
with self._lock:
if speaker_name is None:
removed = len(self._cache)
self._cache.clear()
self._sizes.clear()
self._total_bytes = 0
self._hits = 0
self._misses = 0
return removed
if not speaker_name or not speaker_name.strip():
raise ValueError("speaker_name cannot be an empty string")
normalized = speaker_name.strip().lower()
removed = 0
for k in list(self._cache.keys()):
if isinstance(k, tuple) and len(k) >= 2 and k[1] == normalized:
self._total_bytes -= self._sizes.pop(k, 0)
del self._cache[k]
removed += 1
return removed
def memory_bytes(self) -> int:
with self._lock:
return self._total_bytes
def stats(self) -> dict[str, Any]:
with self._lock:
return {
"entries": len(self._cache),
"memory_bytes": self._total_bytes,
"max_bytes": self._max_bytes,
"memory_mb": round(self._total_bytes / (1024 * 1024), 2),
"hits": self._hits,
"misses": self._misses,
}
def get_speaker_cache() -> SpeakerEmbeddingCache:
"""Return the process-wide speaker cache singleton."""
global _SINGLETON
if _SINGLETON is None:
with _SINGLETON_LOCK:
if _SINGLETON is None:
_SINGLETON = SpeakerEmbeddingCache()
return _SINGLETON
-89
View File
@@ -1,89 +0,0 @@
"""In-memory LRU cache for voice extraction artifacts.
Keyed by voice name + extraction mode (e.g. ``"alice:icl"``).
Only named voices are cached; inline ``ref_audio`` without a voice
name is not cached.
Usage::
key = VoiceEmbeddingCache.make_cache_key("alice", xvec_only=False)
cached = cache.get(key)
if cached is None:
# ... extract ...
cache.put(key, {"artifact": result})
"""
import os
import threading
from collections import OrderedDict
from typing import Any
from vllm.logger import init_logger
logger = init_logger(__name__)
_DEFAULT_MAX_ENTRIES = 128
class VoiceEmbeddingCache:
"""LRU cache for voice extraction outputs.
Each entry stores a ``dict[str, Any]`` whose contents are model-specific.
Thread-safe via a lightweight ``threading.Lock``.
"""
def __init__(self, max_entries: int | None = None):
if max_entries is None:
max_entries = int(os.environ.get("VOICE_CACHE_MAX_ENTRIES", _DEFAULT_MAX_ENTRIES))
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
self._max_entries = max_entries
self._lock = threading.Lock()
self._hits = 0
self._misses = 0
logger.info("Voice embedding cache initialized (max_entries=%d)", max_entries)
@staticmethod
def make_cache_key(voice_name: str, xvec_only: bool, created_at: float = 0.0) -> str:
"""Build a cache key from a voice name, upload timestamp, and extraction mode.
Args:
voice_name: The speaker/voice name (case-insensitive, lowered
by the caller).
xvec_only: True for speaker-embedding-only mode, False for
ICL mode (speaker embedding + ref_code).
created_at: Upload timestamp from metadata. Prevents stale cache
hits after a voice is deleted and re-uploaded with the same
name but different audio.
"""
mode = "xvec" if xvec_only else "icl"
return f"{voice_name}:{created_at:.6f}:{mode}"
def get(self, key: str) -> dict[str, Any] | None:
"""Return cached artifacts or ``None`` on miss. Promotes to MRU on hit."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
self._hits += 1
logger.debug("Voice cache HIT (key=%s, hits=%d)", key, self._hits)
return self._cache[key]
self._misses += 1
return None
def put(self, key: str, artifacts: dict[str, Any]) -> None:
"""Store *artifacts* under *key*, evicting the LRU entry if full."""
with self._lock:
self._cache[key] = artifacts
self._cache.move_to_end(key)
while len(self._cache) > self._max_entries:
evicted_key, _ = self._cache.popitem(last=False)
logger.debug("Voice cache EVICT (key=%s)", evicted_key)
def stats(self) -> dict[str, int]:
"""Return cache statistics."""
with self._lock:
return {
"entries": len(self._cache),
"max_entries": self._max_entries,
"hits": self._hits,
"misses": self._misses,
}