feat(moss-tts-nano): add single-stage MOSS-TTS-Nano TTS support
Integrates OpenMOSS-Team/MOSS-TTS-Nano (0.1B) as a single GPUGenerationWorker stage. Both the AR LM and MOSS-Audio-Tokenizer-Nano codec run inside MossTTSNanoForGeneration.forward(), removing the need for an inter-stage connector. Key design choices: - Weights loaded in load_weights() not __init__ (avoids pre-CUDA alloc) - trust_remote_code delegates to upstream HF model classes - codec path read from config.audio_tokenizer_pretrained_name_or_path - inference_stream() collects progressive audio chunks for low latency - 48 kHz stereo output; voice clone + continuation modes supported Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
1 parent
bc4a659f03
commit
da6b1b5741
7 files changed
+806
No files matched your search
@@ -0,0 +1,184 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline inference example for MOSS-TTS-Nano via vLLM-Omni.
|
||||
|
||||
Single-stage pipeline: the 0.1B AR LM and MOSS-Audio-Tokenizer-Nano codec
|
||||
both run inside one generation stage. Output is 48 kHz stereo WAV.
|
||||
|
||||
Supports:
|
||||
- Built-in voice presets (--voice)
|
||||
- Custom voice cloning via reference audio (--prompt-audio)
|
||||
- Batch synthesis of multiple texts
|
||||
- Reproducible output via --seed
|
||||
|
||||
Usage examples:
|
||||
# Built-in voice
|
||||
python end2end.py --text "Hello from MOSS-TTS-Nano."
|
||||
|
||||
# Voice clone with reference audio
|
||||
python end2end.py --text "Hello!" --prompt-audio /path/to/ref.wav
|
||||
|
||||
# Batch with different voices
|
||||
python end2end.py --batch --output-dir /tmp/moss_output
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from vllm import SamplingParams
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
# Prevent multiprocessing from re-importing CUDA in the wrong context.
|
||||
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
|
||||
|
||||
from vllm_omni import Omni # noqa: E402
|
||||
|
||||
MODEL = "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEFAULT_STAGE_CONFIG = str(
|
||||
_REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "moss_tts_nano.yaml"
|
||||
)
|
||||
|
||||
BATCH_SAMPLES = [
|
||||
{"text": "Hello, this is a test of MOSS-TTS-Nano.", "voice": "Ava", "label": "en_hello"},
|
||||
{"text": "你好,这是 MOSS-TTS-Nano 的语音合成测试。", "voice": "Junhao", "label": "zh_hello"},
|
||||
{"text": "Bonjour, ceci est un test de synthèse vocale.", "voice": "Bella", "label": "fr_hello"},
|
||||
]
|
||||
|
||||
|
||||
def build_request(
|
||||
text: str,
|
||||
voice: str = "Junhao",
|
||||
mode: str = "voice_clone",
|
||||
prompt_audio_path: str | None = None,
|
||||
prompt_text: str | None = None,
|
||||
max_new_frames: int = 375,
|
||||
seed: int | None = None,
|
||||
audio_temperature: float = 0.8,
|
||||
audio_top_k: int = 25,
|
||||
audio_top_p: float = 0.95,
|
||||
text_temperature: float = 1.0,
|
||||
) -> dict:
|
||||
"""Build an Omni request payload for MOSS-TTS-Nano."""
|
||||
additional: dict = {
|
||||
"text": [text],
|
||||
"voice": [voice],
|
||||
"mode": [mode],
|
||||
"max_new_frames": [max_new_frames],
|
||||
"audio_temperature": [audio_temperature],
|
||||
"audio_top_k": [audio_top_k],
|
||||
"audio_top_p": [audio_top_p],
|
||||
"text_temperature": [text_temperature],
|
||||
}
|
||||
if prompt_audio_path:
|
||||
additional["prompt_audio_path"] = [str(prompt_audio_path)]
|
||||
if prompt_text:
|
||||
additional["prompt_text"] = [prompt_text]
|
||||
if seed is not None:
|
||||
additional["seed"] = [seed]
|
||||
|
||||
return {
|
||||
"prompt": "<|im_start|>assistant\n", # minimal placeholder prompt
|
||||
"additional_information": additional,
|
||||
}
|
||||
|
||||
|
||||
def save_audio(waveform: torch.Tensor, path: str, sample_rate: int = 48000) -> None:
|
||||
audio_np = waveform.float().numpy()
|
||||
# Reshape stereo: inference_stream yields interleaved [samples*2]; reshape to [samples, 2]
|
||||
if audio_np.ndim == 1 and audio_np.shape[0] % 2 == 0:
|
||||
audio_np = audio_np.reshape(-1, 2)
|
||||
sf.write(path, audio_np, sample_rate)
|
||||
print(f" Saved {path} ({audio_np.shape}, {sample_rate} Hz)")
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
omni = Omni(
|
||||
model=MODEL,
|
||||
stage_configs_path=args.stage_config,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
)
|
||||
|
||||
sampling_params = SamplingParams(
|
||||
temperature=1.0,
|
||||
top_p=1.0,
|
||||
top_k=50,
|
||||
max_tokens=4096,
|
||||
seed=args.seed if args.seed is not None else 42,
|
||||
detokenize=False,
|
||||
)
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.batch:
|
||||
print(f"Running batch synthesis ({len(BATCH_SAMPLES)} samples)...")
|
||||
inputs = [
|
||||
build_request(s["text"], voice=s["voice"], seed=args.seed)
|
||||
for s in BATCH_SAMPLES
|
||||
]
|
||||
params_list = [sampling_params] * len(inputs)
|
||||
else:
|
||||
print(f"Synthesizing: {args.text!r}")
|
||||
inputs = build_request(
|
||||
text=args.text,
|
||||
voice=args.voice,
|
||||
mode=args.mode,
|
||||
prompt_audio_path=args.prompt_audio,
|
||||
prompt_text=args.prompt_text,
|
||||
max_new_frames=args.max_new_frames,
|
||||
seed=args.seed,
|
||||
audio_temperature=args.audio_temperature,
|
||||
audio_top_k=args.audio_top_k,
|
||||
audio_top_p=args.audio_top_p,
|
||||
text_temperature=args.text_temperature,
|
||||
)
|
||||
params_list = sampling_params
|
||||
|
||||
for stage_outputs in omni.generate(inputs, params_list):
|
||||
for i, req_output in enumerate(stage_outputs.request_output):
|
||||
for j, out in enumerate(req_output.outputs):
|
||||
mm = out.multimodal_output
|
||||
if mm is None:
|
||||
print(f" [req {i}] No audio output.")
|
||||
continue
|
||||
audio = mm.get("audio")
|
||||
sr_tensor = mm.get("sr")
|
||||
if audio is None:
|
||||
print(f" [req {i}] No waveform in multimodal_output.")
|
||||
continue
|
||||
sr = int(sr_tensor.item()) if sr_tensor is not None else 48000
|
||||
label = BATCH_SAMPLES[i]["label"] if args.batch else f"output_{i}_{j}"
|
||||
out_path = str(output_dir / f"{label}.wav")
|
||||
save_audio(audio.cpu(), out_path, sr)
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = FlexibleArgumentParser(description="MOSS-TTS-Nano offline inference")
|
||||
parser.add_argument("--text", default="Hello, this is MOSS-TTS-Nano speaking.", help="Text to synthesize.")
|
||||
parser.add_argument("--voice", default="Junhao", help="Built-in voice preset name.")
|
||||
parser.add_argument("--mode", default="voice_clone", choices=["voice_clone", "continuation"])
|
||||
parser.add_argument("--prompt-audio", default=None, help="Path to reference audio for voice cloning.")
|
||||
parser.add_argument("--prompt-text", default=None, help="Reference transcript (continuation mode).")
|
||||
parser.add_argument("--max-new-frames", type=int, default=375, help="Max AR frames (~14s at default).")
|
||||
parser.add_argument("--seed", type=int, default=None, help="Random seed.")
|
||||
parser.add_argument("--audio-temperature", type=float, default=0.8)
|
||||
parser.add_argument("--audio-top-k", type=int, default=25)
|
||||
parser.add_argument("--audio-top-p", type=float, default=0.95)
|
||||
parser.add_argument("--text-temperature", type=float, default=1.0)
|
||||
parser.add_argument("--batch", action="store_true", help="Run built-in batch of diverse samples.")
|
||||
parser.add_argument("--output-dir", default="/tmp/moss_tts_nano_output", help="Directory for WAV outputs.")
|
||||
parser.add_argument("--stage-config", default=DEFAULT_STAGE_CONFIG)
|
||||
parser.add_argument("--stage-init-timeout", type=int, default=120)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
@@ -0,0 +1,132 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""E2E offline inference tests for MOSS-TTS-Nano single-stage pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm import SamplingParams
|
||||
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni import Omni
|
||||
|
||||
MODEL_NAME = "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||
STAGE_CONFIG = "vllm_omni/model_executor/stage_configs/moss_tts_nano.yaml"
|
||||
SAMPLE_RATE = 48000
|
||||
|
||||
DEFAULT_SAMPLING = SamplingParams(
|
||||
temperature=1.0,
|
||||
top_p=1.0,
|
||||
top_k=50,
|
||||
max_tokens=4096,
|
||||
seed=42,
|
||||
detokenize=False,
|
||||
)
|
||||
|
||||
|
||||
def _build_request(
|
||||
text: str,
|
||||
voice: str = "Junhao",
|
||||
mode: str = "voice_clone",
|
||||
max_new_frames: int = 100, # short for tests
|
||||
seed: int = 42,
|
||||
) -> dict:
|
||||
return {
|
||||
"prompt": "<|im_start|>assistant\n",
|
||||
"additional_information": {
|
||||
"text": [text],
|
||||
"voice": [voice],
|
||||
"mode": [mode],
|
||||
"max_new_frames": [max_new_frames],
|
||||
"seed": [seed],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _collect_audio(omni: Omni, request: dict) -> tuple[torch.Tensor, int]:
|
||||
"""Run a single request and return (waveform, sample_rate)."""
|
||||
for stage_outputs in omni.generate(request, DEFAULT_SAMPLING):
|
||||
for req_output in stage_outputs.request_output:
|
||||
mm = req_output.outputs[0].multimodal_output
|
||||
assert mm is not None, "Expected multimodal_output to be non-None"
|
||||
audio = mm.get("audio")
|
||||
sr = mm.get("sr")
|
||||
assert audio is not None, "Expected 'audio' key in multimodal_output"
|
||||
assert isinstance(audio, torch.Tensor), f"audio should be Tensor, got {type(audio)}"
|
||||
return audio.cpu(), int(sr.item()) if sr is not None else SAMPLE_RATE
|
||||
raise AssertionError("No stage outputs received")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def omni_engine():
|
||||
"""Module-scoped Omni engine to avoid re-loading the model for each test."""
|
||||
return Omni(model=MODEL_NAME, stage_configs_path=STAGE_CONFIG, stage_init_timeout=180)
|
||||
|
||||
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
def test_moss_tts_nano_english(omni_engine):
|
||||
"""English TTS produces non-empty 48 kHz stereo audio."""
|
||||
req = _build_request("Hello, this is a test of MOSS-TTS-Nano.", voice="Ava")
|
||||
audio, sr = _collect_audio(omni_engine, req)
|
||||
|
||||
assert sr == SAMPLE_RATE, f"Expected sample_rate={SAMPLE_RATE}, got {sr}"
|
||||
assert audio.numel() > 0, "Audio tensor should not be empty"
|
||||
assert not torch.all(audio == 0), "Audio should not be all-zeros (silence)"
|
||||
|
||||
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
def test_moss_tts_nano_chinese(omni_engine):
|
||||
"""Chinese TTS produces non-empty audio."""
|
||||
req = _build_request("你好,这是语音合成测试。", voice="Junhao")
|
||||
audio, sr = _collect_audio(omni_engine, req)
|
||||
|
||||
assert sr == SAMPLE_RATE
|
||||
assert audio.numel() > 0
|
||||
assert not torch.all(audio == 0)
|
||||
|
||||
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
def test_moss_tts_nano_deterministic(omni_engine):
|
||||
"""Same seed produces identical waveforms."""
|
||||
req = _build_request("Reproducible output test.", voice="Adam", seed=123)
|
||||
audio1, _ = _collect_audio(omni_engine, req)
|
||||
audio2, _ = _collect_audio(omni_engine, req)
|
||||
|
||||
assert audio1.shape == audio2.shape, "Waveform shapes should match with same seed"
|
||||
assert torch.allclose(audio1, audio2, atol=1e-4), "Waveforms should match with same seed"
|
||||
|
||||
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
def test_moss_tts_nano_batch(omni_engine):
|
||||
"""Batch of two requests returns audio for each."""
|
||||
requests = [
|
||||
_build_request("First request.", voice="Ava"),
|
||||
_build_request("Second request.", voice="Bella"),
|
||||
]
|
||||
results = []
|
||||
for stage_outputs in omni_engine.generate(requests, [DEFAULT_SAMPLING] * 2):
|
||||
for req_output in stage_outputs.request_output:
|
||||
mm = req_output.outputs[0].multimodal_output
|
||||
assert mm is not None
|
||||
results.append(mm["audio"].cpu())
|
||||
|
||||
assert len(results) == 2, f"Expected 2 outputs, got {len(results)}"
|
||||
for i, audio in enumerate(results):
|
||||
assert audio.numel() > 0, f"Audio {i} is empty"
|
||||
|
||||
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
def test_moss_tts_nano_voice_presets(omni_engine):
|
||||
"""Multiple built-in voice presets all produce valid audio."""
|
||||
voices = ["Junhao", "Ava", "Bella"]
|
||||
for voice in voices:
|
||||
req = _build_request(f"Testing voice {voice}.", voice=voice)
|
||||
audio, sr = _collect_audio(omni_engine, req)
|
||||
assert audio.numel() > 0, f"Voice {voice} produced empty audio"
|
||||
assert sr == SAMPLE_RATE
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Configuration for MOSS-TTS-Nano in vLLM-Omni single-stage pipeline."""
|
||||
|
||||
from transformers.configuration_utils import PretrainedConfig
|
||||
|
||||
|
||||
class MossTTSNanoConfig(PretrainedConfig):
|
||||
"""Config for MOSS-TTS-Nano (OpenMOSS-Team/MOSS-TTS-Nano).
|
||||
|
||||
The model is a Global-Local GPT2-based AR LM (0.1B) paired with the
|
||||
MOSS-Audio-Tokenizer-Nano codec. Both components run in a single
|
||||
vLLM-Omni generation stage.
|
||||
|
||||
Relevant fields from config.json:
|
||||
gpt2_config – backbone config (n_layer=12, n_embd=768, n_head=12, ...)
|
||||
n_vq – number of RVQ codebooks (16)
|
||||
audio_vocab_size – per-codebook vocabulary size (1024)
|
||||
audio_tokenizer_pretrained_name_or_path – HF hub path for codec model
|
||||
"""
|
||||
|
||||
model_type = "moss_tts_nano"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
gpt2_cfg = kwargs.pop("gpt2_config", None) or {}
|
||||
if hasattr(gpt2_cfg, "to_dict"):
|
||||
gpt2_cfg = gpt2_cfg.to_dict()
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# --- GPT2 backbone parameters (exposed at top level for vLLM) ---
|
||||
self.hidden_size = gpt2_cfg.get("n_embd", 768)
|
||||
self.num_hidden_layers = gpt2_cfg.get("n_layer", 12)
|
||||
self.num_attention_heads = gpt2_cfg.get("n_head", 12)
|
||||
self.num_key_value_heads = self.num_attention_heads # no GQA in GPT2
|
||||
self.head_dim = self.hidden_size // self.num_attention_heads
|
||||
self.vocab_size = gpt2_cfg.get("vocab_size", 16384)
|
||||
self.max_position_embeddings = gpt2_cfg.get("n_positions", 32768)
|
||||
self.intermediate_size = gpt2_cfg.get("n_inner", self.hidden_size * 4)
|
||||
|
||||
# --- Audio codec parameters ---
|
||||
self.n_vq: int = getattr(self, "n_vq", 16)
|
||||
self.audio_vocab_size: int = getattr(self, "audio_vocab_size", 1024)
|
||||
self.audio_start_token_id: int = getattr(self, "audio_start_token_id", 6)
|
||||
self.audio_end_token_id: int = getattr(self, "audio_end_token_id", 7)
|
||||
self.audio_user_slot_token_id: int = getattr(self, "audio_user_slot_token_id", 8)
|
||||
self.audio_assistant_slot_token_id: int = getattr(self, "audio_assistant_slot_token_id", 9)
|
||||
self.audio_tokenizer_pretrained_name_or_path: str = getattr(
|
||||
self,
|
||||
"audio_tokenizer_pretrained_name_or_path",
|
||||
"OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano",
|
||||
)
|
||||
self.audio_tokenizer_sample_rate: int = getattr(self, "audio_tokenizer_sample_rate", 48000)
|
||||
|
||||
# vLLM requires speculative_config to be absent or None
|
||||
self.speculative_config = None
|
||||
|
||||
def get_text_config(self, **kwargs):
|
||||
"""Return self so vLLM uses our top-level config."""
|
||||
return self
|
||||
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""MOSS-TTS-Nano single-stage model for vLLM-Omni.
|
||||
|
||||
Runs in a single GPUGenerationWorker stage. The 0.1B AR LM and the
|
||||
MOSS-Audio-Tokenizer-Nano codec are both loaded here; the full
|
||||
text → audio-codes → waveform pipeline executes inside forward().
|
||||
|
||||
Streaming (realtime) generation is supported via model.inference_stream():
|
||||
audio chunks are collected as the LM emits tokens and returned as a
|
||||
concatenated waveform. Future work can expose per-chunk streaming via
|
||||
vLLM-Omni's SSE audio endpoint.
|
||||
|
||||
Weight loading deliberately happens inside load_weights() -- NOT __init__ --
|
||||
so that vLLM initialises distributed state before any CUDA allocations occur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Default sampling parameters matching the upstream demo defaults.
|
||||
_DEFAULT_TEXT_TEMPERATURE = 1.0
|
||||
_DEFAULT_TEXT_TOP_P = 1.0
|
||||
_DEFAULT_TEXT_TOP_K = 50
|
||||
_DEFAULT_AUDIO_TEMPERATURE = 0.8
|
||||
_DEFAULT_AUDIO_TOP_P = 0.95
|
||||
_DEFAULT_AUDIO_TOP_K = 25
|
||||
_DEFAULT_AUDIO_REPETITION_PENALTY = 1.2
|
||||
_DEFAULT_MAX_NEW_FRAMES = 375
|
||||
_DEFAULT_VOICE = "Junhao"
|
||||
_DEFAULT_MODE = "voice_clone"
|
||||
|
||||
|
||||
def _pick(info: dict, key: str, default):
|
||||
"""Extract scalar from additional_information dict (list or plain value)."""
|
||||
val = info.get(key, default)
|
||||
if isinstance(val, (list, tuple)) and len(val) > 0:
|
||||
return val[0]
|
||||
return val if val is not None else default
|
||||
|
||||
|
||||
class MossTTSNanoForGeneration(nn.Module):
|
||||
"""Single-stage MOSS-TTS-Nano generation model.
|
||||
|
||||
Integrates the 0.1B MOSS-TTS-Nano LM and the MOSS-Audio-Tokenizer-Nano
|
||||
codec into a single vLLM-Omni generation stage (GPUGenerationWorker).
|
||||
|
||||
The model uses trust_remote_code=True to load the upstream HuggingFace
|
||||
model classes, keeping our implementation thin and easy to update as
|
||||
the upstream model evolves.
|
||||
|
||||
Supported request fields in additional_information:
|
||||
text (str) – text to synthesize [required]
|
||||
voice (str) – built-in voice preset name, default "Junhao"
|
||||
mode (str) – "voice_clone" (default) or "continuation"
|
||||
prompt_audio_path (str) – path to reference WAV/MP3 for custom voice clone
|
||||
prompt_text (str) – reference transcript (for continuation mode)
|
||||
max_new_frames (int) – max AR frames, default 375 (~14s audio)
|
||||
text_temperature (float) – LM text-layer temperature
|
||||
text_top_p (float) – LM text-layer top-p
|
||||
text_top_k (int) – LM text-layer top-k
|
||||
audio_temperature (float) – LM audio-layer temperature
|
||||
audio_top_p (float) – LM audio-layer top-p
|
||||
audio_top_k (int) – LM audio-layer top-k
|
||||
audio_repetition_penalty (float) – LM audio repetition penalty
|
||||
seed (int) – optional random seed for reproducibility
|
||||
"""
|
||||
|
||||
requires_raw_input_tokens = True
|
||||
have_multimodal_outputs = True
|
||||
has_preprocess = False
|
||||
has_postprocess = False
|
||||
enable_update_additional_information = True
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
|
||||
super().__init__()
|
||||
self.config = vllm_config.model_config.hf_config
|
||||
self.model_path: str = vllm_config.model_config.model
|
||||
|
||||
# Populated by load_weights(); kept None until then so __init__ is
|
||||
# allocation-free (vLLM may call __init__ before CUDA is ready).
|
||||
self._lm: nn.Module | None = None
|
||||
self._audio_tokenizer: nn.Module | None = None
|
||||
self._device: torch.device | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Dummy parameter so vLLM can resolve the device via next(parameters()).
|
||||
self._sentinel = nn.Parameter(torch.zeros(1), requires_grad=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Weight loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Load the MOSS-TTS-Nano LM and MOSS-Audio-Tokenizer-Nano codec.
|
||||
|
||||
Both models are loaded via trust_remote_code so we stay in sync
|
||||
with any upstream architecture changes without reimplementing
|
||||
the model code ourselves.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._lm is not None:
|
||||
return set()
|
||||
|
||||
try:
|
||||
device = next(self.parameters()).device
|
||||
except StopIteration:
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
self._device = device
|
||||
|
||||
# Select compute dtype: bfloat16 on capable CUDA, float32 elsewhere.
|
||||
if device.type == "cuda" and torch.cuda.is_bf16_supported():
|
||||
tts_dtype = torch.bfloat16
|
||||
elif device.type == "cuda":
|
||||
tts_dtype = torch.float16
|
||||
else:
|
||||
tts_dtype = torch.float32
|
||||
|
||||
# --- Load the AR language model ---
|
||||
logger.info("Loading MOSS-TTS-Nano LM from %s (dtype=%s)", self.model_path, tts_dtype)
|
||||
from transformers import AutoModelForCausalLM
|
||||
|
||||
lm = AutoModelForCausalLM.from_pretrained(
|
||||
self.model_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=tts_dtype,
|
||||
)
|
||||
if device.type == "cuda":
|
||||
# Use SDPA when flash_attention_2 is unavailable.
|
||||
try:
|
||||
lm._set_attention_implementation("flash_attention_2")
|
||||
except Exception:
|
||||
lm._set_attention_implementation("sdpa")
|
||||
lm.to(device=device)
|
||||
lm.eval()
|
||||
self._lm = lm
|
||||
logger.info("MOSS-TTS-Nano LM loaded on %s", device)
|
||||
|
||||
# --- Load the audio tokenizer (codec) ---
|
||||
codec_path: str = getattr(
|
||||
self.config,
|
||||
"audio_tokenizer_pretrained_name_or_path",
|
||||
"OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano",
|
||||
)
|
||||
logger.info("Loading MOSS-Audio-Tokenizer-Nano from %s", codec_path)
|
||||
from transformers import AutoModel
|
||||
|
||||
audio_tokenizer = AutoModel.from_pretrained(
|
||||
codec_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=torch.float32, # codec runs in fp32 for stability
|
||||
)
|
||||
audio_tokenizer.to(device=device)
|
||||
audio_tokenizer.eval()
|
||||
self._audio_tokenizer = audio_tokenizer
|
||||
logger.info("MOSS-Audio-Tokenizer-Nano loaded on %s", device)
|
||||
|
||||
# Consume the vLLM weight iterator (we loaded weights ourselves above).
|
||||
for _ in weights:
|
||||
pass
|
||||
return set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dummy run support (vLLM profiling / KV-cache estimation)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_dummy_runtime_additional_information(self, num_reqs: int) -> list[dict]:
|
||||
return [{"text": "hello", "voice": _DEFAULT_VOICE, "_is_dummy": True}] * num_reqs
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core forward pass
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None = None,
|
||||
positions: torch.Tensor | None = None,
|
||||
intermediate_tensors: Any = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
runtime_additional_information: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
"""Run full MOSS-TTS-Nano pipeline: text → audio codes → waveform.
|
||||
|
||||
Each request in the batch is processed independently. Results are
|
||||
returned as lists so the generation runner can demultiplex them.
|
||||
"""
|
||||
sr = getattr(self.config, "audio_tokenizer_sample_rate", 48000)
|
||||
sr_tensor = torch.tensor(sr, dtype=torch.int32)
|
||||
empty = torch.zeros((0,), dtype=torch.float32)
|
||||
|
||||
if not runtime_additional_information:
|
||||
# Profiling / dummy run — return empty audio.
|
||||
n = 1 if input_ids is None else max(1, input_ids.shape[0])
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={
|
||||
"model_outputs": [empty] * n,
|
||||
"sr": [sr_tensor] * n,
|
||||
},
|
||||
)
|
||||
|
||||
if self._lm is None or self._audio_tokenizer is None:
|
||||
raise RuntimeError("MOSS-TTS-Nano model not loaded. Was load_weights() called?")
|
||||
|
||||
waveforms: list[torch.Tensor] = []
|
||||
srs: list[torch.Tensor] = []
|
||||
|
||||
for info in runtime_additional_information:
|
||||
if info.get("_is_dummy"):
|
||||
waveforms.append(empty)
|
||||
srs.append(sr_tensor)
|
||||
continue
|
||||
|
||||
waveform = self._run_single_inference(info, sr)
|
||||
waveforms.append(waveform)
|
||||
srs.append(sr_tensor)
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": waveforms, "sr": srs},
|
||||
)
|
||||
|
||||
def _run_single_inference(self, info: dict[str, Any], sr: int) -> torch.Tensor:
|
||||
"""Run inference for a single request and return the waveform tensor."""
|
||||
text: str = str(_pick(info, "text", "") or "")
|
||||
if not text.strip():
|
||||
logger.warning("MOSS-TTS-Nano received empty text; returning silence.")
|
||||
return torch.zeros((sr,), dtype=torch.float32)
|
||||
|
||||
voice: str = str(_pick(info, "voice", _DEFAULT_VOICE))
|
||||
mode: str = str(_pick(info, "mode", _DEFAULT_MODE))
|
||||
prompt_audio_path: str | None = _pick(info, "prompt_audio_path", None)
|
||||
if prompt_audio_path is not None:
|
||||
prompt_audio_path = str(prompt_audio_path)
|
||||
prompt_text: str | None = _pick(info, "prompt_text", None)
|
||||
if prompt_text is not None:
|
||||
prompt_text = str(prompt_text)
|
||||
max_new_frames: int = int(_pick(info, "max_new_frames", _DEFAULT_MAX_NEW_FRAMES))
|
||||
seed: int | None = _pick(info, "seed", None)
|
||||
if seed is not None:
|
||||
seed = int(seed)
|
||||
|
||||
sampling = {
|
||||
"text_temperature": float(_pick(info, "text_temperature", _DEFAULT_TEXT_TEMPERATURE)),
|
||||
"text_top_p": float(_pick(info, "text_top_p", _DEFAULT_TEXT_TOP_P)),
|
||||
"text_top_k": int(_pick(info, "text_top_k", _DEFAULT_TEXT_TOP_K)),
|
||||
"audio_temperature": float(_pick(info, "audio_temperature", _DEFAULT_AUDIO_TEMPERATURE)),
|
||||
"audio_top_p": float(_pick(info, "audio_top_p", _DEFAULT_AUDIO_TOP_P)),
|
||||
"audio_top_k": int(_pick(info, "audio_top_k", _DEFAULT_AUDIO_TOP_K)),
|
||||
"audio_repetition_penalty": float(
|
||||
_pick(info, "audio_repetition_penalty", _DEFAULT_AUDIO_REPETITION_PENALTY)
|
||||
),
|
||||
}
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
|
||||
device = self._device or torch.device("cpu")
|
||||
|
||||
# Use a temp file as the output path (the upstream API requires a path).
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
|
||||
audio_chunks: list[torch.Tensor] = []
|
||||
|
||||
try:
|
||||
for event in self._lm.inference_stream(
|
||||
text=text,
|
||||
output_audio_path=output_path,
|
||||
mode=mode,
|
||||
prompt_text=prompt_text,
|
||||
prompt_audio_path=prompt_audio_path,
|
||||
text_tokenizer_path=self.model_path,
|
||||
audio_tokenizer=self._audio_tokenizer,
|
||||
device=device,
|
||||
nq=None,
|
||||
max_new_frames=max_new_frames,
|
||||
do_sample=True,
|
||||
use_kv_cache=True,
|
||||
**sampling,
|
||||
):
|
||||
event_type = str(event.get("type", ""))
|
||||
if event_type == "audio":
|
||||
waveform = event.get("waveform")
|
||||
if waveform is not None:
|
||||
chunk = torch.as_tensor(waveform, dtype=torch.float32).cpu()
|
||||
audio_chunks.append(chunk)
|
||||
elif event_type == "result":
|
||||
# Final event -- prefer the full waveform from the result
|
||||
# if no intermediate chunks were collected.
|
||||
if not audio_chunks:
|
||||
waveform = event.get("waveform")
|
||||
if waveform is not None:
|
||||
audio_chunks.append(
|
||||
torch.as_tensor(waveform, dtype=torch.float32).cpu()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("MOSS-TTS-Nano inference failed for text=%r", text[:80])
|
||||
return torch.zeros((sr,), dtype=torch.float32)
|
||||
finally:
|
||||
# Clean up temp file.
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not audio_chunks:
|
||||
logger.warning("MOSS-TTS-Nano produced no audio for text=%r", text[:80])
|
||||
return torch.zeros((sr,), dtype=torch.float32)
|
||||
|
||||
# Concatenate all streaming chunks into a single waveform.
|
||||
# inference_stream yields chunks that are 1D or 2D [samples] / [channels, samples].
|
||||
# Flatten to 1D for the vLLM-Omni audio output format.
|
||||
processed: list[torch.Tensor] = []
|
||||
for chunk in audio_chunks:
|
||||
if chunk.ndim == 2:
|
||||
# [channels, samples] → interleave to [samples * channels] for stereo
|
||||
processed.append(chunk.T.reshape(-1))
|
||||
else:
|
||||
processed.append(chunk.reshape(-1))
|
||||
|
||||
return torch.cat(processed, dim=0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# vLLM boilerplate: embed_input_ids required by generation runner
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def embed_input_ids(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
multimodal_embeddings=None,
|
||||
is_multimodal=None,
|
||||
) -> torch.Tensor:
|
||||
# Generation stage does not use token embeddings -- return a dummy
|
||||
# zero tensor of the expected shape so the runner's prefill works.
|
||||
hidden = int(getattr(self.config, "hidden_size", 768))
|
||||
return torch.zeros(
|
||||
(input_ids.shape[0], hidden),
|
||||
device=input_ids.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
@@ -163,6 +163,12 @@ _OMNI_MODELS = {
|
||||
"VoxtralTTSAudioGenerationForConditionalGeneration",
|
||||
),
|
||||
"VoxtralTTSAudioTokenizer": ("voxtral_tts", "voxtral_tts_audio_tokenizer", "VoxtralTTSAudioTokenizer"),
|
||||
## MOSS-TTS-Nano
|
||||
"MossTTSNanoForCausalLM": (
|
||||
"moss_tts_nano",
|
||||
"moss_tts_nano",
|
||||
"MossTTSNanoForGeneration",
|
||||
),
|
||||
"DyninOmniForConditionalGeneration": (
|
||||
"dynin_omni",
|
||||
"dynin_omni",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Stage config for MOSS-TTS-Nano (OpenMOSS-Team/MOSS-TTS-Nano).
|
||||
#
|
||||
# Single generation stage: the 0.1B AR LM and MOSS-Audio-Tokenizer-Nano codec
|
||||
# both run inside MossTTSNanoForGeneration.forward(). No inter-stage connector
|
||||
# is needed. Streaming (realtime) audio is supported via inference_stream()
|
||||
# inside the model, which progressively collects audio chunks during AR decoding.
|
||||
#
|
||||
# Verified on: 1x GPU with ≥2 GB VRAM, or CPU (slower).
|
||||
# Output: 48 kHz, 2-channel stereo audio.
|
||||
#
|
||||
# Request additional_information fields:
|
||||
# text (str) – text to synthesize [required]
|
||||
# voice (str) – built-in voice preset, default "Junhao"
|
||||
# mode (str) – "voice_clone" (default) | "continuation"
|
||||
# prompt_audio_path (str) – path to reference audio for custom voice clone
|
||||
# prompt_text (str) – reference transcript (continuation mode)
|
||||
# max_new_frames (int) – max AR frames, default 375 (~14 s)
|
||||
# text_temperature (float) – default 1.0
|
||||
# audio_temperature (float) – default 0.8
|
||||
# audio_top_k (int) – default 25
|
||||
# audio_top_p (float) – default 0.95
|
||||
# seed (int) – optional RNG seed
|
||||
|
||||
async_chunk: false
|
||||
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
is_comprehension: false
|
||||
runtime:
|
||||
devices: "0"
|
||||
engine_args:
|
||||
model_stage: moss_tts_nano
|
||||
model_arch: MossTTSNanoForCausalLM
|
||||
worker_type: generation
|
||||
worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
trust_remote_code: true
|
||||
enforce_eager: true
|
||||
engine_output_type: audio
|
||||
gpu_memory_utilization: 0.3
|
||||
enable_prefix_caching: false
|
||||
skip_mm_profiling: true
|
||||
max_num_seqs: 4
|
||||
max_model_len: 4096
|
||||
max_num_batched_tokens: 4096
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 1.0
|
||||
top_p: 1.0
|
||||
top_k: 50
|
||||
max_tokens: 4096
|
||||
seed: 42
|
||||
detokenize: false
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 4
|
||||
Reference in new issue
Block a user