[Feature][Moss-TTS] Reference Audio Preprocessing (#4982)

Signed-off-by: BruceLoveDecimal <39156883+BruceLoveDecimal@users.noreply.github.com>
Signed-off-by: liuqihao <liuqihao970610@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BruceLoveDecimalandClaude Opus 5 authored and GitHub committed 2026-09-04 16:36:26 -07:00
1 parent ae57e406fd
commit 1e74807c35
4 files changed
+1072 -199

No files matched your search

@@ -1711,16 +1711,16 @@ class TestTTSMethods:
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 ──
# ── MossReferenceEncoder: speaker cache invalidation ──
@pytest.mark.asyncio
async def test_encode_reference_codes_reencodes_on_key_change(self):
async def test_moss_ref_encoder_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.model_executor.models.moss_tts.reference_encoder import MossReferenceEncoder
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
speaker_cache = SpeakerEmbeddingCache(max_bytes=64 * 1024 * 1024)
@@ -1745,38 +1745,42 @@ class TestTTSMethods:
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,
encoder = MossReferenceEncoder(
processor,
variant="tts",
n_vq=32,
sr_target=24000,
speaker_cache=speaker_cache,
)
assert call_count == 1
assert torch.equal(result1, codes_v1)
try:
# First call: cold miss, encodes and stores
result1, key1 = await encoder.encode(
"data:audio/wav;base64,fake",
resolve_ref_audio=mock_resolve,
get_artifact_key=lambda cache_key: None,
)
assert call_count == 1
assert torch.equal(result1, codes_v1)
assert key1 == "key_aaa"
# 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)
# Second call: different resolve key → must re-encode, not serve cached
result2, key2 = await encoder.encode(
"data:audio/wav;base64,fake",
resolve_ref_audio=mock_resolve,
get_artifact_key=lambda cache_key: None,
)
assert call_count == 2, "Different resolve key should trigger re-encode"
assert torch.equal(result2, codes_v2)
assert key2 == "key_bbb"
finally:
await encoder.aclose()
@pytest.mark.asyncio
async def test_encode_reference_codes_named_voice_skips_resolve_on_hit(self):
async def test_moss_ref_encoder_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.model_executor.models.moss_tts.reference_encoder import MossReferenceEncoder
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
speaker_cache = SpeakerEmbeddingCache(max_bytes=64 * 1024 * 1024)
@@ -1791,33 +1795,37 @@ class TestTTSMethods:
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,
encoder = MossReferenceEncoder(
processor,
variant="tts",
n_vq=32,
sr_target=24000,
voice_name="alice",
voice_created_at=100,
speaker_cache=speaker_cache,
)
assert resolve_called == 1
try:
# First call: cold miss, resolves + encodes
_, key1 = await encoder.encode(
"data:audio/wav;base64,fake",
resolve_ref_audio=mock_resolve,
get_artifact_key=lambda cache_key: None,
voice_name="alice",
voice_created_at=100,
)
assert resolve_called == 1
assert key1 == "some_key"
# 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"
# Second call: warm hit, must NOT resolve
_, key2 = await encoder.encode(
"data:audio/wav;base64,fake",
resolve_ref_audio=mock_resolve,
get_artifact_key=lambda cache_key: None,
voice_name="alice",
voice_created_at=100,
)
assert resolve_called == 1, "Named-voice cache hit should skip resolve_ref_audio"
assert key2 is None # no resolve happened; salted by voice_created_at
finally:
await encoder.aclose()
@pytest.mark.asyncio
async def test_ttsd_second_reference_does_not_use_named_voice(self, speech_server, mocker):
@@ -1835,14 +1843,12 @@ class TestTTSMethods:
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)
class _FakeEncoder:
async def encode(self, ref_str, **kwargs):
seen.append((ref_str, kwargs.get("voice_name")))
return torch.zeros(3, dtype=torch.int64), f"rk:{ref_str}"
mocker.patch(
"vllm_omni.model_executor.models.moss_tts.reference_encoder.encode_reference_codes",
fake_encode,
)
mocker.patch.object(speech_server, "_get_moss_ref_encoder", return_value=_FakeEncoder())
req = OpenAICreateSpeechRequest(
input="hello",
@@ -1850,11 +1856,14 @@ class TestTTSMethods:
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 == [
params = await speech_server._build_moss_tts_params(req, has_inline_ref_audio=False)
assert sorted(seen) == [
("data:audio/wav;base64,aaa", "alice"),
("data:audio/wav;base64,bbb", None),
]
# Per-slot salt keys survive the concurrent (gather) encode order.
assert params["ref_audio_cache_key"] == "rk:data:audio/wav;base64,aaa"
assert params["ref_audio_2_cache_key"] == "rk:data:audio/wav;base64,bbb"
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."""
@@ -0,0 +1,536 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for MossReferenceEncoder: content-addressed caching,
single-flight, and micro-batched encoding."""
import asyncio
import threading
import time
import pytest
import torch
from vllm_omni.model_executor.models.moss_tts.reference_encoder import (
MossReferenceEncoder,
_RefEncodeBatcher,
build_reference_encoder,
encode_request_references,
)
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.tts]
_SR = 24000
_N_VQ = 4
class _FakeProcessor:
"""Deterministic stand-in for the MOSS AutoProcessor."""
def __init__(self, *, n_vq=_N_VQ, sampling_rate=_SR, delay_s=0.0, fail_if_batched=False):
self.model_config = type("_Cfg", (), {"n_vq": n_vq, "sampling_rate": sampling_rate})()
self.attempt_sizes: list[int] = []
self.total_items = 0
self._delay_s = delay_s
self._fail_if_batched = fail_if_batched
self._fail_values: set[int] = set()
self._lock = threading.Lock()
def set_fail_values(self, values) -> None:
self._fail_values = set(values)
def encode_audios_from_wav(self, wav_list, sampling_rate, n_vq=None):
if self._delay_s:
time.sleep(self._delay_s)
with self._lock:
self.attempt_sizes.append(len(wav_list))
if self._fail_if_batched and len(wav_list) > 1:
raise RuntimeError("batched forward not supported")
nq = n_vq or self.model_config.n_vq
out = []
for wav in wav_list:
cid = round(float(wav.reshape(-1)[0].item()))
if cid in self._fail_values:
raise RuntimeError(f"encode failed for content {cid}")
out.append(torch.full((3, nq), cid, dtype=torch.long))
with self._lock:
self.total_items += len(wav_list)
return out
class _FakeAudio:
"""Fake resolve + content-hash side, mirroring serving_speech's contract:
``resolve`` returns ``(wav, sr, cache_key)`` and ``artifact_key`` maps that
resolve cache key to the waveform-content key (or None when unknown)."""
def __init__(self, *, resolve_delay_s=0.0):
self._wav: dict[str, tuple] = {}
self._artifact: dict[str, str | None] = {}
self.resolve_calls: list[str] = []
self._resolve_delay_s = resolve_delay_s
self._lock = threading.Lock()
def register(self, ref_str, content_id, *, artifact=..., sr=_SR, length=100, wav=...):
wav_list = [float(content_id)] * length if wav is ... else wav
self._wav[ref_str] = (wav_list, sr)
# Default: content hash keyed by content_id (same content -> same key).
self._artifact["rk:" + ref_str] = f"c{content_id}" if artifact is ... else artifact
async def resolve(self, ref_str):
with self._lock:
self.resolve_calls.append(ref_str)
if self._resolve_delay_s:
await asyncio.sleep(self._resolve_delay_s)
wav_list, sr = self._wav[ref_str]
return wav_list, sr, "rk:" + ref_str
def artifact_key(self, cache_key):
return self._artifact.get(cache_key)
@pytest.fixture
async def make_encoder():
"""Factory yielding encoders; tears each one's drainer down after the test."""
created: list[MossReferenceEncoder] = []
def _make(processor, *, cache=None, window_ms=10.0, max_batch=8, variant="local"):
enc = MossReferenceEncoder(
processor,
variant=variant,
n_vq=_N_VQ,
sr_target=_SR,
speaker_cache=cache or SpeakerEmbeddingCache(max_bytes=8 * 1024**2),
window_ms=window_ms,
max_batch=max_batch,
)
created.append(enc)
return enc
yield _make
for enc in created:
await enc.aclose()
async def _encode(enc, audio, ref, **kw):
codes, _ = await enc.encode(
ref,
resolve_ref_audio=audio.resolve,
get_artifact_key=audio.artifact_key,
**kw,
)
return codes
# --------------------------------------------------------------------------- #
# Group A — content-addressed cache (R1) #
# --------------------------------------------------------------------------- #
async def test_same_reference_encodes_once(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc)
r1 = await _encode(enc, audio, "a")
r2 = await _encode(enc, audio, "a")
assert proc.total_items == 1 # second call is a cache hit
assert r1.dtype == torch.int64 and r2.dtype == torch.int64
assert int(r1[0, 0]) == 7 and int(r2[0, 0]) == 7
async def test_returned_tensor_does_not_alias_cache(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc)
r1 = await _encode(enc, audio, "a")
r1[:] = 999 # caller mutates its copy
r2 = await _encode(enc, audio, "a")
assert int(r2[0, 0]) == 7 # cache untouched
assert proc.total_items == 1
async def test_cached_dtype_is_int32(make_encoder):
cache = SpeakerEmbeddingCache(max_bytes=8 * 1024**2)
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc, cache=cache)
out = await _encode(enc, audio, "a")
key = cache.make_cache_key("ref:c7", model_type=f"moss_tts_local_nq{_N_VQ}")
stored = cache.get(key)
assert stored is not None
assert stored["codes"].dtype == torch.int32 # compact on-disk
assert out.dtype == torch.int64 # int64 to the caller
async def test_same_content_via_different_refs_shares_cache(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
# base64 vs URL for the *same* clip resolve to the same content hash.
audio.register("data:base64,xxx", 7, artifact="c7")
audio.register("http://x/clip.wav", 7, artifact="c7")
enc = make_encoder(proc)
await _encode(enc, audio, "data:base64,xxx")
await _encode(enc, audio, "http://x/clip.wav")
assert proc.total_items == 1 # encoded once, shared by content hash
async def test_named_voice_created_at_isolation(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("ref", 7)
enc = make_encoder(proc)
await _encode(enc, audio, "ref", voice_name="alice", voice_created_at=1)
await _encode(enc, audio, "ref", voice_name="alice", voice_created_at=2)
assert proc.total_items == 2 # re-upload (new created_at) re-encodes
await _encode(enc, audio, "ref", voice_name="alice", voice_created_at=1)
assert proc.total_items == 2 # original generation still cached
async def test_named_voice_keys_are_case_insensitive(make_encoder):
"""Flight key and cache key must agree on one spelling of the voice name."""
proc, audio = _FakeProcessor(delay_s=0.02), _FakeAudio()
audio.register("ref", 7)
enc = make_encoder(proc)
# Concurrent callers differing only by case share one flight...
r1, r2 = await asyncio.gather(
_encode(enc, audio, "ref", voice_name="Alice", voice_created_at=1),
_encode(enc, audio, "ref", voice_name="ALICE", voice_created_at=1),
)
assert proc.total_items == 1
assert int(r1[0, 0]) == 7 and int(r2[0, 0]) == 7
# ...and populate the slot every later spelling hits.
out = await _encode(enc, audio, "ref", voice_name="alice", voice_created_at=1)
assert proc.total_items == 1
assert int(out[0, 0]) == 7
async def test_unregistered_default_voice_uses_ref_content_key(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("first", 7, artifact="c7")
audio.register("second", 8, artifact="c8")
enc = make_encoder(proc)
first = await _encode(enc, audio, "first", voice_name="default", voice_created_at=0)
second = await _encode(enc, audio, "second", voice_name="default", voice_created_at=0)
first_again = await _encode(enc, audio, "first", voice_name="default", voice_created_at=0)
assert int(first[0, 0]) == 7
assert int(second[0, 0]) == 8
assert int(first_again[0, 0]) == 7
assert proc.total_items == 2 # no collision on the placeholder voice name
async def test_artifact_key_unavailable_falls_back_to_ref_hash(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
# artifact=None simulates a cold/disabled resolve cache.
audio.register("ref", 7, artifact=None)
enc = make_encoder(proc)
await _encode(enc, audio, "ref")
await _encode(enc, audio, "ref")
# Falls back to the stable resolve cache key, so the second call still hits.
assert proc.total_items == 1
async def test_encode_returns_resolve_key_for_salting(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
audio.register("named", 8)
enc = make_encoder(proc)
_, k1 = await enc.encode("a", resolve_ref_audio=audio.resolve, get_artifact_key=audio.artifact_key)
_, k2 = await enc.encode("a", resolve_ref_audio=audio.resolve, get_artifact_key=audio.artifact_key)
# Anonymous refs always resolve, so warm hits still return a fresh key
# (the KV prefix-cache salt must track on-disk edits).
assert k1 == "rk:a" and k2 == "rk:a"
_, k3 = await enc.encode(
"named",
resolve_ref_audio=audio.resolve,
get_artifact_key=audio.artifact_key,
voice_name="alice",
voice_created_at=1,
)
_, k4 = await enc.encode(
"named",
resolve_ref_audio=audio.resolve,
get_artifact_key=audio.artifact_key,
voice_name="alice",
voice_created_at=1,
)
assert k3 == "rk:named"
assert k4 is None # named-voice hot hit skips the resolve; salted by created_at
# --------------------------------------------------------------------------- #
# Group B — single-flight (R2) #
# --------------------------------------------------------------------------- #
async def test_concurrent_same_ref_encodes_once(make_encoder):
proc, audio = _FakeProcessor(delay_s=0.05), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc)
results = await asyncio.gather(*[_encode(enc, audio, "a") for _ in range(10)])
assert proc.total_items == 1 # single-flight collapsed 10 -> 1 encode
assert audio.resolve_calls == ["a"] # and one resolve/download
assert all(int(r[0, 0]) == 7 for r in results)
async def test_concurrent_results_are_isolated(make_encoder):
proc, audio = _FakeProcessor(delay_s=0.02), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc)
r1, r2 = await asyncio.gather(_encode(enc, audio, "a"), _encode(enc, audio, "a"))
r1[:] = 111
assert int(r2[0, 0]) == 7 # separate storage per waiter
async def test_flight_exception_propagates_and_is_not_cached(make_encoder):
proc, audio = _FakeProcessor(delay_s=0.02), _FakeAudio()
audio.register("a", 9)
proc.set_fail_values({9})
enc = make_encoder(proc)
results = await asyncio.gather(*[_encode(enc, audio, "a") for _ in range(6)], return_exceptions=True)
assert all(isinstance(r, Exception) for r in results) # every waiter sees it
proc.set_fail_values(set()) # failure was not cached: a retry now succeeds
ok = await _encode(enc, audio, "a")
assert int(ok[0, 0]) == 9
async def test_initiator_cancel_does_not_break_other_waiter(make_encoder):
proc, audio = _FakeProcessor(delay_s=0.1), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc)
a = asyncio.create_task(_encode(enc, audio, "a"))
await asyncio.sleep(0.02) # let A create the flight
b = asyncio.create_task(_encode(enc, audio, "a"))
await asyncio.sleep(0.02) # let B join the same flight
a.cancel()
res_b = await b
assert int(res_b[0, 0]) == 7 # B still completes off the shared flight
assert proc.total_items == 1
with pytest.raises(asyncio.CancelledError):
await a
# --------------------------------------------------------------------------- #
# Group C — micro-batched encoding (R3) #
# --------------------------------------------------------------------------- #
async def test_distinct_refs_coalesce_into_one_batch(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
for i, ref in enumerate(("a", "b", "c")):
audio.register(ref, 10 + i)
enc = make_encoder(proc, window_ms=100.0, max_batch=8)
results = await asyncio.gather(*[_encode(enc, audio, r) for r in ("a", "b", "c")])
assert proc.attempt_sizes == [3] # one batched forward of all three
assert sorted(int(r[0, 0]) for r in results) == [10, 11, 12]
async def test_max_batch_splits_work():
# Drive the batcher directly: with everything already queued, max_batch=2
# must split 4 submissions into batches of at most 2.
seen: list[int] = []
def encode_batch(payload):
seen.append(len(payload))
return [torch.zeros(1) for _ in payload]
batcher = _RefEncodeBatcher(encode_batch, window_ms=5.0, max_batch=2)
try:
await asyncio.gather(*[batcher.submit([1.0], _SR) for _ in range(4)])
finally:
await batcher.aclose()
assert sum(seen) == 4
assert max(seen) == 2 and all(s <= 2 for s in seen)
async def test_single_submission_still_runs(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
enc = make_encoder(proc, window_ms=20.0)
out = await _encode(enc, audio, "a") # not starved waiting for a full batch
assert int(out[0, 0]) == 7
assert proc.attempt_sizes == [1]
async def test_window_zero_runs_immediately():
seen: list[int] = []
def encode_batch(payload):
seen.append(len(payload))
return [torch.zeros(1) for _ in payload]
batcher = _RefEncodeBatcher(encode_batch, window_ms=0.0, max_batch=8)
try:
out = await batcher.submit([1.0], _SR)
finally:
await batcher.aclose()
assert out is not None
assert seen == [1]
def test_prep_failure_is_isolated(make_encoder_sync):
enc = make_encoder_sync()
good = [1.0] * 100
bad = None # torch.tensor(None) raises inside prep
results = enc._encode_batch_sync([(good, _SR), (bad, _SR), (good, _SR)])
assert isinstance(results[0], torch.Tensor)
assert isinstance(results[1], Exception) # only the bad clip fails
assert isinstance(results[2], torch.Tensor)
def test_batch_forward_failure_falls_back_per_item(make_encoder_sync):
proc = _FakeProcessor(fail_if_batched=True)
enc = make_encoder_sync(proc)
results = enc._encode_batch_sync([([float(i)] * 100, _SR) for i in (1, 2, 3)])
assert all(isinstance(r, torch.Tensor) for r in results)
assert [int(r[0, 0]) for r in results] == [1, 2, 3]
assert any(s > 1 for s in proc.attempt_sizes) # a batched attempt happened
assert proc.attempt_sizes.count(1) == 3 # then per-item fallback
@pytest.fixture
def make_encoder_sync():
"""Encoder factory for the synchronous ``_encode_batch_sync`` tests."""
def _make(processor=None):
return MossReferenceEncoder(
processor or _FakeProcessor(),
variant="local",
n_vq=_N_VQ,
sr_target=_SR,
speaker_cache=SpeakerEmbeddingCache(max_bytes=8 * 1024**2),
)
return _make
# --------------------------------------------------------------------------- #
# Group D — integration with the real SpeakerEmbeddingCache #
# --------------------------------------------------------------------------- #
async def test_model_type_namespaces_cache(make_encoder):
# Two variants sharing one cache must not collide on the same ref.
cache = SpeakerEmbeddingCache(max_bytes=8 * 1024**2)
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 7)
enc_tts = make_encoder(proc, cache=cache, variant="tts")
enc_ttsd = make_encoder(proc, cache=cache, variant="ttsd")
await _encode(enc_tts, audio, "a")
await _encode(enc_ttsd, audio, "a")
assert proc.total_items == 2 # distinct model_type keys -> no cross-hit
assert cache.stats()["entries"] == 2
# --------------------------------------------------------------------------- #
# Group E — serving-layer entry points moved into the model package #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("variant", "expected_sr"),
[
# Local-v1.5 encodes references at a fixed 24 kHz working rate even
# though its output codec is 48 kHz stereo.
("local", 24000),
("tts", 48000),
("ttsd", 48000),
],
)
def test_build_reference_encoder_derives_geometry(variant, expected_sr):
proc = _FakeProcessor(n_vq=24, sampling_rate=48000)
enc = build_reference_encoder(
proc,
variant=variant,
speaker_cache=SpeakerEmbeddingCache(max_bytes=8 * 1024**2),
)
assert enc._n_vq == 24
assert enc._sr_target == expected_sr
def test_build_reference_encoder_falls_back_without_model_config_fields():
proc = _FakeProcessor()
proc.model_config = type("_Empty", (), {})()
enc = build_reference_encoder(
proc,
variant="tts",
speaker_cache=SpeakerEmbeddingCache(max_bytes=8 * 1024**2),
)
assert enc._n_vq == 32
assert enc._sr_target == 24000
async def test_encode_request_references_single_speaker(make_encoder):
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 1)
enc = make_encoder(proc)
refs, resolve_keys = await encode_request_references(
enc,
"a",
None,
resolve_ref_audio=audio.resolve,
get_artifact_key=audio.artifact_key,
)
assert len(refs) == 1
assert set(resolve_keys) == {0}
async def test_encode_request_references_keys_second_speaker_by_slot(make_encoder):
"""Two-speaker resolve keys must land in slot order, not completion order,
and speaker 2 must stay content-addressed even under a named voice."""
proc, audio = _FakeProcessor(), _FakeAudio()
audio.register("a", 1)
audio.register("b", 2)
enc = make_encoder(proc, variant="ttsd", window_ms=100.0)
refs, resolve_keys = await encode_request_references(
enc,
"a",
"b",
resolve_ref_audio=audio.resolve,
get_artifact_key=audio.artifact_key,
voice_name="alice",
voice_created_at=7,
)
assert len(refs) == 2
assert resolve_keys[0] == "rk:a"
assert resolve_keys[1] == "rk:b"
# Both speakers encoded; speaker 2 did not reuse the named-voice entry.
assert not torch.equal(refs[0], refs[1])
assert proc.total_items == 2
# Concurrent, so both clips shared one batch window.
assert proc.attempt_sizes == [2]
+71 -59
View File
@@ -1403,6 +1403,24 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._moss_processor_cache = proc
return proc
def _get_moss_ref_encoder(self):
"""Lazily build the per-server MOSS reference-audio encoder once."""
cached = getattr(self, "_moss_ref_encoder", None)
if cached is not None:
return cached
from vllm_omni.model_executor.models.moss_tts.reference_encoder import build_reference_encoder
# The variant's encode geometry (n_vq, working sample rate) is derived
# inside the model package; this layer only supplies the processor and
# the process-wide speaker cache.
encoder = build_reference_encoder(
self._get_moss_processor(),
variant=self._moss_variant,
speaker_cache=self._speaker_cache,
)
self._moss_ref_encoder = encoder
return encoder
async def _build_moss_tts_params(
self,
request: OpenAICreateSpeechRequest,
@@ -1474,63 +1492,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# consumes, so skipping this path silently drops all voice-clone
# conditioning and produces unconditioned/garbage audio online). ----
proc = self._get_moss_processor()
n_vq = int(getattr(proc.model_config, "n_vq", 32))
# Local-v1.5 encodes reference audio at a fixed 24 kHz working rate
# regardless of its 48 kHz stereo *output* codec -- mirrors the
# offline example's hardcoded encode_audios_from_wav(sampling_rate=24000)
# for this variant; proc.model_config.sampling_rate there is the
# output rate (48000), the wrong value to resample the reference into.
sr_target = 24000 if v == "local" else int(getattr(proc.model_config, "sampling_rate", 24000))
# Reference-audio encoding + speaker caching lives in the model package
# (moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 /
# Qwen3-TTS which keep reference handling with the model rather than in
# this shared serving file. Imported lazily so the API-server process
# 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
# 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 _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=_tracked_resolve,
speaker_cache=self._speaker_cache,
variant=v,
n_vq=n_vq,
sr_target=sr_target,
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, named_voice=True)]
elif v == "ttsd":
refs = [await _encode_ref(request.ref_audio, named_voice=True)]
if request.ref_audio_2:
refs.append(await _encode_ref(request.ref_audio_2, named_voice=False))
user_kwargs["reference"] = refs
resolve_keys: dict[int, str] = {}
if v in ("tts", "local", "ttsd"):
user_kwargs["reference"], resolve_keys = await self._encode_moss_references(
request,
has_inline_ref_audio=has_inline_ref_audio,
two_speaker=(v == "ttsd"),
)
elif v == "sound_effect":
user_kwargs["text"] = request.input or "" # may be empty
user_kwargs["ambient_sound"] = request.ambient_sound or ""
@@ -1563,12 +1533,54 @@ 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]
if 0 in resolve_keys:
params["ref_audio_cache_key"] = resolve_keys[0]
if 1 in resolve_keys:
params["ref_audio_2_cache_key"] = resolve_keys[1]
return params
async def _encode_moss_references(
self,
request: OpenAICreateSpeechRequest,
*,
has_inline_ref_audio: bool,
two_speaker: bool,
) -> tuple[list, dict[int, str]]:
"""Encode the request's reference clip(s) into MOSS RVQ code tensors.
Reference encoding + speaker caching lives in the model package
(moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 /
Qwen3-TTS which keep reference handling with the model rather than in
this shared serving file. This method only resolves the serving-side
inputs the model helper needs: the generic audio resolver/artifact-key
lookups and whether the request may use the named-voice cache.
Returns ``(codes_per_speaker, resolve_keys)`` where ``resolve_keys``
maps the reference slot (0 = ref_audio, 1 = ref_audio_2) to its
content-aware resolve key, for salting the KV prefix cache.
"""
from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_request_references
# Named-voice caching is only valid for uploaded speakers without an
# inline ref_audio: ``request.voice`` plus a file/URL would otherwise
# key on (name, created_at=0) and skip the content-aware resolve.
raw_voice = getattr(request, "voice", None)
raw_voice = raw_voice.strip() if isinstance(raw_voice, str) else ""
voice_lower = raw_voice.lower()
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
return await encode_request_references(
self._get_moss_ref_encoder(),
request.ref_audio,
request.ref_audio_2 if two_speaker else None,
resolve_ref_audio=self._resolve_ref_audio,
get_artifact_key=self._get_resolved_ref_audio_artifact_key,
voice_name=voice or None,
voice_created_at=voice_created,
)
async def _build_higgs_audio_v2_params(self, request: OpenAICreateSpeechRequest):
"""Build prompt_token_ids for higgs_audio_v2 via the upstream processor.
@@ -4,17 +4,25 @@ This lives in the model package (not the shared serving layer) so all
MOSS-specific reference handling stays with the model — mirroring how Fish
Speech (``dac_encoder.encode_reference_audio_codes``), CosyVoice3, and
Qwen3-TTS keep their reference/speaker extraction next to the model rather than
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).
in ``serving_speech.py``. The serving layer constructs one
:class:`MossReferenceEncoder` per server (lazily, alongside the upstream MOSS
processor) and calls :meth:`MossReferenceEncoder.encode` with its generic
helpers (the audio resolver, the artifact-key lookup, and the process-wide
speaker cache).
Kept import-light (only ``asyncio`` / ``torch`` plus the logger)
On top of that cache the encoder adds content-addressed keys (the same clip
arriving via different locators shares one entry), single-flight (concurrent
requests for one uncached clip join a single encode), and micro-batched
encoding (cold encodes arriving close together share one processor forward).
Kept import-light (only ``asyncio`` / ``hashlib`` / ``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
@@ -23,9 +31,20 @@ from vllm.logger import init_logger
logger = init_logger(__name__)
# Coalescing defaults. Kept as module constants (not env/CLI) to match the
# ``_REF_AUDIO_RESOLVE_CACHE_MAX_*`` convention in serving_speech.py.
_REF_ENCODE_BATCH_WINDOW_MS = 10.0
_REF_ENCODE_MAX_BATCH = 8
def _encode_wav_sync(processor: Any, wav_list: list, sr: int, sr_target: int, n_vq: int) -> torch.Tensor:
"""Blocking resample + CPU codec encode (the expensive bit)."""
_INT32_MAX = 2**31
def _sha1(s: str) -> str:
return hashlib.sha1((s or "").encode("utf-8")).hexdigest()
def _prep_wav_sync(wav_list: list, sr: int, sr_target: int) -> torch.Tensor:
"""Tensor-ise + resample one clip to ``sr_target`` (the blocking prep)."""
wav = torch.tensor(wav_list, dtype=torch.float32)
if wav.dim() == 1:
wav = wav.unsqueeze(0)
@@ -33,93 +52,390 @@ def _encode_wav_sync(processor: Any, wav_list: list, sr: int, sr_target: int, n_
import torchaudio
wav = torchaudio.functional.resample(wav, sr, sr_target)
with torch.no_grad():
codes_list = processor.encode_audios_from_wav([wav], sampling_rate=sr_target, n_vq=n_vq)
return codes_list[0]
return wav
async def encode_reference_codes(
ref_str: str,
*,
def _to_compact_codes(codes: torch.Tensor) -> torch.Tensor:
"""Downcast RVQ codes to int32 for compact caching."""
codes = codes.detach().cpu().contiguous()
if codes.numel() == 0:
return codes.to(torch.int32)
hi = int(codes.max().item())
lo = int(codes.min().item())
if -_INT32_MAX <= lo and hi < _INT32_MAX:
return codes.to(torch.int32)
logger.warning("MOSS ref codes out of int32 range (min=%d max=%d); caching as int64", lo, hi)
return codes.to(torch.int64)
def _clone_out(codes: torch.Tensor) -> torch.Tensor:
"""Return an independent int64 copy for the caller."""
return codes.to(torch.int64, copy=True)
def _registered_voice(voice_name: str | None, voice_created_at: int) -> tuple[str | None, int]:
"""Return ``(name, created_at)`` for a registered uploaded voice, else ``(None, 0)``.
The OpenAI speech API requires a ``voice`` field, and callers often send
placeholders such as "default" for ref-audio voice cloning. Only registered
uploaded voices have a positive created_at timestamp; other names must not
key the cache because the timbre comes from ref_audio.
The name is lowercased here so every derived key agrees on one spelling:
if the cache key kept the caller's casing while the flight key normalized
it, two concurrent callers differing only by case would share a flight but
populate different cache slots.
"""
name = voice_name.strip().lower() if isinstance(voice_name, str) else ""
created_at = int(voice_created_at)
if name and created_at > 0:
return name, created_at
return None, 0
class _RefEncodeBatcher:
"""Coalesce cold reference encodes into batched processor forwards."""
def __init__(
self,
encode_batch_fn: Callable[[list[tuple[list, int]]], list],
*,
window_ms: float,
max_batch: int,
):
# encode_batch_fn: sync, takes [(wav_list, sr), ...], returns a list of
# (codes_tensor | Exception) aligned to the input order.
self._encode_batch_fn = encode_batch_fn
self._window_s = max(0.0, float(window_ms) / 1000.0)
self._max_batch = max(1, int(max_batch))
self._queue: asyncio.Queue | None = None
self._drainer: asyncio.Task | None = None
def _ensure_started(self) -> None:
if self._queue is None:
self._queue = asyncio.Queue()
if self._drainer is None or self._drainer.done():
self._drainer = asyncio.create_task(self._drain_loop())
async def submit(self, wav_list: list, sr: int) -> torch.Tensor:
self._ensure_started()
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self._queue.put_nowait((wav_list, sr, fut)) # type: ignore[union-attr]
return await fut
async def _drain_loop(self) -> None:
assert self._queue is not None
while True:
first = await self._queue.get()
jobs = await self._coalesce(first)
await self._run_batch(jobs)
async def _coalesce(self, first: tuple) -> list[tuple]:
"""Group ``first`` with jobs arriving within the batch window."""
jobs = [first]
if self._window_s > 0:
deadline = asyncio.get_running_loop().time() + self._window_s
while len(jobs) < self._max_batch:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
jobs.append(await asyncio.wait_for(self._queue.get(), remaining))
except asyncio.TimeoutError:
break
else:
# window=0: coalesce only what is already queued, never wait.
while len(jobs) < self._max_batch:
try:
jobs.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
return jobs
async def _run_batch(self, jobs: list[tuple[list, int, asyncio.Future]]) -> None:
payload = [(wav_list, sr) for wav_list, sr, _ in jobs]
futs = [fut for _, _, fut in jobs]
try:
results = await asyncio.to_thread(self._encode_batch_fn, payload)
except Exception as exc: # noqa: BLE001 — propagate the batch failure to every waiter
for fut in futs:
if not fut.done():
fut.set_exception(exc)
return
for fut, res in zip(futs, results):
if fut.done():
continue
if isinstance(res, BaseException):
fut.set_exception(res)
else:
fut.set_result(res)
async def aclose(self) -> None:
if self._drainer is not None:
self._drainer.cancel()
try:
await self._drainer
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
self._drainer = None
class MossReferenceEncoder:
"""Content-addressed, single-flight, micro-batched reference-audio encoder."""
def __init__(
self,
processor: Any,
*,
variant: str,
n_vq: int,
sr_target: int,
speaker_cache: Any,
window_ms: float = _REF_ENCODE_BATCH_WINDOW_MS,
max_batch: int = _REF_ENCODE_MAX_BATCH,
):
self._processor = processor
self._n_vq = int(n_vq)
self._sr_target = int(sr_target)
self._speaker_cache = speaker_cache
# ``created_at`` and the audio-content name vary per request; the
# model_type namespaces the whole family so a moss_tts server never
# collides with another model's speaker-cache entries.
self._model_type = f"moss_tts_{variant}_nq{int(n_vq)}"
self._inflight: dict[str, asyncio.Task] = {}
self._batcher = _RefEncodeBatcher(self._encode_batch_sync, window_ms=window_ms, max_batch=max_batch)
def _make_cache_key(self, name: str, created_at: int) -> tuple:
return self._speaker_cache.make_cache_key(name, model_type=self._model_type, created_at=int(created_at))
async def encode(
self,
ref_str: str,
*,
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int, str]]],
get_artifact_key: Callable[[str], str | None],
voice_name: str | None = None,
voice_created_at: int = 0,
) -> tuple[torch.Tensor, str | None]:
"""Encode one reference clip into MOSS RVQ codes, reusing the cache.
``resolve_ref_audio`` maps ``ref_str`` to ``(wav_list, sr, cache_key)``
where *cache_key* is the content-aware resolve key (it folds mtime/size
for local files). ``get_artifact_key`` maps that resolve key to the
waveform-content artifact key, or ``None`` when unknown.
Returns ``(codes, resolve_cache_key)``; the key is ``None`` when the
clip was served from the named-voice cache without resolving (the
caller salts those requests with ``voice_created_at`` instead).
"""
voice_name, created_at = _registered_voice(voice_name, voice_created_at)
if voice_name:
# A named voice has a stable key that does not depend on the
# resolved audio, so the cache can be checked before resolving.
flight_key = f"voice:{voice_name}:{created_at}"
cached = self._speaker_cache.get(self._make_cache_key(voice_name, created_at))
if cached is not None:
return _clone_out(cached["codes"]), None
else:
# Anonymous refs have no pre-resolve hot path: the content key must
# come from the resolve itself (mtime/size from a single stat) so an
# on-disk edit invalidates the cached codes; the flight body
# re-checks the speaker cache right after resolving, which is cheap
# when the resolve cache is warm. The flight key is the request-side
# reference (not the content hash), so concurrent requests for the
# same ref_str also share the resolve/download, not just the encode.
flight_key = "ref:" + _sha1(ref_str)
codes, resolve_key = await self._single_flight(
flight_key,
lambda: self._resolve_and_encode(ref_str, resolve_ref_audio, get_artifact_key, voice_name, created_at),
)
return _clone_out(codes), resolve_key
async def _single_flight(
self,
flight_key: str,
start_flight: Callable[[], Awaitable[tuple[torch.Tensor, str]]],
) -> tuple[torch.Tensor, str]:
"""Join the in-flight encode for ``flight_key``, starting one if absent.
The shared task is awaited through ``shield`` so a caller cancelling
its own request does not cancel the flight (asyncio would otherwise
propagate the cancel into the awaited task and take down every other
waiter with it).
"""
task = self._inflight.get(flight_key)
if task is not None:
return await asyncio.shield(task)
task = asyncio.create_task(start_flight())
self._inflight[flight_key] = task
# Retire the slot when the flight *completes*, not when the creating
# caller returns: if the creator is cancelled the shielded task keeps
# running, and popping the slot early would let the next arrival start
# a duplicate resolve/encode instead of joining this one. Identity
# guard: only drop the slot if it still holds *our* task (a later
# request may have replaced it after ours completed).
def _retire(t: asyncio.Task, key: str = flight_key) -> None:
if self._inflight.get(key) is t:
self._inflight.pop(key, None)
task.add_done_callback(_retire)
return await asyncio.shield(task)
async def _resolve_and_encode(
self,
ref_str: str,
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int, str]]],
get_artifact_key: Callable[[str], str | None],
voice_name: str | None,
created_at: int,
) -> tuple[torch.Tensor, str]:
"""Flight body: resolve → re-check cache by content hash → batch-encode."""
wav_list, sr, resolve_key = await resolve_ref_audio(ref_str)
# The content hash is available now that the clip is resolved; this also
# catches the case where another flight populated the cache in between.
if voice_name:
key = self._make_cache_key(voice_name, created_at)
else:
artifact_key = get_artifact_key(resolve_key)
key_name = ("ref:" + artifact_key) if artifact_key else ("ref:" + resolve_key)
key = self._make_cache_key(key_name, 0)
cached = self._speaker_cache.get(key)
if cached is not None:
return cached["codes"], resolve_key
codes = await self._batcher.submit(wav_list, sr)
compact = _to_compact_codes(codes)
self._speaker_cache.put(key, {"codes": compact})
logger.debug(
"MOSS ref encode STORE key=%s shape=%s dtype=%s",
key,
tuple(compact.shape),
compact.dtype,
)
return compact, resolve_key
def _encode_batch_sync(self, payload: list[tuple[list, int]]) -> list:
"""Worker-thread body: prep each clip, then one batched forward."""
n = len(payload)
results: list = [None] * n
prepared: list[torch.Tensor] = []
prepared_idx: list[int] = []
for i, (wav_list, sr) in enumerate(payload):
try:
prepared.append(_prep_wav_sync(wav_list, sr, self._sr_target))
prepared_idx.append(i)
except Exception as exc: # noqa: BLE001 — isolate this clip's failure
results[i] = exc
if not prepared:
return results
try:
codes_list = self._encode_prepared(prepared)
for local_i, orig_i in enumerate(prepared_idx):
results[orig_i] = codes_list[local_i]
except Exception: # noqa: BLE001 — batch forward failed; retry item-by-item
logger.warning("MOSS ref batch encode (n=%d) failed; falling back to per-item", len(prepared))
for local_i, orig_i in enumerate(prepared_idx):
try:
results[orig_i] = self._encode_prepared([prepared[local_i]])[0]
except Exception as exc: # noqa: BLE001 — isolate this clip's failure
results[orig_i] = exc
return results
def _encode_prepared(self, prepared: list[torch.Tensor]) -> list[torch.Tensor]:
with torch.no_grad():
return self._processor.encode_audios_from_wav(prepared, sampling_rate=self._sr_target, n_vq=self._n_vq)
async def aclose(self) -> None:
"""Release the batcher drainer + any in-flight encodes (tests/shutdown)."""
for task in list(self._inflight.values()):
task.cancel()
self._inflight.clear()
await self._batcher.aclose()
def build_reference_encoder(
processor: Any,
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int, str]]],
speaker_cache: Any,
*,
variant: str,
n_vq: int,
sr_target: int,
speaker_cache: Any,
) -> MossReferenceEncoder:
"""Build the per-server encoder for a MOSS-TTS ``variant``.
Derives the encode geometry (``n_vq`` and the working sample rate) from the
upstream processor's ``model_config`` so the variant knowledge stays in the
model package rather than in ``serving_speech.py``.
"""
n_vq = int(getattr(processor.model_config, "n_vq", 32))
# Local-v1.5 encodes reference audio at a fixed 24 kHz working rate
# regardless of its 48 kHz stereo *output* codec -- mirrors the offline
# example's hardcoded encode_audios_from_wav(sampling_rate=24000) for this
# variant; proc.model_config.sampling_rate there is the output rate
# (48000), the wrong value to resample the reference into.
sr_target = 24000 if variant == "local" else int(getattr(processor.model_config, "sampling_rate", 24000))
return MossReferenceEncoder(
processor,
variant=variant,
n_vq=n_vq,
sr_target=sr_target,
speaker_cache=speaker_cache,
)
async def encode_request_references(
encoder: MossReferenceEncoder,
ref_audio: str,
ref_audio_2: str | None = None,
*,
resolve_ref_audio: Callable[[str], Awaitable[tuple[list, int, str]]],
get_artifact_key: Callable[[str], str | None],
voice_name: str | None = None,
voice_created_at: int = 0,
) -> torch.Tensor:
"""Encode one reference clip into MOSS RVQ codes, reusing the speaker cache.
) -> tuple[list[torch.Tensor], dict[int, str]]:
"""Encode a request's reference clip(s) into MOSS RVQ code tensors.
The MOSS audio tokenizer sits on CPU (to spare ~6.7 GiB next to the 8B
talker), so re-encoding the same reference is a fixed per-request cost that
otherwise dominates the 8B voice-clone variants and serializes under
concurrency. Mirror CosyVoice3 / Qwen3-TTS: cache by named voice when one is
supplied (``voice_created_at`` invalidates on re-upload), else by a content
hash of the reference. The blocking encode runs in a worker thread via
``asyncio.to_thread`` so cold/anonymous encodes from concurrent requests
overlap instead of serializing on the event loop.
``ref_audio_2`` is the TTSD second speaker; pass ``None`` for the
single-speaker variants. The named-voice cache key is
``(voice_name, created_at)`` and ignores the clip content, so only slot 0
(the reference that actually belongs to the uploaded voice) may use it —
speaker 2 is a different clip and stays content-addressed, or it would
silently reuse speaker 1's codes.
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, 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).
sr_target: Target sample rate for the codec.
voice_name: Named/uploaded voice, if any (enables stable cross-request caching).
voice_created_at: Upload timestamp; bumps the cache slot on re-upload.
Returns:
The reference RVQ codes tensor (CPU), ready to pass to the processor.
Returns ``(codes_per_speaker, resolve_keys)`` where ``resolve_keys`` maps
the reference slot (0 = ``ref_audio``, 1 = ``ref_audio_2``) to its
content-aware resolve key, for salting the KV prefix cache. A dict rather
than an append list because the two-speaker encodes run concurrently, so
completion order is not slot order.
"""
model_type = f"moss_tts_{variant}_nq{n_vq}"
resolve_keys: dict[int, str] = {}
# ---- 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,
async def encode_one(ref_str: str, *, named_voice: bool, slot: int) -> torch.Tensor:
codes, resolve_key = await encoder.encode(
ref_str,
resolve_ref_audio=resolve_ref_audio,
get_artifact_key=get_artifact_key,
voice_name=voice_name if named_voice else None,
voice_created_at=voice_created_at if named_voice else 0,
)
if resolve_key is not None:
resolve_keys[slot] = resolve_key
return codes
if ref_audio_2:
# Encode both speakers concurrently so they land in the same batch
# window / share single-flight instead of serializing.
refs = list(
await asyncio.gather(
encode_one(ref_audio, named_voice=True, slot=0),
encode_one(ref_audio_2, named_voice=False, slot=1),
)
)
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:
# ---- 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()
# ---- 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)
return codes
refs = [await encode_one(ref_audio, named_voice=True, slot=0)]
return refs, resolve_keys