[Bugfix] Invalidate stale ref_audio cache on local file modification (#4873) (#5670)

Signed-off-by: pranavthakur0-0 <pranavthakurdew@gmail.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
This commit is contained in:
Pranav ThakurandZhou Taichang authored and GitHub committed 2026-08-25 10:06:32 +08:00
1 parent 50b8a150f9
commit 53b60bd9bc
10 files changed
+675 -103

No files matched your search

+1 -1
View File
@@ -131,7 +131,7 @@ Content-Type: application/json
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ref_audio` | string | null | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`) |
| `ref_audio` | string | null | Reference audio (HTTP URL, base64 data URL, or `file://` URI with `--allowed-local-media-path`). Local files fold `mtime_ns` and `size` into cache keys to automatically reload on-disk edits; HTTP URLs and base64 URIs remain cached by string locator. |
| `ref_text` | string | null | Transcript of reference audio |
| `x_vector_only_mode` | bool | null | Use speaker embedding only (no ICL) |
@@ -1237,10 +1237,364 @@ class TestTTSMethods:
assert second[1] == 24000
assert first[0] is second[0]
assert first[0][0] == pytest.approx(float(wav[0]), abs=1e-4)
cache_key = first[2]
assert speech_server._get_resolved_ref_audio_artifact_key(
ref_audio
cache_key
) == speech_server._make_ref_audio_artifact_cache_key(np.asarray(first[0], dtype=np.float32), 24000)
# ── ref-audio cache key tests (static helper) ──
def test_local_file_cache_key_invalidation(self):
"""Modified file must produce a different cache key (mtime change)."""
import pathlib
import tempfile
import time as _time
with tempfile.TemporaryDirectory() as tmp_dir:
test_file = pathlib.Path(tmp_dir) / "test_audio.wav"
test_file.write_text("dummy audio data 1")
file_uri = f"file://{test_file}"
key1 = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
file_uri,
allowed_local_media_path=tmp_dir,
)
_time.sleep(0.01)
test_file.write_text("dummy audio data 2")
key2 = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
file_uri,
allowed_local_media_path=tmp_dir,
)
assert key1 != key2, "Cache key should change when file is modified!"
def test_remote_url_cache_key(self):
"""Remote URLs must produce a stable, repeatable cache key."""
url = "https://example.com/audio.wav"
key1 = OmniOpenAIServingSpeech._get_ref_audio_cache_key(url)
key2 = OmniOpenAIServingSpeech._get_ref_audio_cache_key(url)
assert key1 == key2, "Cache key for URLs should remain constant!"
def test_missing_file_cache_key(self, caplog):
"""A missing file falls back to the URI-string hash and emits a log
so the stale-cache risk is visible."""
file_uri = "file:///path/to/nonexistent/file.wav"
target_logger = logging.getLogger("vllm_omni.entrypoints.openai.serving_speech")
target_logger.addHandler(caplog.handler)
prev_level = target_logger.level
target_logger.setLevel(logging.DEBUG)
try:
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
file_uri,
allowed_local_media_path="/path/to/nonexistent",
)
finally:
target_logger.removeHandler(caplog.handler)
target_logger.setLevel(prev_level)
expected_key = hashlib.sha1(file_uri.encode("utf-8")).hexdigest()
assert key == expected_key, "Missing files should fallback to the string hash."
assert any("stale cache" in r.getMessage() for r in caplog.records), (
"A log message about stale cache must be emitted when os.stat fails"
)
def test_percent_encoded_file_uri(self):
"""Percent-encoded file:// URIs must be decoded so os.stat hits the real file."""
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
test_file = pathlib.Path(tmp_dir) / "my audio.wav"
test_file.write_text("dummy audio data")
encoded_uri = f"file://{str(test_file).replace(' ', '%20')}"
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
encoded_uri,
allowed_local_media_path=tmp_dir,
)
string_only_key = hashlib.sha1(encoded_uri.encode("utf-8")).hexdigest()
assert key != string_only_key, (
"Percent-encoded URI should be decoded and stat'd, not fall back to string-only cache key!"
)
def test_no_allowlist_skips_stat_for_local_paths(self):
"""When allowed_local_media_path is None, local paths must NOT be stat'd
— the key falls back to the string-only hash."""
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
test_file = pathlib.Path(tmp_dir) / "test.wav"
test_file.write_text("data")
file_uri = f"file://{test_file}"
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
file_uri,
allowed_local_media_path=None,
)
# With no allowlist the stat is skipped, so the key is just sha1(uri)
expected = hashlib.sha1(file_uri.encode("utf-8")).hexdigest()
assert key == expected, "No allowlist should skip stat and use string-only key"
def test_empty_string_allowlist_skips_stat_for_local_paths(self):
"""vLLM's default allowed_local_media_path is '' not None; that must
also skip stat rather than treating cwd as the allowlist."""
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
test_file = pathlib.Path(tmp_dir) / "test.wav"
test_file.write_text("data")
file_uri = f"file://{test_file}"
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
file_uri,
allowed_local_media_path="",
)
expected = hashlib.sha1(file_uri.encode("utf-8")).hexdigest()
assert key == expected, "Empty-string allowlist should skip stat and use string-only key"
def test_uppercase_file_uri_uses_local_metadata(self):
"""FILE:// must be treated as a local file, not a string-only locator."""
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
test_file = pathlib.Path(tmp_dir) / "test.wav"
test_file.write_text("data")
upper_uri = f"FILE://{test_file}"
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(
upper_uri,
allowed_local_media_path=tmp_dir,
)
string_only = hashlib.sha1(upper_uri.encode("utf-8")).hexdigest()
assert key != string_only
def test_uppercase_http_uri_is_remote(self, speech_server):
url = "HTTP://example.com/audio.wav"
key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(url)
assert key == hashlib.sha1(url.encode("utf-8")).hexdigest()
assert speech_server._validate_ref_audio_format(url) is None
assert speech_server._validate_ref_audio_format("FILE:///tmp/a.wav") is None
assert speech_server._validate_ref_audio_format("/tmp/a.wav") is not None
@pytest.mark.asyncio
async def test_resolve_ref_audio_retries_when_file_changes_during_fetch(self, speech_server, mocker):
import pathlib
import tempfile
wav = np.linspace(-0.5, 0.5, 48000, dtype=np.float32)
with tempfile.TemporaryDirectory() as tmp_dir:
path = pathlib.Path(tmp_dir) / "spk.wav"
path.write_bytes(b"v1")
uri = f"file://{path}"
speech_server.model_config.allowed_local_media_path = tmp_dir
speech_server.model_config.allowed_media_domains = None
speech_server._diffusion_mode = False
fetches = {"n": 0}
async def fake_fetch(_ref):
fetches["n"] += 1
if fetches["n"] == 1:
path.write_bytes(b"v2-longer-content")
return wav, 24000
mock_connector = mocker.MagicMock()
mock_connector.fetch_audio_async = fake_fetch
mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.MediaConnector",
return_value=mock_connector,
)
wav_list, sr, cache_key = await speech_server._resolve_ref_audio(uri)
expected_key = OmniOpenAIServingSpeech._get_ref_audio_cache_key(uri, allowed_local_media_path=tmp_dir)
assert sr == 24000
assert len(wav_list) == 48000
assert fetches["n"] == 2
assert cache_key == expected_key
assert cache_key in speech_server._ref_audio_resolve_cache
def test_conditioning_cache_salt_changes_with_ref_audio_cache_key(self):
"""Prefix-cache salt must fold the content-aware resolve key so a
same-path file rewrite cannot reuse KV from the previous reference."""
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
req = OpenAICreateSpeechRequest(input="hello", ref_audio="file:///data/spk.wav")
salt_a = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_aaa"})
salt_b = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_bbb"})
salt_locator_only = _conditioning_cache_salt(req, {})
assert salt_a != salt_b
assert salt_a != salt_locator_only
@pytest.mark.asyncio
async def test_higgs_v3_cache_salt_changes_with_ref_audio_cache_key(self, speech_server, mocker):
"""Higgs v3 voice clone uses placeholder prompt ids + prefix caching.
The salt must move when the resolve key moves, or a same-path rewrite
reuses KV from the previous reference."""
from unittest.mock import AsyncMock, MagicMock
adapter = MagicMock()
adapter.build_prompt.return_value = [1, 1, 1, 1]
speech_server._resolve_higgs_audio_v3_adapter = AsyncMock(return_value=adapter)
codes = torch.arange(8, dtype=torch.long).view(8, 1)
speech_server._resolve_higgs_audio_v3_ref_codes = AsyncMock(return_value=(codes, False, False))
speech_server._get_resolved_ref_audio_artifact_key = MagicMock(return_value="art")
req = OpenAICreateSpeechRequest(
input="hello",
ref_audio="file:///data/spk.wav",
ref_text="transcript",
)
speech_server._resolve_ref_audio = AsyncMock(return_value=([0.1] * 48000, 24000, "key_aaa"))
prompt_a = await speech_server._build_higgs_audio_v3_params(req)
speech_server._resolve_ref_audio = AsyncMock(return_value=([0.9] * 48000, 24000, "key_bbb"))
prompt_b = await speech_server._build_higgs_audio_v3_params(req)
assert prompt_a["prompt_token_ids"] == prompt_b["prompt_token_ids"]
assert prompt_a["cache_salt"] != prompt_b["cache_salt"]
assert prompt_a["additional_information"]["ref_audio_cache_key"] == "key_aaa"
assert prompt_b["additional_information"]["ref_audio_cache_key"] == "key_bbb"
# ── encode_reference_codes: speaker cache invalidation ──
@pytest.mark.asyncio
async def test_encode_reference_codes_reencodes_on_key_change(self):
"""Changing the resolve_cache_key must trigger a fresh encode, not serve
the stale cached tensor. This is the user-visible fix for delay-family
voice clones that were reproducing the old speaker."""
from unittest.mock import MagicMock
from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_reference_codes
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
speaker_cache = SpeakerEmbeddingCache(max_bytes=64 * 1024 * 1024)
call_count = 0
codes_v1 = torch.arange(10, dtype=torch.int64)
codes_v2 = torch.arange(10, 20, dtype=torch.int64)
# Stub processor that tracks call count and returns different codes
processor = MagicMock()
def fake_encode(wavs, sampling_rate, n_vq):
nonlocal call_count
call_count += 1
return [codes_v1 if call_count == 1 else codes_v2]
processor.encode_audios_from_wav = fake_encode
# resolve_ref_audio returns different cache keys on successive calls
resolve_keys = iter(["key_aaa", "key_bbb"])
async def mock_resolve(ref_str):
return ([0.1, 0.2, 0.3], 24000, next(resolve_keys))
# First call: cold miss, encodes and stores
result1 = await encode_reference_codes(
"data:audio/wav;base64,fake",
processor=processor,
resolve_ref_audio=mock_resolve,
speaker_cache=speaker_cache,
variant="tts",
n_vq=32,
sr_target=24000,
)
assert call_count == 1
assert torch.equal(result1, codes_v1)
# Second call: different resolve key → must re-encode, not serve cached
result2 = await encode_reference_codes(
"data:audio/wav;base64,fake",
processor=processor,
resolve_ref_audio=mock_resolve,
speaker_cache=speaker_cache,
variant="tts",
n_vq=32,
sr_target=24000,
)
assert call_count == 2, "Different resolve key should trigger re-encode"
assert torch.equal(result2, codes_v2)
@pytest.mark.asyncio
async def test_encode_reference_codes_named_voice_skips_resolve_on_hit(self):
"""Named-voice cache hit must NOT call resolve_ref_audio."""
from unittest.mock import MagicMock
from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_reference_codes
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
speaker_cache = SpeakerEmbeddingCache(max_bytes=64 * 1024 * 1024)
processor = MagicMock()
processor.encode_audios_from_wav = lambda wavs, sampling_rate, n_vq: [torch.zeros(5)]
resolve_called = 0
async def mock_resolve(ref_str):
nonlocal resolve_called
resolve_called += 1
return ([0.1], 24000, "some_key")
# First call: cold miss, resolves + encodes
await encode_reference_codes(
"data:audio/wav;base64,fake",
processor=processor,
resolve_ref_audio=mock_resolve,
speaker_cache=speaker_cache,
variant="tts",
n_vq=32,
sr_target=24000,
voice_name="alice",
voice_created_at=100,
)
assert resolve_called == 1
# Second call: warm hit, must NOT resolve
await encode_reference_codes(
"data:audio/wav;base64,fake",
processor=processor,
resolve_ref_audio=mock_resolve,
speaker_cache=speaker_cache,
variant="tts",
n_vq=32,
sr_target=24000,
voice_name="alice",
voice_created_at=100,
)
assert resolve_called == 1, "Named-voice cache hit should skip resolve_ref_audio"
@pytest.mark.asyncio
async def test_ttsd_second_reference_does_not_use_named_voice(self, speech_server, mocker):
"""Uploaded voice names speaker 1 only; speaker 2 must stay anonymous."""
speech_server._moss_variant = "ttsd"
speech_server.uploaded_speakers = {"alice": {}}
mocker.patch.object(speech_server, "_voice_created_at", return_value=42)
proc = mocker.MagicMock()
proc.model_config.n_vq = 8
proc.model_config.sampling_rate = 24000
proc.build_user_message.return_value = "msg"
proc.return_value = {"input_ids": [torch.zeros((4, 9), dtype=torch.int64)]}
mocker.patch.object(speech_server, "_get_moss_processor", return_value=proc)
seen: list[tuple[str, str | None]] = []
async def fake_encode(ref_str, **kwargs):
seen.append((ref_str, kwargs.get("voice_name")))
return torch.zeros(3, dtype=torch.int64)
mocker.patch(
"vllm_omni.model_executor.models.moss_tts.reference_encoder.encode_reference_codes",
fake_encode,
)
req = OpenAICreateSpeechRequest(
input="hello",
voice="alice",
ref_audio="data:audio/wav;base64,aaa",
ref_audio_2="data:audio/wav;base64,bbb",
)
await speech_server._build_moss_tts_params(req, has_inline_ref_audio=False)
assert seen == [
("data:audio/wav;base64,aaa", "alice"),
("data:audio/wav;base64,bbb", None),
]
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"
@@ -1896,7 +2250,7 @@ class TestTTSMethods:
mocker.patch.object(
speech_server,
"_resolve_ref_audio",
new=mocker.AsyncMock(return_value=ref_audio_data),
new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")),
)
mock_prompt = mocker.patch.object(
speech_server,
@@ -2013,7 +2367,7 @@ class TestTTSMethods:
mocker.patch.object(
speech_server,
"_resolve_ref_audio",
new=mocker.AsyncMock(return_value=ref_audio_data),
new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")),
)
mock_prompt = mocker.patch.object(
speech_server,
@@ -4382,7 +4736,7 @@ class TestTTSAsyncOffloading:
):
"""Base explicit true should reach the model prompt additional_information."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0] * 48000, 24000))
qwen3_tts_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0] * 48000, 24000, "fake_cache_key"))
qwen3_tts_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value=None)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
@@ -183,6 +183,13 @@ def test_indextts25_speed_does_not_change_conditioning_cache_salt():
) == indextts2_conditioning_cache_salt(slow_request, slow_params)
def test_indextts2_conditioning_cache_salt_changes_with_ref_audio_cache_key():
request = SimpleNamespace(input="hello", ref_audio="file:///data/spk.wav")
salt_a = indextts2_conditioning_cache_salt(request, {"ref_audio_cache_key": ["key_aaa"]})
salt_b = indextts2_conditioning_cache_salt(request, {"ref_audio_cache_key": ["key_bbb"]})
assert salt_a != salt_b
@pytest.mark.parametrize(
("hf_config", "expected_tokenizer_file"),
[
+256 -75
View File
@@ -13,6 +13,8 @@ from concurrent.futures import ThreadPoolExecutor
from http import HTTPStatus
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.request import url2pathname
import numpy as np
import soundfile as sf
@@ -98,6 +100,8 @@ _COSYVOICE3_PROMPT_PREFIX = f"You are a helpful assistant.{_COSYVOICE3_PROMPT_DE
_AUDEX_NO_AUDIO_GUARD_MODEL_TYPES = frozenset({"audex", "audex_tta"})
_REF_AUDIO_MIN_DURATION = 1.0 # seconds
_REF_AUDIO_MAX_DURATION = 30.0 # seconds
_REF_AUDIO_METADATA_FETCH_ATTEMPTS = 3
_REMOTE_REF_AUDIO_SCHEMES = frozenset({"http", "https", "data"})
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES = 256 * 1024 * 1024
_HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES = 256
@@ -228,6 +232,13 @@ def _conditioning_cache_salt(request, tts_params: dict | None = None) -> str:
must also be folded in. ``voice_created_at`` bumps on every (re-)upload,
which uniquely identifies the resolved reference artifact together with
the voice name; the decoded ref_audio array itself need not be hashed.
Local ``ref_audio`` locators are the same class of gap: the raw request
hashes the path/URI string, so an on-disk rewrite is invisible to the
salt. Callers stash the content-aware key from ``_resolve_ref_audio`` as
``ref_audio_cache_key`` (and ``ref_audio_2_cache_key`` / emotion keys
when those clips exist) so a file edit cannot reuse KV from the previous
reference.
"""
h = hashlib.sha256()
for part in (
@@ -245,13 +256,15 @@ def _conditioning_cache_salt(request, tts_params: dict | None = None) -> str:
if part is not None:
h.update(repr(part).encode("utf-8"))
# Fold resolved conditioning that is auto-derived for uploaded voices and
# absent from the raw request.
# absent from the raw request (or that a locator string cannot see).
for key in (
"voice_created_at",
"task_type",
"speaker",
"ref_text",
"x_vector_only_mode",
"ref_audio_cache_key",
"ref_audio_2_cache_key",
):
h.update(b"\x00")
value = tts_params.get(key) if tts_params is not None else None
@@ -803,7 +816,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
ref_sr = None
voice_profile = None
if request.ref_audio is not None:
ref_audio, ref_sr = await self._resolve_ref_audio(request.ref_audio)
ref_audio, ref_sr, _ = await self._resolve_ref_audio(request.ref_audio)
elif uploaded_ref is not None:
wav_np, ref_sr = uploaded_ref
ref_audio = wav_np.tolist()
@@ -1290,11 +1303,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"""Validate ref_audio is a supported URI format. Returns error or None."""
if not isinstance(ref_audio, str):
return "ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)"
if not (
ref_audio.startswith(("http://", "https://"))
or ref_audio.startswith("data:")
or ref_audio.startswith("file://")
):
scheme = (urlparse(ref_audio).scheme or "").lower()
if scheme not in {"http", "https", "data", "file"}:
return "ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)"
return None
@@ -1343,7 +1353,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._moss_processor_cache = proc
return proc
async def _build_moss_tts_params(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]:
async def _build_moss_tts_params(
self,
request: OpenAICreateSpeechRequest,
*,
has_inline_ref_audio: bool = False,
) -> dict[str, Any]:
"""Build the talker prompt + ``additional_information`` payload for any
MOSS-TTS-family request (nano + 5 full variants).
@@ -1371,8 +1386,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
params["prompt_audio_array"] = [[wav_list, sr]]
params["ref_audio_cache_key"] = cache_key
return params
# ---- MOSS-TTS-Realtime: keep the old prompt_audio_array path ----
@@ -1390,8 +1406,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
params["prompt_audio_array"] = [[wav_list, sr]]
params["ref_audio_cache_key"] = cache_key
return params
# ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator)
@@ -1422,30 +1439,47 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# only pulls it on the delay-family path (alongside the upstream proc).
from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_reference_codes
_voice = getattr(request, "voice", None)
_voice = _voice.strip() if isinstance(_voice, str) else ""
_voice_created = self._voice_created_at(_voice.lower()) if _voice else 0
# Named-voice speaker cache is only valid for uploaded speakers
# without an inline ref_audio. ``request.voice`` plus a file/URL
# otherwise keys on (name, created_at=0) and skips the content-aware
# resolve — the adapter already applies this guard when tagging
# ``voice_name`` on tts_params.
_raw_voice = getattr(request, "voice", None)
_raw_voice = _raw_voice.strip() if isinstance(_raw_voice, str) else ""
_voice_lower = _raw_voice.lower() if _raw_voice else ""
_use_named_voice = bool(_voice_lower) and _voice_lower in self.uploaded_speakers and not has_inline_ref_audio
_voice = _voice_lower if _use_named_voice else ""
_voice_created = self._voice_created_at(_voice) if _voice else 0
_resolve_keys: list[str] = []
async def _encode_ref(ref_str: str) -> torch.Tensor:
async def _tracked_resolve(ref_str: str) -> tuple[list, int, str]:
wav_list, sr, cache_key = await self._resolve_ref_audio(ref_str)
_resolve_keys.append(cache_key)
return wav_list, sr, cache_key
async def _encode_ref(ref_str: str, *, named_voice: bool) -> torch.Tensor:
# Named-voice cache is (voice_name, created_at). TTSD speaker 2 is a
# different clip, so it must stay anonymous or it reuses speaker 1.
use_named = named_voice and bool(_voice)
return await encode_reference_codes(
ref_str,
processor=proc,
resolve_ref_audio=self._resolve_ref_audio,
resolve_ref_audio=_tracked_resolve,
speaker_cache=self._speaker_cache,
variant=v,
n_vq=n_vq,
sr_target=sr_target,
voice_name=_voice or None,
voice_created_at=_voice_created,
voice_name=_voice if use_named else None,
voice_created_at=_voice_created if use_named else 0,
)
user_kwargs: dict[str, Any] = {"text": request.input or ""}
if v in ("tts", "local"):
user_kwargs["reference"] = [await _encode_ref(request.ref_audio)]
user_kwargs["reference"] = [await _encode_ref(request.ref_audio, named_voice=True)]
elif v == "ttsd":
refs = [await _encode_ref(request.ref_audio)]
refs = [await _encode_ref(request.ref_audio, named_voice=True)]
if request.ref_audio_2:
refs.append(await _encode_ref(request.ref_audio_2))
refs.append(await _encode_ref(request.ref_audio_2, named_voice=False))
user_kwargs["reference"] = refs
elif v == "sound_effect":
user_kwargs["text"] = request.input or "" # may be empty
@@ -1479,6 +1513,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
}
if request.max_new_tokens is not None:
params["max_new_frames"] = [request.max_new_tokens]
if _resolve_keys:
params["ref_audio_cache_key"] = _resolve_keys[0]
if len(_resolve_keys) > 1:
params["ref_audio_2_cache_key"] = _resolve_keys[1]
return params
async def _build_higgs_audio_v2_params(self, request: OpenAICreateSpeechRequest):
@@ -1507,7 +1545,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
prompt_token_ids = input_ids_to_python_list(inputs)
return tokens_input(prompt_token_ids=prompt_token_ids)
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
wav_list, sr, _ = await self._resolve_ref_audio(request.ref_audio)
wav = np.asarray(wav_list, dtype=np.float32)
out = await asyncio.to_thread(
build_voice_clone_prompt,
@@ -1576,8 +1614,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
encode_reference_audio,
)
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
artifact_key = self._get_resolved_ref_audio_artifact_key(request.ref_audio)
wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio)
artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key)
wav = np.asarray(wav_list, dtype=np.float32)
ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes(
artifact_key,
@@ -1599,7 +1637,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
prompt["additional_information"] = {
"audio_input_ids": ref_codes_delayed.to(torch.long),
"audio_input_ids_mask": torch.ones(ref_codes_delayed.shape[0], dtype=torch.bool),
"ref_audio_cache_key": cache_key,
}
# Placeholder prompt ids do not change when a same-path file is
# rewritten at the same duration; fold the content-aware resolve key
# into cache_salt so prefix caching cannot reuse the previous KV.
prompt["cache_salt"] = _conditioning_cache_salt(request, prompt["additional_information"])
return prompt
async def _resolve_higgs_audio_v3_ref_codes(
@@ -1690,43 +1733,100 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._higgs_audio_v3_adapter = adapter
return adapter
async def _resolve_ref_audio(self, ref_audio_str: str) -> tuple[list[float], int]:
"""Resolve ref_audio to (wav_samples, sample_rate).
@staticmethod
def _local_ref_audio_stat_path(ref_audio_str: str) -> str | None:
"""Filesystem path to stat for a local locator, else ``None``.
Delegates to upstream vLLM's MediaConnector which handles http(s)
URLs, ``data:`` base64 URIs, and ``file:`` local paths (the latter
gated by ``--allowed-local-media-path``).
Scheme comparison is case-insensitive (``urlparse`` lowercases it), so
``FILE:///...`` is treated as a local file the same way ``file://`` is.
Remote ``http`` / ``https`` / ``data`` locators, including ``HTTP://``,
return ``None`` and stay string-keyed.
"""
cache_key = hashlib.sha1(ref_audio_str.encode("utf-8")).hexdigest()
cached = self._ref_audio_resolve_cache.get(cache_key)
if cached is not None:
self._ref_audio_resolve_cache.move_to_end(cache_key)
wav_list, sr, _, _ = cached
logger.debug(
"Resolved ref_audio from cache: samples=%d sr=%d duration_s=%.3f",
len(wav_list),
sr,
len(wav_list) / sr if sr > 0 else 0.0,
)
return wav_list, sr
parsed = urlparse(ref_audio_str)
scheme = (parsed.scheme or "").lower()
if scheme in _REMOTE_REF_AUDIO_SCHEMES:
return None
if scheme == "file":
netloc = parsed.netloc or ""
if netloc.lower() not in ("", "localhost"):
raise OSError(f"file:// URI with non-local authority {netloc!r} cannot be stat'd")
return url2pathname(parsed.path or "")
if scheme:
return None
return ref_audio_str
# In diffusion mode, model_config may not be available
if self._diffusion_mode:
connector = MediaConnector()
else:
model_config = self.model_config
connector = MediaConnector(
allowed_local_media_path=model_config.allowed_local_media_path,
allowed_media_domains=model_config.allowed_media_domains,
)
fetch_start_s = time.perf_counter()
wav_np, sr = await connector.fetch_audio_async(ref_audio_str)
fetch_decode_ms = (time.perf_counter() - fetch_start_s) * 1000.0
@staticmethod
def _get_ref_audio_cache_key(
ref_audio_str: str,
allowed_local_media_path: str | None = None,
) -> str:
"""Compute a cache key hash for *ref_audio_str*.
For local files (bare paths and ``file://`` URIs) the key folds in
``st_mtime_ns`` and ``st_size`` so that an on-disk edit automatically
invalidates the cached waveform without a server restart. Remote URLs
and ``data:`` URIs are keyed on the raw string alone — the server does
not re-fetch them to check for changes.
``os.stat`` runs only when *allowed_local_media_path* is a non-empty
path. vLLM's default is ``""`` (not ``None``); both mean local media
is refused, so a stat would be exposure for a request that cannot
load the file.
Note: when the key changes the *previous* entry stays in
``_ref_audio_resolve_cache`` until LRU eviction, so
``_discard_ref_audio_artifact_ready_if_unreferenced`` will not fire for
the replaced file and its stale artifact key remains in
``_ref_audio_model_artifact_ready``. This is functionally correct
(the stale entry is never *used*) but doubles memory until eviction.
"""
cache_key_source = ref_audio_str
try:
path = OmniOpenAIServingSpeech._local_ref_audio_stat_path(ref_audio_str)
except OSError as exc:
path = None
logger.debug("Skipping local-file cache metadata for %s: %s", ref_audio_str[:80], exc)
if path is not None:
try:
# Only stat paths the server operator has explicitly permitted
# via --allowed-local-media-path. The default is "" (and
# diffusion mode passes None); MediaConnector refuses local
# file loads in both cases, so a stat is exposure for zero
# benefit. ``if not`` covers "" and None; ``is None`` would
# treat "" as an allowlist of the process cwd.
if not allowed_local_media_path:
raise OSError("no allowed_local_media_path configured; skipping stat")
resolved = os.path.realpath(path)
allowed_base = os.path.realpath(allowed_local_media_path)
if os.path.commonpath([resolved, allowed_base]) != allowed_base:
raise OSError("path outside allowed_local_media_path; skipping stat")
st = os.stat(path)
cache_key_source = f"{ref_audio_str}:{st.st_mtime_ns}:{st.st_size}"
except (OSError, ValueError):
# Truncate the value to avoid dumping huge base64 blobs into
# the server log when a client omits the ``data:`` prefix.
display = ref_audio_str[:80]
if len(ref_audio_str) > 80:
display += f"... ({len(ref_audio_str)} chars)"
logger.debug(
"Failed to stat ref_audio path %s; falling back to string-only cache key (stale cache possible)",
display,
)
return hashlib.sha1(cache_key_source.encode("utf-8")).hexdigest()
async def _ref_audio_cache_key(self, ref_audio_str: str, allowed_local_media_path: str | None) -> str:
"""Stat local files off the event loop; remote locators stay a cheap hash."""
return await asyncio.to_thread(
self._get_ref_audio_cache_key,
ref_audio_str,
allowed_local_media_path,
)
def _finalize_fetched_ref_audio(self, wav_np: np.ndarray, sr: int) -> tuple[list[float], int, str, float]:
wav_np = np.asarray(wav_np, dtype=np.float32)
if wav_np.ndim > 1:
wav_np = np.mean(wav_np, axis=-1)
sr = int(sr)
artifact_key = self._make_ref_audio_artifact_cache_key(wav_np, sr)
duration = len(wav_np) / sr if sr > 0 else 0.0
if duration < _REF_AUDIO_MIN_DURATION:
raise ValueError(
@@ -1738,19 +1838,92 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
f"Reference audio too long ({duration:.1f}s). "
f"Maximum {_REF_AUDIO_MAX_DURATION:.0f}s supported — use a shorter clip."
)
tolist_start_s = time.perf_counter()
wav_list = wav_np.tolist()
tolist_ms = (time.perf_counter() - tolist_start_s) * 1000.0
logger.debug(
"Resolved ref_audio: fetch_decode_ms=%.3f tolist_ms=%.3f samples=%d sr=%d duration_s=%.3f",
fetch_decode_ms,
tolist_ms,
len(wav_np),
sr,
duration,
artifact_key = self._make_ref_audio_artifact_cache_key(wav_np, sr)
return wav_np.tolist(), sr, artifact_key, duration
async def _resolve_ref_audio(self, ref_audio_str: str) -> tuple[list[float], int, str]:
"""Resolve ref_audio to (wav_samples, sample_rate, cache_key).
Delegates to upstream vLLM's MediaConnector which handles http(s)
URLs, ``data:`` base64 URIs, and ``file:`` local paths (the latter
gated by ``--allowed-local-media-path``).
Local file references incorporate mtime and size into the cache key
so that modified files are automatically reloaded without a server
restart. Remote URLs remain cached by their original string locator.
The returned *cache_key* should be passed to
``_get_resolved_ref_audio_artifact_key`` when the caller needs the
artifact key, avoiding a redundant ``os.stat`` and the TOCTOU window.
After a cache miss the file is re-stat'd; a metadata change retries
the fetch so the stored waveform matches the key.
"""
# Pass the allowed-local-media-path so the stat is restricted to
# paths the server operator has explicitly permitted.
allowed_path = None
if not self._diffusion_mode:
allowed_path = getattr(self.model_config, "allowed_local_media_path", None)
wav_list: list[float] | None = None
sr = 0
artifact_key = ""
post_key = ""
connector: MediaConnector | None = None
for attempt in range(_REF_AUDIO_METADATA_FETCH_ATTEMPTS):
cache_key = await self._ref_audio_cache_key(ref_audio_str, allowed_path)
cached = self._ref_audio_resolve_cache.get(cache_key)
if cached is not None:
self._ref_audio_resolve_cache.move_to_end(cache_key)
wav_list, sr, _, _ = cached
logger.debug(
"Resolved ref_audio from cache: samples=%d sr=%d duration_s=%.3f",
len(wav_list),
sr,
len(wav_list) / sr if sr > 0 else 0.0,
)
return wav_list, sr, cache_key
if connector is None:
# In diffusion mode, model_config may not be available
if self._diffusion_mode:
connector = MediaConnector()
else:
model_config = self.model_config
connector = MediaConnector(
allowed_local_media_path=model_config.allowed_local_media_path,
allowed_media_domains=model_config.allowed_media_domains,
)
fetch_start_s = time.perf_counter()
wav_np, fetched_sr = await connector.fetch_audio_async(ref_audio_str)
fetch_decode_ms = (time.perf_counter() - fetch_start_s) * 1000.0
tolist_start_s = time.perf_counter()
wav_list, sr, artifact_key, duration = self._finalize_fetched_ref_audio(wav_np, fetched_sr)
tolist_ms = (time.perf_counter() - tolist_start_s) * 1000.0
logger.debug(
"Resolved ref_audio: fetch_decode_ms=%.3f tolist_ms=%.3f samples=%d sr=%d duration_s=%.3f",
fetch_decode_ms,
tolist_ms,
len(wav_list),
sr,
duration,
)
post_key = await self._ref_audio_cache_key(ref_audio_str, allowed_path)
if post_key == cache_key:
self._put_resolved_ref_audio(cache_key, wav_list, sr, artifact_key)
return wav_list, sr, cache_key
logger.debug(
"ref_audio metadata changed during fetch (attempt %d/%d); retrying",
attempt + 1,
_REF_AUDIO_METADATA_FETCH_ATTEMPTS,
)
logger.warning(
"ref_audio file changed during fetch after %d attempts; skipping resolve cache",
_REF_AUDIO_METADATA_FETCH_ATTEMPTS,
)
self._put_resolved_ref_audio(cache_key, wav_list, sr, artifact_key)
return wav_list, sr
assert wav_list is not None and post_key
return wav_list, sr, post_key
@staticmethod
def _make_ref_audio_artifact_cache_key(wav: np.ndarray, sr: int) -> str:
@@ -1761,12 +1934,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
h.update(wav_f32.tobytes(order="C"))
return h.hexdigest()
def _get_resolved_ref_audio_artifact_key(self, ref_audio_str: str) -> str | None:
source_key = hashlib.sha1(ref_audio_str.encode("utf-8")).hexdigest()
cached = self._ref_audio_resolve_cache.get(source_key)
def _get_resolved_ref_audio_artifact_key(self, cache_key: str) -> str | None:
"""Look up the artifact key for a previously resolved ref_audio.
*cache_key* must be the exact key returned by the preceding
``_resolve_ref_audio`` call. Passing the key explicitly avoids a
second ``os.stat`` syscall and eliminates the TOCTOU window that
would arise from recomputing the key independently.
"""
cached = self._ref_audio_resolve_cache.get(cache_key)
if cached is None:
return None
self._ref_audio_resolve_cache.move_to_end(source_key)
self._ref_audio_resolve_cache.move_to_end(cache_key)
return cached[3]
def _put_resolved_ref_audio(self, cache_key: str, wav_list: list[float], sr: int, artifact_key: str) -> None:
@@ -1831,7 +2010,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
async def _resolve_ref_audio_many(self, ref_audio_list: list[str]) -> list[tuple[list[float], int]]:
resolved = []
for ref_audio in ref_audio_list:
resolved.append(await self._resolve_ref_audio(ref_audio))
wav_list, sr, _ = await self._resolve_ref_audio(ref_audio)
resolved.append((wav_list, sr))
return resolved
# ---- Ming TTS helpers ----
@@ -2502,7 +2682,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
+ mm_processor_kwargs with prompt_text.
"""
# Resolve reference audio
wav_samples, sr = await self._resolve_ref_audio(request.ref_audio)
wav_samples, sr, _ = await self._resolve_ref_audio(request.ref_audio)
audio_data = (np.asarray(wav_samples, dtype=np.float32), sr)
# Wrap the reference transcript in the CosyVoice3 instruction template
@@ -2616,7 +2796,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"""
# Voice cloning requires ref_audio + ref_text
if request.ref_audio is not None and request.ref_text:
wav_samples, sr = await self._resolve_ref_audio(request.ref_audio)
wav_samples, sr, _ = await self._resolve_ref_audio(request.ref_audio)
audio_data = (np.asarray(wav_samples, dtype=np.float32), int(sr))
mm_kwargs: dict[str, Any] = {
@@ -2748,8 +2928,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
ref_audio_source = tts_params["ref_audio"][0]
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
artifact_key = self._get_resolved_ref_audio_artifact_key(ref_audio_source)
wav_list, sr, cache_key = await self._resolve_ref_audio(ref_audio_source)
tts_params["ref_audio_cache_key"] = cache_key
artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key)
if artifact_key:
tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key]
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
@@ -3142,7 +3323,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
request_id = f"speech-{random_uuid()}"
prompt: dict[str, Any] = {"input": request.input}
if request.ref_audio:
wav, sr = await self._resolve_ref_audio(request.ref_audio)
wav, sr, _ = await self._resolve_ref_audio(request.ref_audio)
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
if request.ref_text:
prompt["ref_text"] = request.ref_text
@@ -49,13 +49,14 @@ class FishSpeechAdapter(ARTTSAdapter):
) -> PreparedRequest:
server = self.ctx.server
ref_audio_data = None
tts_params: dict = {}
if request.ref_audio is not None:
wav_list, sr = await server._resolve_ref_audio(request.ref_audio)
wav_list, sr, cache_key = await server._resolve_ref_audio(request.ref_audio)
ref_audio_data = (wav_list, sr)
tts_params["ref_audio_cache_key"] = cache_key
prompt = await server._build_fish_speech_prompt_async(
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
)
tts_params = {}
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="fish_speech")
@@ -108,6 +108,8 @@ def indextts2_conditioning_cache_salt(
"use_random",
"lang",
"text_normalization",
"ref_audio_cache_key",
"emo_audio_cache_key",
):
h.update(b"\x00")
h.update(key.encode("utf-8"))
@@ -260,8 +262,9 @@ class IndexTTS2Adapter(ARTTSAdapter):
ref_audio_source = server._get_uploaded_audio_data(voice_lower)
using_uploaded_voice = ref_audio_source is not None
if ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await server._resolve_ref_audio(ref_audio_source)
wav_list, sr, cache_key = await server._resolve_ref_audio(ref_audio_source)
params["voice"] = [[wav_list, sr]]
params["ref_audio_cache_key"] = [cache_key]
if using_uploaded_voice and voice_lower:
params["voice_name"] = [voice_lower]
params["voice_created_at"] = [server._voice_created_at(voice_lower)]
@@ -271,8 +274,9 @@ class IndexTTS2Adapter(ARTTSAdapter):
if key not in extras:
continue
if key == "emo_audio":
wav_list, sr = await server._resolve_ref_audio(extras[key])
wav_list, sr, emo_key = await server._resolve_ref_audio(extras[key])
params[key] = [[wav_list, sr]]
params["emo_audio_cache_key"] = [emo_key]
else:
params[key] = [extras[key]]
return params
@@ -64,7 +64,7 @@ class MingTTSAdapter(ARTTSAdapter):
if isinstance(ref_audio_source, list):
ref_audio_data = await server._resolve_ref_audio_many(ref_audio_source)
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
wav_list, sr = await server._resolve_ref_audio(ref_audio_source)
wav_list, sr, _ = await server._resolve_ref_audio(ref_audio_source)
ref_audio_data = (wav_list, sr)
prompt = server._build_ming_dense_prompt(
request,
@@ -91,7 +91,7 @@ class _MossTTSAdapterBase(ARTTSAdapter):
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
) -> PreparedRequest:
server = self.ctx.server
tts_params = await server._build_moss_tts_params(request)
tts_params = await server._build_moss_tts_params(request, has_inline_ref_audio=has_inline_ref_audio)
if request.voice:
voice_lower = request.voice.lower()
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
@@ -33,7 +33,7 @@ class OmniVoiceAdapter(ARTTSAdapter):
server = self.ctx.server
prompt: dict = {"input": request.input}
if request.ref_audio:
wav, sr = await server._resolve_ref_audio(request.ref_audio)
wav, sr, _ = await server._resolve_ref_audio(request.ref_audio)
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
if request.ref_text:
prompt["ref_text"] = request.ref_text
@@ -8,14 +8,13 @@ in ``serving_speech.py``. The serving layer just calls
:func:`encode_reference_codes` with its generic helpers (the audio resolver and
the process-wide speaker cache).
Kept import-light (only ``asyncio`` / ``hashlib`` / ``torch`` plus the logger)
Kept import-light (only ``asyncio`` / ``torch`` plus the logger)
so importing it from the API-server process does not pull the talker/codec.
"""
from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Awaitable, Callable
from typing import Any
@@ -43,7 +42,7 @@ async def encode_reference_codes(
ref_str: str,
*,
processor: Any,
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int]]],
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int, str]]],
speaker_cache: Any,
variant: str,
n_vq: int,
@@ -62,10 +61,15 @@ async def encode_reference_codes(
``asyncio.to_thread`` so cold/anonymous encodes from concurrent requests
overlap instead of serializing on the event loop.
For named voices the speaker cache is checked *before* resolving the audio,
avoiding the decode cost when the cache is warm. For anonymous references
the audio is resolved first so the cache key incorporates mtime/size from
a single stat (no TOCTOU window).
Args:
ref_str: The raw reference audio (URL / path / data URL) as received.
processor: Upstream MOSS processor exposing ``encode_audios_from_wav``.
resolve_ref_audio: Async callable mapping ``ref_str`` to ``(wav_list, sr)``.
resolve_ref_audio: Async callable mapping ``ref_str`` to ``(wav_list, sr, cache_key)``.
speaker_cache: Process-wide ``SpeakerEmbeddingCache``.
variant: MOSS sub-variant (``tts`` / ``ttsd`` / ...), namespaces the cache key.
n_vq: Number of RVQ codebooks (also namespaces the cache key).
@@ -76,24 +80,45 @@ async def encode_reference_codes(
Returns:
The reference RVQ codes tensor (CPU), ready to pass to the processor.
"""
model_type = f"moss_tts_{variant}_nq{n_vq}"
# ---- Named-voice branch: check cache *before* resolving ----
# When voice_name is set, the cache key is (voice_name, created_at) which
# does not depend on the resolved audio content. The serving layer only
# passes voice_name for uploaded speakers without an inline ref_audio;
# re-upload bumps created_at and clears the speaker-cache slot.
if voice_name:
speaker_name = voice_name
created_at = int(voice_created_at)
cache_key = speaker_cache.make_cache_key(
speaker_name,
model_type=model_type,
created_at=created_at,
)
cached = speaker_cache.get(cache_key)
if cached is not None:
logger.debug("Speaker cache HIT for MOSS-TTS reference '%s'", speaker_name)
return cached["codes"].clone()
# Cache miss — resolve the audio for encoding.
wav_list, sr, _ = await resolve_ref_audio(ref_str)
else:
speaker_name = "ref:" + hashlib.sha1((ref_str or "").encode("utf-8")).hexdigest()
# ---- Anonymous branch: resolve first for content-addressed key ----
# The resolve incorporates mtime/size from a single stat so the key
# and the waveform always come from the same filesystem snapshot.
wav_list, sr, resolve_cache_key = await resolve_ref_audio(ref_str)
speaker_name = "ref:" + resolve_cache_key
created_at = 0
cache_key = speaker_cache.make_cache_key(
speaker_name,
model_type=model_type,
created_at=created_at,
)
cached = speaker_cache.get(cache_key)
if cached is not None:
logger.debug("Speaker cache HIT for MOSS-TTS reference '%s'", speaker_name)
return cached["codes"].clone()
cache_key = speaker_cache.make_cache_key(
speaker_name,
model_type=f"moss_tts_{variant}_nq{n_vq}",
created_at=created_at,
)
cached = speaker_cache.get(cache_key)
if cached is not None:
logger.debug("Speaker cache HIT for MOSS-TTS reference '%s'", speaker_name)
return cached["codes"].clone()
wav_list, sr = await resolve_ref_audio(ref_str)
# ---- Cold miss: encode in a worker thread and store ----
codes = await asyncio.to_thread(_encode_wav_sync, processor, wav_list, sr, sr_target, n_vq)
speaker_cache.put(cache_key, {"codes": codes.detach().cpu()})
logger.debug("Speaker cache STORE for MOSS-TTS reference '%s'", speaker_name)