[BugFix][TTS] Fix MOSS Realtime serving and generation (#5661)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
This commit is contained in:
1 parent
04c55d0164
commit
a8434a60da
13 files changed
+771
-64
No files matched your search
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""E2E offline inference tests for MOSS-VoiceGenerator (MossTTSDelayModel, 1.7B).
|
||||
|
||||
Weekly expansion coverage (``pytest.mark.slow``) for the delay-model path.
|
||||
@@ -74,7 +74,6 @@ def _get_test_config() -> str:
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"),
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize(
|
||||
"omni_runner",
|
||||
@@ -137,7 +136,7 @@ def _collect_audio(omni_runner: OmniRunner, request: dict) -> tuple[torch.Tensor
|
||||
return torch.cat(chunks, dim=0), sr_final
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_delay_english(omni_runner: OmniRunner) -> None:
|
||||
"""VoiceGenerator: English instruction produces non-empty 24 kHz audio."""
|
||||
req = _build_request(
|
||||
@@ -151,7 +150,7 @@ def test_moss_tts_delay_english(omni_runner: OmniRunner) -> None:
|
||||
assert not torch.all(audio == 0), "Audio is silence"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_delay_chinese(omni_runner: OmniRunner) -> None:
|
||||
"""VoiceGenerator: Chinese input produces non-empty audio."""
|
||||
req = _build_request(
|
||||
@@ -165,7 +164,7 @@ def test_moss_tts_delay_chinese(omni_runner: OmniRunner) -> None:
|
||||
assert not torch.all(audio == 0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_delay_batch(omni_runner: OmniRunner) -> None:
|
||||
"""VoiceGenerator: batch of two requests each returns non-empty audio."""
|
||||
requests = [
|
||||
|
||||
@@ -95,7 +95,6 @@ def _get_test_config() -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"),
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize(
|
||||
@@ -272,7 +271,7 @@ def _collect_audio(omni_runner: OmniRunner, request: dict) -> tuple[torch.Tensor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_realtime_english(omni_runner: OmniRunner, ref_audio_path: str) -> None:
|
||||
"""MossTTSRealtime: English voice_clone produces non-empty 24 kHz audio."""
|
||||
req = _build_request(ref_audio_path, "This is a real-time TTS streaming test.")
|
||||
@@ -283,7 +282,7 @@ def test_moss_tts_realtime_english(omni_runner: OmniRunner, ref_audio_path: str)
|
||||
assert not torch.all(audio == 0), "Audio is silence"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_realtime_chinese(omni_runner: OmniRunner, ref_audio_path: str) -> None:
|
||||
"""MossTTSRealtime: Chinese input produces non-empty audio."""
|
||||
req = _build_request(ref_audio_path, "你好,这是语音合成测试。")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""E2E offline inference tests for MOSS-TTS-Realtime (MossTTSRealtime, 1.7B).
|
||||
|
||||
Exercises the local-transformer generation path on L4. Delay-model coverage is in
|
||||
@@ -33,7 +33,6 @@ _OMNI_RUNNER_PARAM = (
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"),
|
||||
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
|
||||
]
|
||||
|
||||
@@ -118,7 +117,7 @@ def _collect_audio(omni_runner: OmniRunner, request: dict) -> tuple[torch.Tensor
|
||||
raise AssertionError("No stage outputs received")
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_moss_tts_realtime_english(omni_runner: OmniRunner, ref_audio_path) -> None:
|
||||
"""MossTTSRealtime: English voice_clone produces non-empty 24 kHz audio."""
|
||||
req = _build_request("This is a real-time TTS streaming test.", ref_audio_path)
|
||||
|
||||
@@ -30,20 +30,16 @@ from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
_SKIP_ISSUE_6417 = pytest.mark.skip(
|
||||
reason="https://github.com/vllm-project/vllm-omni/issues/6417",
|
||||
)
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.advanced_model,
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
_SKIP_ISSUE_6417,
|
||||
]
|
||||
|
||||
MODEL = "OpenMOSS-Team/MOSS-TTS-Realtime"
|
||||
REF_AUDIO_URL = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS/HEAD/assets/audio/reference_zh_1.wav"
|
||||
# Voice-clone output is not reliably transcribed by the Whisper check used at
|
||||
# full_model run_level; assert non-trivial WAV payload size instead.
|
||||
# Reject a response that contains only a header or a very short audio payload.
|
||||
# Advanced and full runs also validate the transcript.
|
||||
_MIN_AUDIO_BYTES = 10_000
|
||||
|
||||
|
||||
@@ -77,7 +73,9 @@ def get_prompt(prompt_type: str = "text") -> str:
|
||||
"""
|
||||
prompts = {
|
||||
"text": "Hello, this is a real-time voice cloning demo for testing.",
|
||||
"chinese": "你好,这是一段实时语音合成测试。",
|
||||
# Match the offline test prompt. Whisper-small can transcribe "一段"
|
||||
# as "一本", which makes this short sample fail the similarity check.
|
||||
"chinese": "你好,这是语音合成测试。",
|
||||
}
|
||||
return prompts.get(prompt_type, prompts["text"])
|
||||
|
||||
@@ -94,7 +92,7 @@ tts_server_params = [
|
||||
]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_001(omni_server, online_client, ref_audio_data_url) -> None:
|
||||
"""
|
||||
@@ -105,9 +103,8 @@ def test_text_to_audio_001(omni_server, online_client, ref_audio_data_url) -> No
|
||||
Input Setting: stream=False
|
||||
Datasets: single request
|
||||
|
||||
NOTE: ``min_audio_bytes`` skips Whisper transcript similarity — cloned
|
||||
voice output is often empty or mismatched under ASR without indicating a
|
||||
real serving regression.
|
||||
``min_audio_bytes`` adds a payload-size check. It does not disable the
|
||||
transcript check used by advanced and full runs.
|
||||
"""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
@@ -121,7 +118,7 @@ def test_text_to_audio_001(omni_server, online_client, ref_audio_data_url) -> No
|
||||
online_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_002_streaming(omni_server, online_client, ref_audio_data_url) -> None:
|
||||
"""
|
||||
@@ -150,7 +147,7 @@ def test_text_to_audio_002_streaming(omni_server, online_client, ref_audio_data_
|
||||
online_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_003_chinese(omni_server, online_client, ref_audio_data_url) -> None:
|
||||
"""
|
||||
@@ -161,7 +158,8 @@ def test_text_to_audio_003_chinese(omni_server, online_client, ref_audio_data_ur
|
||||
Input Setting: stream=False
|
||||
Datasets: single request
|
||||
|
||||
NOTE: same ``min_audio_bytes`` rationale as test_text_to_audio_001.
|
||||
``min_audio_bytes`` adds a payload-size check. It does not disable the
|
||||
transcript check used by advanced and full runs.
|
||||
"""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import moss_tts as adapter_module
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import MossTTSAdapter
|
||||
from vllm_omni.model_executor.models.moss_tts import reference_encoder
|
||||
from vllm_omni.utils.speaker_cache import SpeakerEmbeddingCache
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hf_config", "expected_codec_path"),
|
||||
[
|
||||
(SimpleNamespace(), "OpenMOSS-Team/MOSS-Audio-Tokenizer"),
|
||||
(
|
||||
SimpleNamespace(codec_model_name_or_path="/models/custom-moss-codec"),
|
||||
"/models/custom-moss-codec",
|
||||
),
|
||||
],
|
||||
ids=["default-codec", "configured-codec"],
|
||||
)
|
||||
def test_realtime_components_use_the_realtime_model_and_codec(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
hf_config: SimpleNamespace,
|
||||
expected_codec_path: str,
|
||||
) -> None:
|
||||
engine_client = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
model="OpenMOSS-Team/MOSS-TTS-Realtime",
|
||||
hf_config=hf_config,
|
||||
)
|
||||
)
|
||||
|
||||
server = MossTTSAdapter(SpeechServingContext(server=object(), engine_client=engine_client))
|
||||
|
||||
tokenizer = object()
|
||||
codec = type("Codec", (), {"to": lambda self, device: self, "eval": lambda self: self})()
|
||||
processor_calls = []
|
||||
|
||||
class Processor:
|
||||
def __init__(self, *, tokenizer):
|
||||
processor_calls.append(tokenizer)
|
||||
|
||||
class_calls = []
|
||||
tokenizer_calls = []
|
||||
codec_calls = []
|
||||
|
||||
def fake_get_class(class_reference, model_id):
|
||||
class_calls.append((class_reference, model_id))
|
||||
return Processor
|
||||
|
||||
def fake_load_tokenizer(model_id, *, trust_remote_code):
|
||||
tokenizer_calls.append((model_id, trust_remote_code))
|
||||
return tokenizer
|
||||
|
||||
def fake_load_codec(model_id, *, trust_remote_code):
|
||||
codec_calls.append((model_id, trust_remote_code))
|
||||
return codec
|
||||
|
||||
monkeypatch.setattr(adapter_module, "get_class_from_dynamic_module", fake_get_class)
|
||||
monkeypatch.setattr(adapter_module.AutoTokenizer, "from_pretrained", fake_load_tokenizer)
|
||||
monkeypatch.setattr(adapter_module.AutoModel, "from_pretrained", fake_load_codec)
|
||||
|
||||
components = server._get_moss_realtime_components()
|
||||
|
||||
assert components[0] is tokenizer
|
||||
assert components[2] is codec
|
||||
assert server._get_moss_realtime_components() is components
|
||||
assert class_calls == [
|
||||
(
|
||||
"processing_mossttsrealtime.MossTTSRealtimeProcessor",
|
||||
"OpenMOSS-Team/MOSS-TTS-Realtime",
|
||||
)
|
||||
]
|
||||
assert tokenizer_calls == [("OpenMOSS-Team/MOSS-TTS-Realtime", True)]
|
||||
assert codec_calls == [(expected_codec_path, True)]
|
||||
assert processor_calls == [tokenizer]
|
||||
|
||||
|
||||
def test_realtime_serving_builds_the_talker_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = MossTTSAdapter(SpeechServingContext(server=SimpleNamespace(uploaded_speakers={})))
|
||||
server._moss_variant = "realtime"
|
||||
tokenizer = object()
|
||||
processor = object()
|
||||
codec = object()
|
||||
request_thread = threading.get_ident()
|
||||
|
||||
def load_components():
|
||||
assert threading.get_ident() != request_thread
|
||||
return tokenizer, processor, codec
|
||||
|
||||
server._get_moss_realtime_components = load_components
|
||||
|
||||
reference_codes = torch.arange(64, dtype=torch.int64).reshape(4, 16)
|
||||
encode = AsyncMock(return_value=([reference_codes], {0: "reference-cache-key"}))
|
||||
monkeypatch.setattr(server, "_encode_moss_references", encode)
|
||||
|
||||
build_call = None
|
||||
|
||||
def fake_build(actual_tokenizer, actual_processor, text, actual_codes):
|
||||
nonlocal build_call
|
||||
build_call = (actual_tokenizer, actual_processor, text, actual_codes)
|
||||
return {
|
||||
"prompt_token_ids": [10, 11],
|
||||
"codes": {"ref": actual_codes},
|
||||
"ids": {"all": [12]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(adapter_module, "build_realtime_prompt", fake_build)
|
||||
|
||||
params = asyncio.run(
|
||||
server._build_moss_tts_params(
|
||||
OpenAICreateSpeechRequest(
|
||||
input="speak this text",
|
||||
ref_audio="data:audio/wav;base64,AAAA",
|
||||
max_new_tokens=50,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
encode.assert_awaited_once()
|
||||
assert encode.call_args.kwargs == {"has_inline_ref_audio": False, "two_speaker": False}
|
||||
assert encode.call_args.args[0].ref_audio == "data:audio/wav;base64,AAAA"
|
||||
assert build_call == (tokenizer, processor, "speak this text", reference_codes)
|
||||
assert params == {
|
||||
"prompt_token_ids": [10, 11],
|
||||
"codes": {"ref": reference_codes},
|
||||
"ids": {"all": [12]},
|
||||
"max_new_frames": [50],
|
||||
"ref_audio_cache_key": "reference-cache-key",
|
||||
}
|
||||
assert "prompt_audio_array" not in params
|
||||
|
||||
|
||||
def test_cancelled_component_waiter_does_not_duplicate_codec_load(monkeypatch):
|
||||
adapter = MossTTSAdapter(
|
||||
SpeechServingContext(
|
||||
server=object(),
|
||||
engine_client=SimpleNamespace(
|
||||
model_config=SimpleNamespace(model="OpenMOSS-Team/MOSS-TTS-Realtime", hf_config=SimpleNamespace())
|
||||
),
|
||||
)
|
||||
)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
second_started = threading.Event()
|
||||
loads = []
|
||||
codec = type("Codec", (), {"to": lambda self, device: self, "eval": lambda self: self})()
|
||||
|
||||
def load_codec(*args, **kwargs):
|
||||
loads.append(args)
|
||||
started.set()
|
||||
assert release.wait(5), "codec loader was not released"
|
||||
return codec
|
||||
|
||||
monkeypatch.setattr(adapter_module, "get_class_from_dynamic_module", lambda *args: lambda **kwargs: object())
|
||||
monkeypatch.setattr(adapter_module.AutoTokenizer, "from_pretrained", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(adapter_module.AutoModel, "from_pretrained", load_codec)
|
||||
|
||||
def second_load():
|
||||
second_started.set()
|
||||
return adapter._get_moss_realtime_components()
|
||||
|
||||
async def run():
|
||||
first = asyncio.create_task(asyncio.to_thread(adapter._get_moss_realtime_components))
|
||||
try:
|
||||
assert await asyncio.to_thread(started.wait, 5)
|
||||
first.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await first
|
||||
second = asyncio.create_task(asyncio.to_thread(second_load))
|
||||
try:
|
||||
assert await asyncio.to_thread(second_started.wait, 5)
|
||||
finally:
|
||||
release.set()
|
||||
components = await asyncio.wait_for(second, 5)
|
||||
assert components[2] is codec
|
||||
assert adapter._get_moss_realtime_components() is components
|
||||
assert len(loads) == 1
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("voice_name", [None, "speaker"])
|
||||
def test_realtime_reference_cache_reuses_codes(voice_name):
|
||||
cache = SpeakerEmbeddingCache()
|
||||
resolve = AsyncMock(return_value=([0.0], 24000, "audio-key"))
|
||||
request_thread = threading.get_ident()
|
||||
encode_calls = []
|
||||
expected = torch.arange(64).reshape(4, 16)
|
||||
|
||||
def batch_encode(wavs, *, num_quantizers):
|
||||
assert threading.get_ident() != request_thread
|
||||
assert num_quantizers == 16
|
||||
encode_calls.append(wavs)
|
||||
return SimpleNamespace(audio_codes=expected.T.unsqueeze(1), audio_codes_lengths=torch.tensor([4]))
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=batch_encode), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def run():
|
||||
kwargs = dict(
|
||||
resolve_ref_audio=resolve,
|
||||
get_artifact_key=lambda key: "content-key",
|
||||
voice_name=voice_name,
|
||||
voice_created_at=1,
|
||||
)
|
||||
try:
|
||||
first, first_key = await encoder.encode("reference", **kwargs)
|
||||
second, second_key = await encoder.encode("reference", **kwargs)
|
||||
assert torch.equal(first, expected)
|
||||
assert torch.equal(second, expected)
|
||||
assert first.dtype == torch.int64
|
||||
assert first_key == "audio-key"
|
||||
assert second_key == (None if voice_name else "audio-key")
|
||||
second.zero_()
|
||||
third, _ = await encoder.encode("another-reference", **kwargs)
|
||||
assert torch.equal(third, expected)
|
||||
assert len(encode_calls) == 1
|
||||
assert resolve.await_count == (1 if voice_name else 3)
|
||||
key = cache.make_cache_key(
|
||||
voice_name or "ref:content-key", "moss_tts_realtime_nq16", 1 if voice_name else 0
|
||||
)
|
||||
assert cache.get(key)["codes"].dtype == torch.int32
|
||||
if voice_name:
|
||||
kwargs["voice_created_at"] = 2
|
||||
await encoder.encode("reference", **kwargs)
|
||||
assert len(encode_calls) == 2
|
||||
assert resolve.await_count == 2
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_realtime_reference_batch_trims_each_item():
|
||||
cache = SpeakerEmbeddingCache()
|
||||
codes = torch.arange(16 * 2 * 5).reshape(16, 2, 5)
|
||||
calls = []
|
||||
|
||||
def batch_encode(wavs, *, num_quantizers):
|
||||
calls.append(wavs)
|
||||
assert num_quantizers == 16
|
||||
assert [wav.shape for wav in wavs] == [torch.Size([8]), torch.Size([12])]
|
||||
return SimpleNamespace(audio_codes=codes, audio_codes_lengths=torch.tensor([3, 5]))
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=batch_encode), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def resolve(ref):
|
||||
return [0.0] * (8 if ref == "short" else 12), 24000, ref
|
||||
|
||||
async def run():
|
||||
try:
|
||||
short, long = await asyncio.gather(
|
||||
encoder.encode("short", resolve_ref_audio=resolve, get_artifact_key=lambda key: key),
|
||||
encoder.encode("long", resolve_ref_audio=resolve, get_artifact_key=lambda key: key),
|
||||
)
|
||||
torch.testing.assert_close(short[0], codes[:, 0, :3].T)
|
||||
torch.testing.assert_close(long[0], codes[:, 1, :5].T)
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_failed_realtime_reference_encoding_does_not_populate_cache():
|
||||
cache = SpeakerEmbeddingCache()
|
||||
resolve = AsyncMock(return_value=([0.0], 24000, "audio-key"))
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise ValueError("invalid reference")
|
||||
|
||||
encoder = reference_encoder.build_reference_encoder(
|
||||
SimpleNamespace(batch_encode=fail), variant="realtime", speaker_cache=cache
|
||||
)
|
||||
|
||||
async def run():
|
||||
try:
|
||||
with pytest.raises(ValueError, match="invalid reference"):
|
||||
await encoder.encode("reference", resolve_ref_audio=resolve, get_artifact_key=lambda key: None)
|
||||
assert cache.memory_bytes() == 0
|
||||
finally:
|
||||
await encoder.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for MOSS audio tokenizer v2 projection modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import (
|
||||
MossAudioTokenizerProjectedTransformer,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_projected_transformer_keeps_learned_projections_when_dimensions_match() -> None:
|
||||
module = MossAudioTokenizerProjectedTransformer(
|
||||
input_dimension=8,
|
||||
output_dimension=8,
|
||||
d_model=8,
|
||||
module_type="transformer",
|
||||
num_heads=1,
|
||||
num_layers=0,
|
||||
positional_embedding="rope",
|
||||
)
|
||||
|
||||
assert isinstance(module.input_proj, nn.Linear)
|
||||
assert isinstance(module.output_proj, nn.Linear)
|
||||
assert module.input_proj.weight.shape == (8, 8)
|
||||
assert module.output_proj.weight.shape == (8, 8)
|
||||
|
||||
x = torch.randn(2, 8, 5)
|
||||
lengths = torch.tensor([5, 3], dtype=torch.long)
|
||||
output, output_lengths = module(x, lengths)
|
||||
|
||||
assert output.shape == x.shape
|
||||
torch.testing.assert_close(output_lengths, lengths)
|
||||
|
||||
|
||||
def test_projected_transformer_keeps_linear_projections_when_dimensions_differ() -> None:
|
||||
module = MossAudioTokenizerProjectedTransformer(
|
||||
input_dimension=8,
|
||||
output_dimension=6,
|
||||
d_model=10,
|
||||
module_type="transformer",
|
||||
num_heads=1,
|
||||
num_layers=0,
|
||||
positional_embedding="rope",
|
||||
)
|
||||
|
||||
assert isinstance(module.input_proj, nn.Linear)
|
||||
assert isinstance(module.output_proj, nn.Linear)
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_talker_preserves_state_across_chunked_prefill() -> None:
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_talker import (
|
||||
MossTTSRealtimeTalkerForGeneration,
|
||||
)
|
||||
|
||||
talker = MossTTSRealtimeTalkerForGeneration.__new__(MossTTSRealtimeTalkerForGeneration)
|
||||
talker.n_vq = 2
|
||||
talker.audio_pad_token = 1024
|
||||
talker.text_vocab_size = 16
|
||||
talker.text_pad_id = 2
|
||||
talker._batch_state = None
|
||||
talker._batch_state_spans = None
|
||||
|
||||
embedded_audio: list[torch.Tensor | None] = []
|
||||
|
||||
def build_input_embeds(input_ids, audio_codes):
|
||||
embedded_audio.append(audio_codes.clone() if audio_codes is not None else None)
|
||||
return torch.zeros((input_ids.shape[0], 1))
|
||||
|
||||
generated: list[None] = []
|
||||
|
||||
def generate_frame(*args, **kwargs):
|
||||
generated.append(None)
|
||||
return torch.ones((1, talker.n_vq), dtype=torch.long)
|
||||
|
||||
talker._build_input_embeds = build_input_embeds
|
||||
talker.local_transformer = SimpleNamespace(generate_frame=generate_frame)
|
||||
talker.local_lm_heads = []
|
||||
|
||||
ref_codes = torch.arange(10, dtype=torch.long).reshape(5, 2)
|
||||
info: dict[str, Any] = {
|
||||
"codes": {"ref": ref_codes},
|
||||
"ids": {"all": [7, 8]},
|
||||
"max_new_frames": [3],
|
||||
}
|
||||
chunks = [(0, 2, False), (2, 2, False), (4, 1, True)]
|
||||
|
||||
state: dict[str, Any] | None = None
|
||||
for computed, length, eligible in chunks:
|
||||
_, _, update = talker.preprocess(
|
||||
torch.ones(length, dtype=torch.long),
|
||||
None,
|
||||
_omni_is_prefill=True,
|
||||
_omni_num_computed_tokens=computed,
|
||||
_omni_prompt_len=5,
|
||||
**info,
|
||||
)
|
||||
info.update(update)
|
||||
if state is None:
|
||||
state = info["audio_state"]
|
||||
else:
|
||||
assert info["audio_state"] is state
|
||||
|
||||
output = talker.make_omni_output(
|
||||
torch.ones((length, 1)),
|
||||
model_intermediate_buffer=[info],
|
||||
request_token_spans=[(0, length)],
|
||||
request_sample_eligible=[eligible],
|
||||
)
|
||||
if not eligible:
|
||||
assert output.multimodal_outputs == {}
|
||||
logits = talker.compute_logits(torch.ones((1, 1)))
|
||||
assert logits.argmax().item() == (7 if eligible else talker.text_pad_id)
|
||||
|
||||
assert state is not None
|
||||
assert info["ref_offset"] == 5
|
||||
assert state["step"] == 1
|
||||
assert state["text_cursor"] == 1
|
||||
assert state["remaining_text"] == [7, 8]
|
||||
assert len(generated) == 1
|
||||
assert output.multimodal_outputs["codes"]["audio"][0].shape == (1, 2)
|
||||
for actual, expected in zip(embedded_audio, ref_codes.split((2, 2, 1)), strict=True):
|
||||
assert actual is not None
|
||||
torch.testing.assert_close(actual, expected)
|
||||
@@ -0,0 +1,53 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_realtime_talker_stops_at_max_new_frames() -> None:
|
||||
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_talker import (
|
||||
MossTTSRealtimeTalkerForGeneration,
|
||||
)
|
||||
|
||||
talker = MossTTSRealtimeTalkerForGeneration.__new__(MossTTSRealtimeTalkerForGeneration)
|
||||
talker.n_vq = 4
|
||||
talker.audio_pad_token = 1024
|
||||
talker.text_vocab_size = 8
|
||||
talker.text_pad_id = 2
|
||||
talker.audio_eos_id = 3
|
||||
talker._batch_state = None
|
||||
talker._build_input_embeds = lambda input_ids, audio_codes: torch.zeros((input_ids.shape[0], 1))
|
||||
|
||||
generated: list[None] = []
|
||||
|
||||
def generate_frame(*args, **kwargs):
|
||||
generated.append(None)
|
||||
return torch.ones((1, talker.n_vq), dtype=torch.long)
|
||||
|
||||
talker.local_transformer = SimpleNamespace(generate_frame=generate_frame)
|
||||
talker.local_lm_heads = []
|
||||
|
||||
_, _, info = talker.preprocess(
|
||||
torch.tensor([1, 2]),
|
||||
None,
|
||||
max_new_frames=[2],
|
||||
)
|
||||
assert info["audio_state"]["max_new_frames"] == 2
|
||||
|
||||
for _ in range(3):
|
||||
output = talker.make_omni_output(
|
||||
torch.ones((1, 1)),
|
||||
runtime_additional_information=[info],
|
||||
request_token_spans=[(0, 1)],
|
||||
)
|
||||
|
||||
assert len(generated) == 2
|
||||
assert info["audio_state"]["step"] == 2
|
||||
assert info["audio_state"]["is_stopping"] is True
|
||||
assert output.multimodal_outputs == {}
|
||||
assert talker.compute_logits(torch.ones((1, 1))).argmax().item() == talker.audio_eos_id
|
||||
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.moss_tts.realtime_prompt import build_realtime_prompt
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _Tokenizer:
|
||||
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
|
||||
del add_special_tokens
|
||||
if text == "<|im_start|>assistant\n":
|
||||
return [20, 21]
|
||||
return list(range(100, 100 + len(text.split())))
|
||||
|
||||
|
||||
class _Processor:
|
||||
audio_channel_pad = 1024
|
||||
audio_bos_token = 1025
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reference_codes = None
|
||||
|
||||
def make_ensemble(self, prompt_audio_tokens):
|
||||
self.reference_codes = prompt_audio_tokens
|
||||
return np.full((3, 17), self.audio_channel_pad, dtype=np.int64)
|
||||
|
||||
|
||||
def test_build_realtime_prompt_aligns_reference_and_prefill_text() -> None:
|
||||
processor = _Processor()
|
||||
reference_codes = torch.arange(80, dtype=torch.int64).reshape(5, 16)
|
||||
|
||||
params = build_realtime_prompt(
|
||||
_Tokenizer(),
|
||||
processor,
|
||||
"one two three",
|
||||
reference_codes,
|
||||
)
|
||||
|
||||
assert np.array_equal(processor.reference_codes, reference_codes.numpy())
|
||||
assert params["prompt_token_ids"] == [1024, 1024, 1024, 20, 21, 100, 101, 102]
|
||||
audio_grid = params["codes"]["ref"]
|
||||
assert audio_grid.shape == (len(params["prompt_token_ids"]), 16)
|
||||
assert audio_grid[-1, 0].item() == processor.audio_bos_token
|
||||
assert "ids" not in params
|
||||
|
||||
|
||||
def test_build_realtime_prompt_streams_text_after_first_twelve_tokens() -> None:
|
||||
params = build_realtime_prompt(
|
||||
_Tokenizer(),
|
||||
_Processor(),
|
||||
" ".join(f"word{i}" for i in range(15)),
|
||||
torch.zeros((2, 16), dtype=torch.int64),
|
||||
)
|
||||
|
||||
assert params["prompt_token_ids"][-12:] == list(range(100, 112))
|
||||
assert params["ids"] == {"all": [112, 113, 114]}
|
||||
|
||||
|
||||
def test_build_realtime_prompt_rejects_wrong_processor_layout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
processor = _Processor()
|
||||
monkeypatch.setattr(
|
||||
processor,
|
||||
"make_ensemble",
|
||||
lambda prompt_audio_tokens: np.zeros((3, 16), dtype=np.int64),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"expected \(sequence_length, 17\)"):
|
||||
build_realtime_prompt(
|
||||
_Tokenizer(),
|
||||
processor,
|
||||
"one",
|
||||
torch.zeros((2, 16), dtype=torch.int64),
|
||||
)
|
||||
@@ -6,8 +6,12 @@ Both variants share the same build/validate flow (``_build_moss_tts_params``
|
||||
handles each); they are registered under distinct model-type names.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from transformers import AutoModel, AutoTokenizer
|
||||
from transformers.dynamic_module_utils import get_class_from_dynamic_module
|
||||
from vllm.inputs import tokens_input
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
@@ -18,6 +22,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import (
|
||||
apply_max_new_tokens,
|
||||
conditioning_cache_salt,
|
||||
)
|
||||
from vllm_omni.model_executor.models.moss_tts.realtime_prompt import build_realtime_prompt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
@@ -30,6 +35,7 @@ class _MossTTSAdapterBase(ARTTSAdapter):
|
||||
super().__init__(ctx)
|
||||
self._moss_variant = None if self.name == "moss_tts_nano" else self._detect_moss_variant()
|
||||
self._moss_processor_cache = None
|
||||
self._moss_realtime_components_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def engine_client(self):
|
||||
@@ -63,7 +69,7 @@ class _MossTTSAdapterBase(ARTTSAdapter):
|
||||
# inside the model package; this layer only supplies the processor and
|
||||
# the process-wide speaker cache.
|
||||
encoder = build_reference_encoder(
|
||||
self._get_moss_processor(),
|
||||
self._get_moss_realtime_components()[2] if self._moss_variant == "realtime" else self._get_moss_processor(),
|
||||
variant=cast(str, self._moss_variant),
|
||||
speaker_cache=self._speaker_cache,
|
||||
)
|
||||
@@ -157,6 +163,45 @@ class _MossTTSAdapterBase(ARTTSAdapter):
|
||||
self._moss_processor_cache = proc
|
||||
return proc
|
||||
|
||||
def _get_moss_realtime_components(self):
|
||||
with self._moss_realtime_components_lock:
|
||||
cached = getattr(self, "_moss_realtime_components", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
model_id = self.engine_client.model_config.model
|
||||
processor_cls = get_class_from_dynamic_module(
|
||||
"processing_mossttsrealtime.MossTTSRealtimeProcessor",
|
||||
model_id,
|
||||
)
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
|
||||
processor = processor_cls(tokenizer=tokenizer)
|
||||
hf_config = self.engine_client.model_config.hf_config
|
||||
codec_path = str(
|
||||
getattr(
|
||||
hf_config,
|
||||
"codec_model_name_or_path",
|
||||
getattr(
|
||||
hf_config,
|
||||
"audio_tokenizer_name_or_path",
|
||||
"OpenMOSS-Team/MOSS-Audio-Tokenizer",
|
||||
),
|
||||
)
|
||||
)
|
||||
codec = (
|
||||
AutoModel.from_pretrained(
|
||||
codec_path,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
# Reference encoding runs in the API process. Keep the codec on CPU
|
||||
# so it does not reserve accelerator memory outside the model workers.
|
||||
.to("cpu")
|
||||
.eval()
|
||||
)
|
||||
cached = (tokenizer, processor, codec)
|
||||
self._moss_realtime_components = cached
|
||||
return cached
|
||||
|
||||
async def _build_moss_tts_params(
|
||||
self,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
@@ -196,24 +241,18 @@ class _MossTTSAdapterBase(ARTTSAdapter):
|
||||
params["ref_audio_cache_key"] = cache_key
|
||||
return params
|
||||
|
||||
# ---- MOSS-TTS-Realtime: keep the old prompt_audio_array path ----
|
||||
# ``AutoProcessor.from_pretrained`` doesn't auto-discover
|
||||
# ``MossTTSRealtimeProcessor`` (no ``processor_config.json`` in the
|
||||
# snapshot), and Realtime's prompt format diverges from MossTTSDelay
|
||||
# (16-channel grid, separate per-step text feed). The
|
||||
# ``prompt_audio_array`` shape lines up well enough with what the
|
||||
# talker reads for short prompts; full Realtime support needs a
|
||||
# separate processor.from_module path which we don't wire here.
|
||||
if v == "realtime":
|
||||
params = {
|
||||
"text": [request.input or ""],
|
||||
"mode": ["voice_clone"],
|
||||
}
|
||||
tokenizer, processor, _ = await asyncio.to_thread(self._get_moss_realtime_components)
|
||||
references, realtime_resolve_keys = await self._encode_moss_references(
|
||||
request,
|
||||
has_inline_ref_audio=has_inline_ref_audio,
|
||||
two_speaker=False,
|
||||
)
|
||||
params = build_realtime_prompt(tokenizer, processor, request.input or "", references[0])
|
||||
if request.max_new_tokens is not None:
|
||||
params["max_new_frames"] = [request.max_new_tokens]
|
||||
wav_list, sr, cache_key = await self._resolve_ref_audio(cast(str, request.ref_audio))
|
||||
params["prompt_audio_array"] = [[wav_list, sr]]
|
||||
params["ref_audio_cache_key"] = cache_key
|
||||
if 0 in realtime_resolve_keys:
|
||||
params["ref_audio_cache_key"] = realtime_resolve_keys[0]
|
||||
return params
|
||||
|
||||
# ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator)
|
||||
|
||||
@@ -44,10 +44,10 @@ def _maybe_prefix(prefix: str, name: str) -> str:
|
||||
|
||||
|
||||
def _iter_state_row_spans(
|
||||
states: Sequence[dict[str, Any]],
|
||||
states: Sequence[dict[str, Any] | None],
|
||||
spans: Sequence[tuple[int, int]] | None,
|
||||
num_rows: int,
|
||||
) -> Iterable[tuple[dict[str, Any], int, int]]:
|
||||
) -> Iterable[tuple[dict[str, Any] | None, int, int]]:
|
||||
"""Yield state/logit-row ranges without assuming equal request row counts."""
|
||||
if num_rows <= 0 or not states:
|
||||
return
|
||||
@@ -798,6 +798,9 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
have_multimodal_outputs: bool = True
|
||||
has_preprocess: bool = True
|
||||
has_postprocess: bool = True
|
||||
# Intermediate prefill chunks reach make_omni_output but must not produce
|
||||
# audio until the scheduler has completed the prompt.
|
||||
requires_request_sample_eligibility: bool = True
|
||||
|
||||
AUDIO_BOS = 1025
|
||||
AUDIO_EOS = 1026
|
||||
@@ -858,7 +861,7 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
# We still need a logits_processor so vLLM downstream stays happy with
|
||||
# something callable; a minimal pass-through suffices.
|
||||
self.logits_processor = LogitsProcessor(self.text_vocab_size)
|
||||
self._batch_state: list[dict[str, Any]] | None = None
|
||||
self._batch_state: list[dict[str, Any] | None] | None = None
|
||||
|
||||
# Stacked weight cache built after load_weights().
|
||||
# shape: (n_vq, audio_vocab_size, hidden_size)
|
||||
@@ -972,13 +975,14 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
span_len = int(input_ids.shape[0])
|
||||
audio_state = info_dict.get("audio_state")
|
||||
is_first_call = not isinstance(audio_state, dict)
|
||||
is_prefill = bool(info_dict.get("_omni_is_prefill", span_len > 1))
|
||||
|
||||
if span_len > 1 or is_first_call:
|
||||
if is_prefill or is_first_call:
|
||||
# Prefill: read the full reference-audio code grid (channels x T)
|
||||
# from ``info_dict["codes"]["ref"]``; slice the chunk that aligns
|
||||
# with this prefill window.
|
||||
ref_codes = (info_dict.get("codes", {}) or {}).get("ref")
|
||||
ref_offset = int(info_dict.get("ref_offset", 0))
|
||||
ref_offset = int(info_dict.get("_omni_num_computed_tokens", info_dict.get("ref_offset", 0)))
|
||||
chunk_audio = None
|
||||
if isinstance(ref_codes, torch.Tensor) and ref_codes.numel() > 0:
|
||||
if ref_codes.dim() == 1 and ref_codes.numel() % self.n_vq == 0:
|
||||
@@ -994,22 +998,24 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
if chunk_audio is not None
|
||||
else torch.full((self.n_vq,), self.audio_pad_token, dtype=torch.long, device=device)
|
||||
)
|
||||
# Capture the streaming-text list from the request (set by the
|
||||
# realtime end2end builder; empty for the delay variants which
|
||||
# don't take this code path anyway).
|
||||
remaining = (info_dict.get("ids", {}) or {}).get("all")
|
||||
if not isinstance(remaining, list):
|
||||
remaining = []
|
||||
info_update: dict[str, Any] = {
|
||||
"audio_state": {
|
||||
"audio_codes": {"current": current_codes},
|
||||
"ref_offset": ref_offset + span_len,
|
||||
}
|
||||
if is_first_call:
|
||||
remaining = (info_dict.get("ids", {}) or {}).get("all")
|
||||
if not isinstance(remaining, list):
|
||||
remaining = []
|
||||
max_new_frames = info_dict.get("max_new_frames")
|
||||
if isinstance(max_new_frames, (list, tuple)):
|
||||
max_new_frames = max_new_frames[0] if max_new_frames else None
|
||||
info_update["audio_state"] = {
|
||||
"is_stopping": False,
|
||||
"step": 0,
|
||||
"text_cursor": 0,
|
||||
"remaining_text": list(remaining),
|
||||
},
|
||||
"audio_codes": {"current": current_codes},
|
||||
"ref_offset": ref_offset + span_len,
|
||||
}
|
||||
"max_new_frames": int(max_new_frames) if max_new_frames is not None else -1,
|
||||
}
|
||||
return input_ids, embeds, info_update
|
||||
|
||||
# Decode step: text_token from the just-sampled vLLM logit, plus the
|
||||
@@ -1046,13 +1052,21 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
info_dicts: list[dict[str, Any]] = (
|
||||
kwargs.get("model_intermediate_buffer") or kwargs.get("runtime_additional_information") or []
|
||||
)
|
||||
sample_eligible = kwargs.get("request_sample_eligible")
|
||||
eligible = [
|
||||
sample_eligible is None or i >= len(sample_eligible) or bool(sample_eligible[i])
|
||||
for i in range(len(info_dicts))
|
||||
]
|
||||
|
||||
# Defensive state init.
|
||||
for info in info_dicts:
|
||||
if isinstance(info, dict) and not isinstance(info.get("audio_state"), dict):
|
||||
info["audio_state"] = {"is_stopping": False, "step": 0}
|
||||
|
||||
self._batch_state = [(info["audio_state"] if isinstance(info, dict) else {}) for info in info_dicts]
|
||||
self._batch_state = [
|
||||
(info["audio_state"] if isinstance(info, dict) and eligible[i] else None)
|
||||
for i, info in enumerate(info_dicts)
|
||||
]
|
||||
self._batch_state_spans = kwargs.get("request_token_spans")
|
||||
|
||||
# See delay talker: real per-request row spans from the runner are
|
||||
@@ -1074,6 +1088,8 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
for i, info in enumerate(info_dicts):
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
if not eligible[i]:
|
||||
continue
|
||||
row_start, row_end = spans[i]
|
||||
row_end = min(int(row_end), num_rows)
|
||||
if row_start >= row_end:
|
||||
@@ -1115,10 +1131,13 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
)
|
||||
|
||||
ch0 = int(new_codes[0].item())
|
||||
state["step"] = int(state.get("step", 0)) + 1
|
||||
max_new_frames = int(state.get("max_new_frames", -1))
|
||||
if max_new_frames > 0 and state["step"] >= max_new_frames:
|
||||
state["is_stopping"] = True
|
||||
# Stop condition mirrors upstream: codebook 0 == eos_audio_id.
|
||||
if ch0 == self.AUDIO_EOS:
|
||||
state["is_stopping"] = True
|
||||
state["step"] = int(state.get("step", 0)) + 1
|
||||
info["audio_codes"] = {
|
||||
"current": new_codes,
|
||||
"accumulated": (info.get("audio_codes", {}) or {}).get("accumulated"),
|
||||
@@ -1127,7 +1146,6 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
|
||||
if ch0 in (self.AUDIO_BOS, self.audio_pad_token):
|
||||
# Skip the bos / pad frames — they don't decode to real audio.
|
||||
state["step"] = int(state.get("step", 0)) + 1
|
||||
info["audio_codes"] = {
|
||||
"current": new_codes,
|
||||
"accumulated": (info.get("audio_codes", {}) or {}).get("accumulated"),
|
||||
@@ -1141,7 +1159,6 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module):
|
||||
updated_acc = new_codes.unsqueeze(0)
|
||||
|
||||
info["audio_codes"] = {"current": new_codes, "accumulated": updated_acc}
|
||||
state["step"] = int(state.get("step", 0)) + 1
|
||||
per_req_codes[i] = new_codes.unsqueeze(0)
|
||||
have_codes = True
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Prompt construction for MOSS-TTS-Realtime."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
REALTIME_AUDIO_CHANNELS = 16
|
||||
REALTIME_PREFILL_TEXT_TOKENS = 12
|
||||
|
||||
|
||||
def build_realtime_prompt(
|
||||
tokenizer: Any,
|
||||
processor: Any,
|
||||
text: str,
|
||||
reference_codes: torch.Tensor,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the mixed text/audio prompt consumed by the Realtime talker."""
|
||||
audio_tokens = reference_codes.detach().cpu().numpy()
|
||||
system_grid = np.asarray(processor.make_ensemble(prompt_audio_tokens=audio_tokens), dtype=np.int64)
|
||||
expected_width = REALTIME_AUDIO_CHANNELS + 1
|
||||
if system_grid.ndim != 2 or system_grid.shape[1] != expected_width:
|
||||
raise ValueError(
|
||||
f"MOSS-TTS-Realtime processor returned shape {system_grid.shape}; "
|
||||
f"expected (sequence_length, {expected_width})"
|
||||
)
|
||||
|
||||
assistant_ids = tokenizer.encode("<|im_start|>assistant\n", add_special_tokens=False)
|
||||
assistant_grid = np.full(
|
||||
(len(assistant_ids), expected_width),
|
||||
processor.audio_channel_pad,
|
||||
dtype=np.int64,
|
||||
)
|
||||
assistant_grid[:, 0] = assistant_ids
|
||||
|
||||
text_ids = tokenizer.encode(text, add_special_tokens=False)
|
||||
if not text_ids:
|
||||
raise ValueError("MOSS-TTS-Realtime input is empty after tokenization")
|
||||
|
||||
prefill_len = min(len(text_ids), REALTIME_PREFILL_TEXT_TOKENS)
|
||||
text_grid = np.full(
|
||||
(prefill_len, expected_width),
|
||||
processor.audio_channel_pad,
|
||||
dtype=np.int64,
|
||||
)
|
||||
text_grid[:, 0] = text_ids[:prefill_len]
|
||||
text_grid[-1, 1] = processor.audio_bos_token
|
||||
|
||||
prompt_grid = np.concatenate((system_grid, assistant_grid, text_grid), axis=0)
|
||||
params: dict[str, Any] = {
|
||||
"prompt_token_ids": prompt_grid[:, 0].tolist(),
|
||||
"codes": {"ref": torch.from_numpy(prompt_grid[:, 1:].copy())},
|
||||
}
|
||||
remaining_text_ids = text_ids[prefill_len:]
|
||||
if remaining_text_ids:
|
||||
params["ids"] = {"all": remaining_text_ids}
|
||||
return params
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Reference-audio encoding + speaker cache for the MOSS-TTS-family talker.
|
||||
|
||||
This lives in the model package (not the shared serving layer) so all
|
||||
@@ -360,6 +363,18 @@ class MossReferenceEncoder:
|
||||
await self._batcher.aclose()
|
||||
|
||||
|
||||
class MossRealtimeReferenceEncoder(MossReferenceEncoder):
|
||||
"""Use the realtime codec with the shared reference cache and batcher."""
|
||||
|
||||
def _encode_prepared(self, prepared: list[torch.Tensor]) -> list[torch.Tensor]:
|
||||
with torch.no_grad():
|
||||
encoded = self._processor.batch_encode([wav.squeeze(0) for wav in prepared], num_quantizers=self._n_vq)
|
||||
return [
|
||||
encoded.audio_codes[:, i, : int(length.item())].transpose(0, 1).contiguous()
|
||||
for i, length in enumerate(encoded.audio_codes_lengths)
|
||||
]
|
||||
|
||||
|
||||
def build_reference_encoder(
|
||||
processor: Any,
|
||||
*,
|
||||
@@ -369,9 +384,17 @@ def build_reference_encoder(
|
||||
"""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``.
|
||||
upstream processor's ``model_config``; Realtime takes its codec directly
|
||||
and uses 16 codebooks at 24 kHz. Variant knowledge stays in the model package.
|
||||
"""
|
||||
if variant == "realtime":
|
||||
return MossRealtimeReferenceEncoder(
|
||||
processor,
|
||||
variant=variant,
|
||||
n_vq=16,
|
||||
sr_target=24000,
|
||||
speaker_cache=speaker_cache,
|
||||
)
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user