[Model] VoxCPM2 native AR TTS support (#2658)

Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: SYLAR <lishunyang12@users.noreply.github.com>
This commit is contained in:
Yueqian LinandSYLAR authored and GitHub committed 2026-04-11 10:21:26 +08:00
1 parent 2bc183f6f0
commit a41174e083
12 files changed
+1211

No files matched your search

+25
View File
@@ -317,6 +317,31 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "VoxCPM2 Native AR E2E Test"
timeout_in_minutes: 20
depends_on: upload-ready-pipeline
commands:
- |
timeout 20m bash -c '
pip install voxcpm
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxcpm2.py -m "core_model" --run-level "core_model"
'
agents:
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
- "HF_TOKEN"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "OmniVoice E2E Test"
timeout_in_minutes: 20
depends_on: upload-ready-pipeline
@@ -0,0 +1,83 @@
# VoxCPM2 Offline Inference (Native AR)
VoxCPM2 is a 2B-parameter tokenizer-free diffusion AR TTS model. It produces 48kHz audio and supports 30+ languages with a single-stage native AR pipeline backed by MiniCPM4.
## Prerequisites
Install the `voxcpm` package, or set the environment variable pointing to the source tree:
```bash
# Option A: install package
pip install voxcpm
# Option B: use source checkout
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/voxcpm
```
## Quick Start
Zero-shot synthesis:
```bash
python examples/offline_inference/voxcpm2/end2end.py \
--model openbmb/VoxCPM2 \
--text "Hello, this is a VoxCPM2 demo." \
--output-dir output_audio
```
Voice cloning with a reference audio:
```bash
python examples/offline_inference/voxcpm2/end2end.py \
--text "Hello, this is a voice clone demo." \
--reference-audio /path/to/reference.wav \
--output-dir output_clone
```
Prompt continuation (matched audio + text prefix):
```bash
python examples/offline_inference/voxcpm2/end2end.py \
--text "Continuation target sentence." \
--prompt-audio /path/to/prompt.wav \
--prompt-text "Transcript of the prompt audio." \
--output-dir output_cont
```
The script accepts the following arguments:
| Argument | Default | Description |
|---|---|---|
| `--model` | `openbmb/VoxCPM2` | HuggingFace repo ID or local path |
| `--text` | (example sentence) | Text to synthesize |
| `--output-dir` | `output_audio` | Directory for output WAV files |
| `--stage-configs-path` | `voxcpm2.yaml` | Stage config YAML path |
| `--reference-audio` | `None` | Reference audio for voice cloning (isolated) |
| `--prompt-audio` | `None` | Prompt audio for continuation mode |
| `--prompt-text` | `None` | Transcript matching `--prompt-audio` |
## Performance
Measured on a single H20 GPU (80 GB), voxcpm 0.0.0, PyTorch 2.10.0+cu128:
| Input length | RTF | Sample rate |
|---|---|---|
| Short (~6 words) | ~0.81 | 48 kHz |
| Long (~50 words) | ~0.72 | 48 kHz |
RTF < 1.0 means faster than real time.
## Architecture
VoxCPM2 uses a single-stage native AR pipeline:
```
feat_encoder
└─► MiniCPM4 (base LM)
└─► FSQ (finite scalar quantization)
└─► residual_lm (residual AR)
└─► LocDiT (local diffusion transformer)
└─► AudioVAE → 48 kHz waveform
```
All stages are fused into one vllm-native execution graph via `voxcpm2.yaml`, eliminating inter-stage coordination overhead and enabling true end-to-end batching.
@@ -0,0 +1,145 @@
"""Offline VoxCPM2 inference example (native AR pipeline).
Uses the single-stage native AR config (voxcpm2.yaml).
Requires the `voxcpm` package or VLLM_OMNI_VOXCPM_CODE_PATH env var.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import soundfile as sf
import torch
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import Omni
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_STAGE_CONFIGS_PATH = str(REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm2.yaml")
SAMPLE_RATE = 48_000
def parse_args():
parser = FlexibleArgumentParser(description="Offline VoxCPM2 native AR inference")
parser.add_argument(
"--model",
type=str,
default="openbmb/VoxCPM2",
help="VoxCPM2 model path or HuggingFace repo ID.",
)
parser.add_argument(
"--text",
type=str,
default="This is a VoxCPM2 native AR synthesis example running on vLLM Omni.",
help="Text to synthesize.",
)
parser.add_argument(
"--output-dir",
type=str,
default="output_audio",
help="Directory for output WAV files.",
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=DEFAULT_STAGE_CONFIGS_PATH,
help="Path to the stage config YAML file.",
)
parser.add_argument(
"--reference-audio",
type=str,
default=None,
help="Path to reference audio for voice cloning (isolated ref mode).",
)
parser.add_argument(
"--prompt-audio",
type=str,
default=None,
help="Path to prompt audio for continuation mode (requires --prompt-text).",
)
parser.add_argument(
"--prompt-text",
type=str,
default=None,
help="Text matching --prompt-audio for continuation mode.",
)
return parser.parse_args()
def extract_audio(multimodal_output: dict) -> torch.Tensor:
"""Extract the final complete audio tensor from multimodal output.
The output processor accumulates per-step full audio under ``audio``
as a list. The last element is the complete waveform.
"""
audio = multimodal_output.get("audio") or multimodal_output.get("model_outputs")
if audio is None:
raise ValueError(f"No audio key in multimodal_output: {list(multimodal_output.keys())}")
if isinstance(audio, list):
# Take the last valid tensor (most complete audio)
valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
if not valid:
raise ValueError("Audio list is empty or all elements are None.")
return valid[-1]
return torch.as_tensor(audio).float().cpu().reshape(-1)
def main():
args = parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
engine = Omni(
model=args.model,
stage_configs_path=args.stage_configs_path,
)
additional: dict = {}
if args.reference_audio:
additional["reference_audio"] = args.reference_audio
if args.prompt_audio and args.prompt_text:
additional["prompt_audio"] = args.prompt_audio
additional["prompt_text"] = args.prompt_text
prompt: dict = {"prompt": args.text}
if additional:
prompt["additional_information"] = additional
print(f"Model : {args.model}")
print(f"Text : {args.text}")
if args.reference_audio:
print(f"Ref audio : {args.reference_audio}")
if args.prompt_audio:
print(f"Prompt audio: {args.prompt_audio}")
print(f"Prompt text : {args.prompt_text}")
print(f"Output dir : {output_dir}")
t_start = time.perf_counter()
outputs = engine.generate([prompt])
elapsed = time.perf_counter() - t_start
# outputs[0].outputs[0].multimodal_output["audio"] is a list of tensors
request_output = outputs[0]
mm = request_output.outputs[0].multimodal_output
audio = extract_audio(mm)
duration = audio.numel() / SAMPLE_RATE
rtf = elapsed / duration if duration > 0 else float("inf")
output_path = output_dir / "output.wav"
sf.write(str(output_path), audio.numpy(), SAMPLE_RATE, format="WAV")
print(f"Saved : {output_path}")
print(f"Duration : {duration:.2f}s")
print(f"Inference : {elapsed:.2f}s")
print(f"RTF : {rtf:.3f}")
if __name__ == "__main__":
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
main()
+101
View File
@@ -0,0 +1,101 @@
"""E2E test for VoxCPM2 native AR offline inference."""
import os
import pytest
import torch
from tests.utils import hardware_test
VOXCPM2_MODEL = "openbmb/VoxCPM2"
STAGE_CONFIG = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"..",
"vllm_omni",
"model_executor",
"stage_configs",
"voxcpm2.yaml",
)
SAMPLE_RATE = 48000
@pytest.fixture(scope="module")
def voxcpm2_engine():
"""Create VoxCPM2 engine for testing."""
from vllm_omni import Omni
engine = Omni(model=VOXCPM2_MODEL, stage_configs_path=STAGE_CONFIG)
yield engine
def _extract_audio(multimodal_output: dict) -> torch.Tensor:
"""Extract the final complete audio tensor from multimodal output."""
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
# Output processor accumulates per-step full audio under "audio".
audio = multimodal_output.get("audio") or multimodal_output.get("model_outputs")
assert audio is not None, f"No audio key, got {list(multimodal_output.keys())}"
if isinstance(audio, list):
valid = [x for x in audio if isinstance(x, torch.Tensor) and x.numel() > 100]
assert valid, "No valid audio tensors in output list"
audio = valid[-1]
assert isinstance(audio, torch.Tensor), f"Expected Tensor, got {type(audio)}"
return audio
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voxcpm2_zero_shot_001(voxcpm2_engine):
"""Test zero-shot TTS produces valid audio output."""
outputs = voxcpm2_engine.generate([{"prompt": "Hello, this is a test."}])
assert len(outputs) == 1
audio = _extract_audio(outputs[0].outputs[0].multimodal_output)
duration_s = audio.shape[0] / SAMPLE_RATE
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voxcpm2_voice_clone_002(voxcpm2_engine):
"""Test voice cloning with a reference audio file.
Uses the example ``reference_speaker.wav`` bundled with the voxcpm
package. Skipped if the file is not present.
"""
# Try to locate a reference wav from the voxcpm package / env override
candidates = []
env_path = os.environ.get("VLLM_OMNI_VOXCPM_CODE_PATH")
if env_path:
candidates.append(os.path.join(env_path, "..", "examples", "reference_speaker.wav"))
try:
import voxcpm # noqa: F401 (only used to locate path)
vox_dir = os.path.dirname(os.path.dirname(os.path.abspath(voxcpm.__file__)))
candidates.append(os.path.join(vox_dir, "examples", "reference_speaker.wav"))
except ImportError:
pass
ref_path = next((p for p in candidates if p and os.path.exists(p)), None)
if ref_path is None:
pytest.skip("No reference audio available for voice clone test")
outputs = voxcpm2_engine.generate(
[
{
"prompt": "Hello, this is a voice clone demo.",
"additional_information": {"reference_audio": ref_path},
}
]
)
assert len(outputs) == 1
audio = _extract_audio(outputs[0].outputs[0].multimodal_output)
duration_s = audio.shape[0] / SAMPLE_RATE
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
+3
View File
@@ -20,6 +20,7 @@ logger = init_logger(__name__)
_ARCH_TO_MODEL_TYPE: dict[str, str] = {
"CosyVoice3Model": "cosyvoice3",
"OmniVoiceModel": "omnivoice",
"VoxCPM2TalkerForConditionalGeneration": "voxcpm2",
}
# Maps model architecture names to tokenizer subfolder paths within HF repos.
@@ -40,6 +41,7 @@ def _register_omni_hf_configs() -> None:
from vllm_omni.model_executor.models.voxtral_tts.configuration_voxtral_tts import (
VoxtralTTSConfig,
)
from vllm_omni.transformers_utils.configs.voxcpm2 import VoxCPM2Config
except Exception as exc: # pragma: no cover - best-effort optional registration
logger.warning("Skipping omni HF config registration due to import error: %s", exc)
return
@@ -57,6 +59,7 @@ def _register_omni_hf_configs() -> None:
("cosyvoice3", CosyVoice3Config),
("omnivoice", OmniVoiceConfig),
("voxtral_tts", VoxtralTTSConfig),
("voxcpm2", VoxCPM2Config),
]:
try:
AutoConfig.register(model_type, config_cls)
@@ -145,6 +145,12 @@ _OMNI_MODELS = {
"fish_speech_dac_decoder",
"FishSpeechDACDecoder",
),
## VoxCPM2
"VoxCPM2TalkerForConditionalGeneration": (
"voxcpm2",
"voxcpm2_talker",
"VoxCPM2TalkerForConditionalGeneration",
),
## Voxtral TTS
"VoxtralTTSForConditionalGeneration": (
"voxtral_tts",
@@ -0,0 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from .voxcpm2_talker import VoxCPM2TalkerForConditionalGeneration
__all__ = ["VoxCPM2TalkerForConditionalGeneration"]
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Dynamic import utilities for the native VoxCPM2 package.
Supports three discovery modes (first match wins):
1. ``VLLM_OMNI_VOXCPM_CODE_PATH`` env var (explicit source tree)
2. Sibling ``../VoxCPM/src`` relative to the vllm-omni repo root
3. pip-installed ``voxcpm`` package (>= 2.0)
"""
from __future__ import annotations
import importlib
import os
import sys
from pathlib import Path
from typing import Any
from vllm.logger import init_logger
logger = init_logger(__name__)
def _iter_voxcpm2_src_candidates() -> list[Path]:
"""Yield candidate source directories for VoxCPM2."""
candidates: list[Path] = []
env_path = os.environ.get("VLLM_OMNI_VOXCPM_CODE_PATH")
if env_path:
candidates.append(Path(env_path).expanduser())
repo_root = Path(__file__).resolve().parents[4]
candidates.append(repo_root.parent / "VoxCPM" / "src")
seen: set[str] = set()
unique: list[Path] = []
for c in candidates:
key = str(c)
if key not in seen:
seen.add(key)
unique.append(c)
return unique
def _prepend_src(candidate: Path) -> None:
candidate_str = str(candidate)
if candidate_str not in sys.path:
sys.path.insert(0, candidate_str)
def _import_voxcpm2_attrs(module_name: str, *attr_names: str) -> tuple[Any, ...]:
"""Import attributes from the voxcpm package, trying source tree first."""
last_exc: ImportError | None = None
for candidate in _iter_voxcpm2_src_candidates():
if not candidate.exists():
continue
_prepend_src(candidate)
try:
mod = importlib.import_module(module_name)
return tuple(getattr(mod, name) for name in attr_names)
except (ImportError, AttributeError) as exc:
last_exc = ImportError(str(exc))
continue
try:
mod = importlib.import_module(module_name)
return tuple(getattr(mod, name) for name in attr_names)
except (ImportError, AttributeError) as exc:
last_exc = ImportError(str(exc))
raise ImportError(
f"Could not import {attr_names} from {module_name}. "
f"Install voxcpm>=2.0: pip install voxcpm. "
f"Or set VLLM_OMNI_VOXCPM_CODE_PATH to the VoxCPM source tree. "
f"Last error: {last_exc}"
)
def import_voxcpm2_core():
"""Import the VoxCPM core class used to load the native TTS model."""
(VoxCPM,) = _import_voxcpm2_attrs("voxcpm.core", "VoxCPM")
return VoxCPM
@@ -0,0 +1,569 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""VoxCPM2 native AR talker — uses native MiniCPM4 base_lm directly.
Uses native VoxCPM2 modules (no PagedAttention, manual KV cache).
Each AR decode step:
feat_encoder → base_lm → FSQ → residual_lm → LocDiT → stop
TODO(PagedAttention): The base_lm is a MiniCPM4 variant (GQA + LongRoPE,
use_mup=False). vllm's MiniCPMModel already supports the architecture
(LongRoPE via Phi3LongRoPEScaledRotaryEmbedding, muP via config), but
two issues block replacing the native base_lm with a vllm MiniCPM4Model:
1. Per-request state isolation — residual_lm and LocDiT diffusion use
shared native KV caches; concurrent requests clobber each other.
Fix: save/restore residual_lm cache per request, or pool N instances.
2. Streaming audio — make_omni_output re-decodes all patches each step.
Fix: sliding-window VAE decode (decode_pad pattern from nanovllm).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.model_executor.models.minicpm import MiniCPMModel
from vllm.model_executor.models.utils import (
AutoWeightsLoader,
WeightsMapper,
maybe_prefix,
)
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
from .voxcpm2_import_utils import import_voxcpm2_core
logger = init_logger(__name__)
class VoxCPM2TalkerForConditionalGeneration(nn.Module):
"""VoxCPM2 talker using native MiniCPM4 base_lm.
Loads the full VoxCPM2 model natively and decomposes the AR loop:
each vllm decode step runs one iteration of the native generate loop.
"""
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
self.vllm_config = vllm_config
self.config = vllm_config.model_config.hf_config
# Flags for OmniGPUModelRunner
self.have_multimodal_outputs = True
self.has_preprocess = True
self.has_postprocess = True
self._accumulated_patches: list[torch.Tensor] = []
# vllm MiniCPMModel scaffold — needed for warmup/profiling/KV cache
# sizing. Not used for actual computation (native modules are used).
self.model = MiniCPMModel(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model"))
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
# Placeholder — actual native model loaded in load_weights
self._tts: nn.Module | None = None
self._device = "cuda"
self._side_dtype = torch.bfloat16
# Config values
self._patch_size = getattr(self.config, "patch_size", 4)
self._feat_dim = getattr(self.config, "feat_dim", 64)
self._inference_timesteps = 10
self._cfg_value = 2.0
# TODO: implement sliding-window VAE decode (nanovllm pattern)
# for O(1) per-step streaming. Current impl re-decodes all patches.
@property
def tts(self) -> nn.Module:
assert self._tts is not None, "Model not loaded yet"
return self._tts
# -------------------- vllm hooks --------------------
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
"""Embed input IDs using native base_lm with scale_emb."""
embeds = self.tts.base_lm.embed_tokens(input_ids)
return embeds * self.tts.config.lm_config.scale_emb
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
**kwargs: Any,
) -> torch.Tensor | IntermediateTensors:
"""Full VoxCPM2 AR step: base_lm → FSQ → residual_lm → diffusion."""
# Always run scaffold model to keep FlashInfer/attention happy
model_output = self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
if isinstance(model_output, IntermediateTensors):
return model_output
scaffold_hidden = model_output
if isinstance(scaffold_hidden, tuple):
scaffold_hidden = scaffold_hidden[0]
# Real computation: use native modules
has_infos = bool(getattr(self, "_current_step_infos", None))
is_prefill = scaffold_hidden.shape[0] > 1
if is_prefill and has_infos:
self._forward_prefill(inputs_embeds, scaffold_hidden.device)
# Return scaffold output (right shape for engine) — our side
# computation results are stored in instance state
return scaffold_hidden
if not is_prefill and hasattr(self, "_prev_feat_embed"):
self._forward_decode(inputs_embeds, scaffold_hidden.device)
return scaffold_hidden
return scaffold_hidden
def _build_prefill_inputs(self, text: str, dev: Any):
"""Build text_token / audio_feat / masks like native _generate_with_prompt_cache.
Returns a dict with keys: text_token, audio_feat, text_mask, audio_mask,
prefix_feat_cond. Handles zero-shot, reference (voice clone), continuation,
and ref_continuation modes.
"""
tts = self.tts
dtype = self._side_dtype
cache = getattr(self, "_prompt_cache", None)
mode = cache.get("mode", "continuation") if cache else "zero_shot"
if cache is not None and mode in ("continuation", "ref_continuation"):
full_text = cache.get("prompt_text", "") + text
else:
full_text = text
text_token = torch.LongTensor(tts.text_tokenizer(full_text))
text_token = torch.cat(
[
text_token,
torch.tensor([tts.audio_start_token], dtype=torch.int32, device=text_token.device),
],
dim=-1,
)
text_length = text_token.shape[0]
latent_dim = tts.audio_vae.latent_dim
patch_size = tts.patch_size
if mode in ("zero_shot", "continuation"):
prompt_audio_feat = (
cache["audio_feat"] if cache else torch.empty((0, patch_size, latent_dim), dtype=torch.float32)
)
audio_length = prompt_audio_feat.size(0)
text_pad_token = torch.zeros(audio_length, dtype=torch.int32)
text_pad_feat = torch.zeros((text_length, patch_size, latent_dim), dtype=torch.float32)
text_token = torch.cat([text_token, text_pad_token])
audio_feat = torch.cat([text_pad_feat, prompt_audio_feat], dim=0)
text_mask = torch.cat(
[
torch.ones(text_length, dtype=torch.int32),
torch.zeros(audio_length, dtype=torch.int32),
]
)
audio_mask = torch.cat(
[
torch.zeros(text_length, dtype=torch.int32),
torch.ones(audio_length, dtype=torch.int32),
]
)
elif mode == "reference":
ref_audio_feat = cache["ref_audio_feat"]
ref_tokens, ref_feats, ref_t_mask, ref_a_mask = tts._make_ref_prefix(ref_audio_feat, text_token.device)
text_pad_feat = torch.zeros((text_length, patch_size, latent_dim), dtype=torch.float32)
text_token = torch.cat([ref_tokens.cpu(), text_token])
audio_feat = torch.cat([ref_feats.cpu(), text_pad_feat], dim=0)
text_mask = torch.cat([ref_t_mask.cpu(), torch.ones(text_length, dtype=torch.int32)])
audio_mask = torch.cat([ref_a_mask.cpu(), torch.zeros(text_length, dtype=torch.int32)])
else:
# ref_continuation
ref_audio_feat = cache["ref_audio_feat"]
prompt_audio_feat = cache["audio_feat"]
prompt_audio_length = prompt_audio_feat.size(0)
ref_tokens, ref_feats, ref_t_mask, ref_a_mask = tts._make_ref_prefix(ref_audio_feat, text_token.device)
prompt_pad_token = torch.zeros(prompt_audio_length, dtype=torch.int32)
text_pad_feat = torch.zeros((text_length, patch_size, latent_dim), dtype=torch.float32)
text_token = torch.cat([ref_tokens.cpu(), text_token, prompt_pad_token])
audio_feat = torch.cat([ref_feats.cpu(), text_pad_feat, prompt_audio_feat], dim=0)
text_mask = torch.cat(
[
ref_t_mask.cpu(),
torch.ones(text_length, dtype=torch.int32),
torch.zeros(prompt_audio_length, dtype=torch.int32),
]
)
audio_mask = torch.cat(
[
ref_a_mask.cpu(),
torch.zeros(text_length, dtype=torch.int32),
torch.ones(prompt_audio_length, dtype=torch.int32),
]
)
return {
"text_token": text_token.unsqueeze(0).to(dev),
"audio_feat": audio_feat.unsqueeze(0).to(dev).to(dtype),
"text_mask": text_mask.unsqueeze(0).to(dev),
"audio_mask": audio_mask.unsqueeze(0).to(dev),
}
def _forward_prefill(self, inputs_embeds: torch.Tensor, dev: Any) -> torch.Tensor:
"""Prefill: build combined embeds, run base_lm + residual_lm + first diffusion.
Uses the same path as native ``VoxCPM2Model._inference`` so zero-shot,
voice cloning (reference), continuation, and ref_continuation modes
all share the same code.
"""
tts = self.tts
dtype = self._side_dtype
text = getattr(self, "_prefill_text", None)
if text is None:
# Fallback (should not hit at runtime; preprocess sets this)
text = ""
inputs = self._build_prefill_inputs(text, dev)
text_token = inputs["text_token"]
feat = inputs["audio_feat"]
text_mask = inputs["text_mask"]
feat_mask = inputs["audio_mask"]
# Compose combined_embed exactly like native _inference
feat_embed = tts.feat_encoder(feat)
feat_embed = tts.enc_to_lm_proj(feat_embed)
scale_emb = tts.config.lm_config.scale_emb if tts.config.lm_config.use_mup else 1.0
text_embed = tts.base_lm.embed_tokens(text_token) * scale_emb
combined_embed = text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed
# last audio patch becomes initial prefix_feat_cond (zeros for zero-shot,
# last reference/prompt patch for voice clone / continuation)
prefix_feat_cond = (
feat[:, -1, ...]
if feat.shape[1] > 0
else torch.zeros(1, tts.patch_size, tts.feat_dim, device=dev, dtype=dtype)
)
# Base LM prefill
tts.base_lm.setup_cache(1, 4096, dev, dtype)
enc_out, enc_kv = tts.base_lm(inputs_embeds=combined_embed, is_causal=True)
tts.base_lm.kv_cache.fill_caches(enc_kv)
# FSQ: identity on text positions, quantized on audio positions
enc_outputs = tts.fsq_layer(enc_out) * feat_mask.unsqueeze(-1) + enc_out * text_mask.unsqueeze(-1)
lm_hidden = enc_outputs[:, -1, :] # [1, H]
logger.info(
"PREFILL: enc shape=%s last_norm=%.4f",
enc_outputs.shape,
lm_hidden.norm().item(),
)
# Residual LM prefill
tts.residual_lm.setup_cache(1, 4096, dev, dtype)
residual_input = tts.fusion_concat_proj(torch.cat([enc_outputs, feat_mask.unsqueeze(-1) * feat_embed], dim=-1))
res_out, res_kv = tts.residual_lm(inputs_embeds=residual_input, is_causal=True)
tts.residual_lm.kv_cache.fill_caches(res_kv)
residual_hidden = res_out[:, -1, :] # [1, H]
# Precompute stop logits for first compute_logits call
stop_logits = tts.stop_head(tts.stop_actn(tts.stop_proj(lm_hidden)))
self._precomputed_stop_logits = stop_logits.detach()
logger.info("PREFILL stop: %s", stop_logits[0].tolist())
# First diffusion step
dit_h = torch.cat(
[
tts.lm_to_dit_proj(lm_hidden),
tts.res_to_dit_proj(residual_hidden),
],
dim=-1,
)
pred_feat = tts.feat_decoder(
mu=dit_h,
patch_size=tts.patch_size,
cond=prefix_feat_cond.transpose(1, 2).contiguous(),
n_timesteps=self._inference_timesteps,
cfg_value=self._cfg_value,
).transpose(1, 2) # [1, P, D]
with torch.no_grad():
curr_embed = tts.enc_to_lm_proj(tts.feat_encoder(pred_feat.unsqueeze(1))).squeeze(1)
# Store state for decode steps
self._curr_embed_for_next = curr_embed.detach()
self._prev_feat_embed = curr_embed.detach()
self._curr_prefix_feat_cond = pred_feat[0].detach()
self._last_audio_patch = pred_feat.reshape(1, -1).detach().cpu().float()
logger.info(
"PREFILL patch: norm=%.4f first3=%s",
pred_feat.norm().item(),
pred_feat[0, 0, :3].tolist(),
)
return lm_hidden.to(dtype)
def _forward_decode(self, inputs_embeds: torch.Tensor | None, dev: Any) -> torch.Tensor:
"""Decode step: base_lm → FSQ → residual_lm → diffusion."""
tts = self.tts
dtype = self._side_dtype
# 1. Base LM step with curr_embed from previous diffusion
curr_embed = self._curr_embed_for_next.to(dev, dtype=dtype)
if curr_embed.ndim == 2:
curr_embed_3d = curr_embed.unsqueeze(0) # [1, 1, H]
else:
curr_embed_3d = curr_embed
step_pos = torch.tensor([tts.base_lm.kv_cache.step()], device=dev)
new_hidden = tts.base_lm.forward_step(curr_embed_3d[:, 0, :], step_pos).clone()
# 2. FSQ
new_lm_hidden = tts.fsq_layer(new_hidden)
if new_lm_hidden.ndim == 1:
new_lm_hidden = new_lm_hidden.unsqueeze(0)
# 3. Residual LM step
prev_fe = self._prev_feat_embed.to(dtype)
if prev_fe.ndim == 1:
prev_fe = prev_fe.unsqueeze(0)
res_input = tts.fusion_concat_proj(torch.cat([new_lm_hidden, prev_fe], dim=-1))
res_step_pos = torch.tensor([tts.residual_lm.kv_cache.step()], device=dev)
new_res_hidden = tts.residual_lm.forward_step(res_input, res_step_pos).clone()
if new_res_hidden.ndim == 1:
new_res_hidden = new_res_hidden.unsqueeze(0)
# 4. Diffusion
p = self._patch_size
pfc = self._curr_prefix_feat_cond.to(dtype).unsqueeze(0)
dit_h = torch.cat(
[
tts.lm_to_dit_proj(new_lm_hidden),
tts.res_to_dit_proj(new_res_hidden),
],
dim=-1,
)
pred_feat = tts.feat_decoder(
mu=dit_h,
patch_size=p,
cond=pfc.transpose(1, 2).contiguous(),
n_timesteps=self._inference_timesteps,
cfg_value=self._cfg_value,
).transpose(1, 2) # [1, P, D]
# 5. feat_encoder → curr_embed
with torch.no_grad():
curr_embed = tts.enc_to_lm_proj(tts.feat_encoder(pred_feat.unsqueeze(1))).squeeze(1)
# 6. Stop logits
stop_logits = tts.stop_head(tts.stop_actn(tts.stop_proj(new_lm_hidden)))
self._precomputed_stop_logits = stop_logits.detach()
# 7. Store state
self._curr_embed_for_next = curr_embed.detach()
self._prev_feat_embed = curr_embed.detach()
self._curr_prefix_feat_cond = pred_feat[0].detach()
self._last_audio_patch = pred_feat.reshape(1, -1).detach().cpu().float()
return new_lm_hidden[-1:].detach()
def compute_logits(
self,
hidden_states: torch.Tensor | OmniOutput,
sampling_metadata: Any = None,
) -> torch.Tensor | None:
if isinstance(hidden_states, OmniOutput):
hidden_states = hidden_states.text_hidden_states
if hidden_states is None:
return None
precomputed = getattr(self, "_precomputed_stop_logits", None)
if precomputed is not None:
self._precomputed_stop_logits = None
raw_logits = precomputed[: hidden_states.shape[0]]
else:
# Fallback for warmup
bsz = hidden_states.shape[0]
raw_logits = torch.zeros(bsz, 2, device=hidden_states.device)
raw_logits[:, 0] = 1.0 # continue
bsz = raw_logits.shape[0]
full_logits = torch.full(
(bsz, self.config.vocab_size),
float("-inf"),
device=raw_logits.device,
dtype=raw_logits.dtype,
)
full_logits[:, 0] = raw_logits[:, 0] # continue
full_logits[:, 1] = raw_logits[:, 1] # stop
return full_logits
# -------------------- Omni output --------------------
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
hidden = model_outputs
patch = getattr(self, "_last_audio_patch", None)
mm: dict[str, Any] = {}
if patch is not None:
self._last_audio_patch = None
self._accumulated_patches.append(patch.clone())
# Decode all accumulated patches → full audio waveform.
# TODO: implement sliding-window VAE decode (nanovllm pattern)
# for O(1) per-step streaming instead of O(N) re-decode.
if self._accumulated_patches:
all_p = torch.cat(self._accumulated_patches, dim=0)
d = self._feat_dim
from einops import rearrange
feat = rearrange(all_p.float().reshape(1, -1, d), "b t d -> b d t")
with torch.no_grad():
audio = self.tts.audio_vae.decode(feat.to(self._device)).reshape(-1).detach().cpu().float()
mm["model_outputs"] = [audio]
mm["sr"] = [torch.tensor(48000, dtype=torch.int32)]
return OmniOutput(
text_hidden_states=hidden,
multimodal_outputs=mm,
)
# -------------------- preprocess / postprocess --------------------
def preprocess(
self,
input_ids: torch.Tensor,
input_embeds: torch.Tensor | None,
**info_dict: Any,
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
additional_information = info_dict.get("additional_information")
if isinstance(additional_information, dict):
merged = {k: v for k, v in info_dict.items() if k != "additional_information"}
for k, v in additional_information.items():
merged.setdefault(k, v)
info_dict = merged
span_len = int(input_ids.shape[0])
dev = input_ids.device
if span_len > 1:
# ---- Prefill ----
# Decode the text from input_ids for native-matching tokenization.
# Speech API tokenizes with BOS; we use the detokenized string so
# native's ``text_tokenizer`` produces the exact same tokens as
# ``generate()``.
ids = input_ids.tolist()
if ids and ids[0] == self.config.bos_token_id:
ids = ids[1:]
text = self.tts.text_tokenizer.tokenizer.decode(ids, skip_special_tokens=True)
self._prefill_text = text
# Voice clone / continuation: build prompt cache from info_dict.
ref_audio = info_dict.get("reference_audio") or info_dict.get("ref_audio")
prompt_audio = info_dict.get("prompt_audio")
prompt_text = info_dict.get("prompt_text")
if isinstance(ref_audio, list):
ref_audio = ref_audio[0] if ref_audio else None
if isinstance(prompt_audio, list):
prompt_audio = prompt_audio[0] if prompt_audio else None
if isinstance(prompt_text, list):
prompt_text = prompt_text[0] if prompt_text else None
self._prompt_cache = None
if ref_audio or (prompt_audio and prompt_text):
try:
self._prompt_cache = self.tts.build_prompt_cache(
prompt_text=prompt_text,
prompt_wav_path=prompt_audio,
reference_wav_path=ref_audio,
)
except Exception as e:
logger.warning("build_prompt_cache failed: %s; falling back to zero-shot", e)
self._prompt_cache = None
# Reset per-request state (fresh generation)
self._accumulated_patches = []
if hasattr(self, "_prev_feat_embed"):
del self._prev_feat_embed
if hasattr(self, "_curr_embed_for_next"):
del self._curr_embed_for_next
# Store info for forward
self._current_step_infos = [{"is_prefill": True}]
# The scaffold model still needs embeddings sized to span_len for
# its warmup/attention bookkeeping. Native modules use the full
# (potentially longer) sequence internally. Pass zeros — scaffold
# output is discarded.
embeds = torch.zeros(
span_len,
self.config.hidden_size,
device=dev,
dtype=self._side_dtype,
)
return input_ids, embeds, {}
# ---- Decode ----
curr_embed = getattr(self, "_curr_embed_for_next", None)
if curr_embed is not None:
inputs_embeds = curr_embed.to(dev, dtype=self._side_dtype).reshape(1, -1)
else:
inputs_embeds = torch.zeros(
1,
self.config.hidden_size,
device=dev,
dtype=self._side_dtype,
)
self._current_step_infos = [{}]
return input_ids, inputs_embeds, {}
def postprocess(self, hidden_states: torch.Tensor, **info: Any) -> dict[str, Any]:
return {}
# -------------------- Weight loading --------------------
# Weight mapping for vllm scaffold
hf_to_vllm_mapper = WeightsMapper(orig_to_new_prefix={"base_lm.": "model."})
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load scaffold weights via vllm + native model for computation."""
# Filter: only pass base_lm weights to the scaffold
def _base_lm_only(ws):
for name, tensor in ws:
if name.startswith("base_lm."):
yield name, tensor
loader = AutoWeightsLoader(self)
loaded = loader.load_weights(_base_lm_only(weights), mapper=self.hf_to_vllm_mapper)
# Load the full native model for actual computation
model_path = self.vllm_config.model_config.model
VoxCPM = import_voxcpm2_core()
native = VoxCPM.from_pretrained(model_path, load_denoiser=False, optimize=False)
self._tts = native.tts_model.to("cuda")
self._side_dtype = self._tts.fusion_concat_proj.weight.dtype
self._device = "cuda"
self._patch_size = self._tts.patch_size
self._feat_dim = self._tts.feat_dim
logger.info(
"Loaded native VoxCPM2 (patch_size=%d, feat_dim=%d, dtype=%s)",
self._patch_size,
self._feat_dim,
self._side_dtype,
)
return loaded
@@ -0,0 +1,36 @@
# VoxCPM2 native AR single-stage pipeline.
# Uses native MiniCPM4 base_lm + native VAE decode in one stage.
# All computation (base_lm, residual_lm, diffusion, VAE) in forward().
stage_args:
- stage_id: 0
stage_type: llm
is_comprehension: true
runtime:
devices: "0"
max_batch_size: 1
engine_args:
dtype: bfloat16
model_stage: latent_generator
model_arch: VoxCPM2TalkerForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: true
enable_prefix_caching: false
engine_output_type: audio
gpu_memory_utilization: 0.9
distributed_executor_backend: "mp"
max_num_batched_tokens: 4096
max_model_len: 4096
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 4096
seed: 42
detokenize: false
repetition_penalty: 1.0
stop_token_ids: [1]
final_output: true
final_output_type: audio
@@ -17,6 +17,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
"FishSpeechConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"FishSpeechSlowARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2",
}
__all__ = [
@@ -27,6 +28,7 @@ __all__ = [
"FishSpeechConfig",
"FishSpeechSlowARConfig",
"FishSpeechFastARConfig",
"VoxCPM2Config",
]
@@ -47,3 +49,4 @@ def __dir__():
# run as soon as `vllm_omni.transformers_utils.configs` is imported.
from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402
from vllm_omni.transformers_utils.configs import mammoth_moda2 as _mammoth_moda2 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import voxcpm2 as _voxcpm2 # noqa: F401, E402
@@ -0,0 +1,153 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
from transformers import AutoConfig
from transformers.configuration_utils import PretrainedConfig
from transformers.modeling_rope_utils import rope_config_validation
class VoxCPM2Config(PretrainedConfig):
"""Configuration for VoxCPM2 native AR integration.
The HuggingFace checkpoint stores LM parameters inside a nested
``lm_config`` dict. This class hoists them to top-level attributes
so that vllm's ``MiniCPMModel`` can consume them directly.
vllm's MiniCPM **always** applies muP scaling (scale_emb, scale_depth,
dim_model_base). VoxCPM2 was trained with ``use_mup=false``, so we
neutralise the scalings:
* ``scale_emb = 1.0``
* ``scale_depth = sqrt(num_hidden_layers)`` (cancels the division)
* ``dim_model_base = hidden_size`` (makes scale_width = 1.0)
"""
model_type = "voxcpm2"
keys_to_ignore_at_inference = ["past_key_values"]
def __init__(
self,
# -- top-level VoxCPM2 params --
architecture: str = "voxcpm2",
lm_config: dict | None = None,
encoder_config: dict | None = None,
dit_config: dict | None = None,
audio_vae_config: dict | None = None,
patch_size: int = 4,
feat_dim: int = 64,
residual_lm_num_layers: int = 8,
residual_lm_no_rope: bool = True,
scalar_quantization_latent_dim: int = 512,
scalar_quantization_scale: int = 9,
max_length: int = 8192,
device: str = "cuda",
dtype: str = "bfloat16",
# -- LM defaults (overridden by lm_config if present) --
bos_token_id: int = 1,
eos_token_id: int = 2,
vocab_size: int = 73448,
hidden_size: int = 2048,
intermediate_size: int = 6144,
max_position_embeddings: int = 32768,
num_attention_heads: int = 16,
num_hidden_layers: int = 28,
num_key_value_heads: int = 2,
rms_norm_eps: float = 1e-5,
rope_theta: float = 10000.0,
rope_scaling: dict | None = None,
**kwargs,
):
super().__init__(
bos_token_id=bos_token_id,
eos_token_id=eos_token_id,
**kwargs,
)
self.architecture = architecture
# -- VoxCPM2-specific fields --
self.lm_config = lm_config or {}
self.encoder_config = encoder_config or {}
self.dit_config = dit_config or {}
self.audio_vae_config = audio_vae_config or {}
self.patch_size = patch_size
self.feat_dim = feat_dim
self.residual_lm_num_layers = residual_lm_num_layers
self.residual_lm_no_rope = residual_lm_no_rope
self.scalar_quantization_latent_dim = scalar_quantization_latent_dim
self.scalar_quantization_scale = scalar_quantization_scale
self.max_length = max_length
self.device = device
self.dtype = dtype
# -- Hoist LM parameters to top-level for MiniCPMModel --
lm = self.lm_config
self.vocab_size = lm.get("vocab_size", vocab_size)
self.hidden_size = lm.get("hidden_size", hidden_size)
self.intermediate_size = lm.get("intermediate_size", intermediate_size)
self.max_position_embeddings = lm.get("max_position_embeddings", max_position_embeddings)
self.num_attention_heads = lm.get("num_attention_heads", num_attention_heads)
self.num_hidden_layers = lm.get("num_hidden_layers", num_hidden_layers)
self.num_key_value_heads = lm.get("num_key_value_heads", num_key_value_heads)
self.rms_norm_eps = lm.get("rms_norm_eps", rms_norm_eps)
self.rope_theta = lm.get("rope_theta", rope_theta)
# MiniCPM-specific: kv_channels overrides head_dim when set.
kv_channels = lm.get("kv_channels")
if kv_channels is not None:
self.head_dim = kv_channels
else:
self.head_dim = self.hidden_size // self.num_attention_heads
# MiniCPM requires hidden_act; VoxCPM2 uses SiLU.
self.hidden_act = "silu"
self.hidden_act_param = 0.0
self.tie_word_embeddings = False
self.num_experts = 0
# -- muP scaling --
# Native VoxCPM2 MiniCPM gates scale_depth behind use_mup:
# use_mup=True → residual += h * (scale_depth / sqrt(N))
# use_mup=False → residual += h (plain add, no scaling)
# But vllm's MiniCPMModel ALWAYS applies scale_depth / sqrt(N).
# Native applies scale_emb externally; vllm applies it in embed_input_ids.
use_mup = lm.get("use_mup", False)
self.scale_emb = lm.get("scale_emb", 1.0)
if use_mup:
self.scale_depth = lm.get("scale_depth", 1.0)
self.dim_model_base = lm.get("dim_model_base", self.hidden_size)
else:
# Neutralize: scale_depth/sqrt(N) = 1.0, scale_width = 1.0
self.scale_depth = math.sqrt(self.num_hidden_layers)
self.dim_model_base = self.hidden_size
# -- RoPE scaling (longrope) --
raw_rope = lm.get("rope_scaling", rope_scaling)
if raw_rope is not None:
self.rope_scaling = dict(raw_rope)
# HF expects "rope_type" not "type"
if "type" in self.rope_scaling:
self.rope_scaling["rope_type"] = self.rope_scaling.pop("type")
# longrope requires "factor" (used by HF validation)
if "factor" not in self.rope_scaling:
self.rope_scaling["factor"] = 1.0
rope_config_validation(self)
# vllm's MiniCPMAttention reads config.rope_parameters (a dict
# with rope_type, theta, scaling factors, etc.). HF transformers
# only auto-computes this for known model_types; for custom
# types we must build it manually.
if not getattr(self, "rope_parameters", None):
rp = dict(self.rope_scaling)
rp["rope_theta"] = self.rope_theta
self.rope_parameters = rp
else:
self.rope_scaling = None
def get_text_config(self, **kwargs):
"""Return self as the text config — LM attributes are top-level."""
return self
AutoConfig.register("voxcpm2", VoxCPM2Config)
__all__ = ["VoxCPM2Config"]