Files
vllm-omni/tests/e2e/offline_inference/test_moss_tts.py
T

241 lines
8.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E offline inference tests for MOSS-VoiceGenerator (MossTTSDelayModel, 1.7B).
Uses the standard omni_runner + pytestmark pattern (one module-scoped engine
per file, skill invariant I4). The realtime variant lives in the sibling file
test_moss_tts_realtime.py.
MOSS-VoiceGenerator synthesizes speech from a text *instruction* that describes
the desired voice (e.g. "a warm female voice with an American accent"). It does
NOT accept a reference audio clip. The request format requires running
AutoProcessor.from_pretrained once per call to encode the (text, instruction)
pair into the (prompt_token_ids, codes.ref) grid that the MossTTSDelayModel
talker expects — same as examples/offline_inference/text_to_speech/moss_tts/
end2end.py:_build_unified_codes.
No determinism test: VoiceGenerator produces variable-length output even with
a fixed seed; waveform length reproducibility is not guaranteed.
"""
from __future__ import annotations
import gc
import os
import pytest
import torch
from vllm import SamplingParams
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MODEL = "OpenMOSS-Team/MOSS-VoiceGenerator"
SAMPLE_RATE = 24_000
# Stage 0 = AR talker; max_tokens=512 keeps tests fast (~20 s of audio).
# Stage 1 = codec decoder; greedy, large context to hold all codec tokens.
_STAGE0_PARAMS = SamplingParams(
temperature=1.7,
top_p=0.8,
top_k=25,
max_tokens=512,
seed=42,
detokenize=False,
)
_STAGE1_PARAMS = SamplingParams(
temperature=0.0,
top_p=1.0,
top_k=-1,
max_tokens=65536,
seed=42,
detokenize=False,
)
_SAMPLING = [_STAGE0_PARAMS, _STAGE1_PARAMS]
# ---------------------------------------------------------------------------
# Deploy config
# ---------------------------------------------------------------------------
def _get_test_config() -> str:
"""Derive a CI-friendly config from moss_voice_generator.yaml.
Reduces Stage 0 gpu_memory_utilization from 0.60 → 0.45 to leave headroom
for Stage 1 (0.12) on a shared L4/A10G. max_num_seqs=1 keeps per-test
peak memory predictable.
"""
return modify_stage_config(
get_deploy_config_path("moss_voice_generator.yaml"),
updates={
"stages": {
0: {
"max_num_seqs": 1,
"gpu_memory_utilization": 0.45,
},
},
},
)
# ---------------------------------------------------------------------------
# pytestmark — one engine for the whole module
# ---------------------------------------------------------------------------
pytestmark = [
pytest.mark.full_model,
pytest.mark.tts,
pytest.mark.parametrize("omni_runner", [(MODEL, _get_test_config())], indirect=True),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_request(text: str, instruction: str) -> dict:
"""Build a VoiceGenerator request using AutoProcessor.
Calls proc.build_user_message + proc() to produce the unified-codes grid
(L, 1+n_vq) and splits it into prompt_token_ids + codes.ref, exactly as
end2end.py:_build_unified_codes does for the voice_generator variant.
The processor (including its CPU-resident audio_tokenizer) is freed before
returning so it does not compete with the running engine.
Set MOSS_TTS_SKIP_ON_NET_FAIL=1 to skip in air-gapped environments where
the HF snapshot is not cached.
"""
from transformers import AutoProcessor
try:
proc = AutoProcessor.from_pretrained(MODEL, trust_remote_code=True)
except Exception as exc:
msg = f"Cannot load AutoProcessor for {MODEL}: {exc}"
if os.environ.get("MOSS_TTS_SKIP_ON_NET_FAIL"):
pytest.skip(msg)
pytest.fail(msg)
user_msg = proc.build_user_message(text=text, instruction=instruction)
batch = proc(conversations=[[user_msg]], mode="generation")
unified = batch["input_ids"][0] # (L, 1+n_vq)
text_ids = unified[:, 0].tolist()
audio_codes = unified[:, 1:].contiguous().to(torch.int64) # (L, n_vq)
del proc
gc.collect()
return {
"prompt_token_ids": text_ids,
"additional_information": {"codes": {"ref": audio_codes}},
}
def _collect_audio(omni_runner: OmniRunner, request: dict) -> tuple[torch.Tensor, int]:
"""Run one request and return (waveform_cpu, sample_rate).
Iterates all OmniRequestOutputs from generate() via the top-level
multimodal_output property (which aggregates from completion outputs).
AR-stage outputs have empty multimodal_output and are silently skipped.
With async_chunk=True and FINAL_ONLY, the codec stage consolidates its
chunks into a single tensor before yielding — but we handle list fallback
for robustness.
"""
chunks: list[torch.Tensor] = []
sr_final = SAMPLE_RATE
for out in omni_runner.omni.generate(request, _SAMPLING):
mm = out.multimodal_output
if not mm:
continue
audio = mm.get("audio")
if audio is None:
audio = mm.get("model_outputs")
if audio is None:
continue
sr = mm.get("sr")
if sr is not None:
sr_final = int(sr.item() if hasattr(sr, "item") else sr)
if isinstance(audio, list):
audio = torch.cat(
[t.reshape(-1) for t in audio if isinstance(t, torch.Tensor) and t.numel() > 0],
dim=0,
)
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
chunks.append(audio.reshape(-1).cpu())
if not chunks:
raise AssertionError("No audio output received from generate()")
return torch.cat(chunks, dim=0), sr_final
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_moss_tts_english(omni_runner: OmniRunner) -> None:
"""VoiceGenerator: English instruction produces non-empty 24 kHz audio."""
req = _build_request(
"Hello, this is a MOSS voice design test.",
"a warm female voice with an American accent",
)
audio, sr = _collect_audio(omni_runner, req)
assert sr == SAMPLE_RATE, f"Expected {SAMPLE_RATE} Hz, got {sr}"
assert audio.numel() > 0, "Audio tensor is empty"
assert not torch.all(audio == 0), "Audio is silence"
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_moss_tts_chinese(omni_runner: OmniRunner) -> None:
"""VoiceGenerator: Chinese input produces non-empty audio."""
req = _build_request(
"你好,这是语音合成测试。",
"一个清晰温暖的女声",
)
audio, sr = _collect_audio(omni_runner, req)
assert sr == SAMPLE_RATE
assert audio.numel() > 0
assert not torch.all(audio == 0)
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_moss_tts_batch(omni_runner: OmniRunner) -> None:
"""VoiceGenerator: batch of two requests each returns non-empty audio."""
requests = [
_build_request("First sentence.", "a warm female voice"),
_build_request("Second sentence.", "a young male voice"),
]
results: list[torch.Tensor] = []
for out in omni_runner.omni.generate(requests, _SAMPLING):
mm = out.multimodal_output
if not mm:
continue
audio = mm.get("audio")
if audio is None:
audio = mm.get("model_outputs")
if audio is None:
continue
if isinstance(audio, list):
audio = torch.cat(
[t.reshape(-1) for t in audio if isinstance(t, torch.Tensor) and t.numel() > 0],
dim=0,
)
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
results.append(audio.reshape(-1).cpu())
# With async_chunk streaming + FINAL_ONLY, each request produces one
# consolidated output. The >= 2 guard handles backward-compatibility if
# consolidation behavior changes.
assert len(results) >= 2, f"Expected at least 2 audio outputs, got {len(results)}"
for i, audio in enumerate(results):
assert audio.numel() > 0, f"Audio chunk[{i}] is empty"