Compare commits

...
Author SHA1 Message Date
Hongsheng Liu f82ec476bf [Misc] Address LingBot runner review feedback
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 23:00:24 +08:00
Hongsheng Liu 0a593bdded [Misc] Consolidate LingBot text-to-video runner
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 08:58:34 +08:00
Hongsheng Liu 8b0a6779d4 [Misc] Build canonical image task prompts (#6049)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 08:30:34 +08:00
Sy03andYueqian Lin 58007c0084 [New Model] Support IndexTTS 2.5 (#5957)
Signed-off-by: Sysy <1370724210@qq.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-08-11 23:00:35 +00:00
Hongsheng Liu 1ff2753469 docs: align User Guide feature taxonomy (#6045)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 00:20:56 +08:00
SYLAR 4697bd1be0 [Docs] Add MiniMax-H3 recipe for RTX PRO 5000 (#5857)
Signed-off-by: lishunyang12 <lishunyang12@163.com>
2026-08-11 23:53:58 +08:00
105 changed files with 7926 additions and 1060 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ steps:
DOCKER_BUILDKIT: "1"
# Buildkite will automatically replace this with the actual commit hash
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
VLLM_VERSION: "v0.26.0"
VLLM_VERSION: "v0.27.0"
priority: 100
timeout_in_minutes: 60
soft_fail: false
+16 -1
View File
@@ -34,8 +34,23 @@ docker_build() {
docker build "${out[@]}" "$@" -f docker/Dockerfile.xpu .
}
build_vllm_base() (
local vllm_source_dir
vllm_source_dir=$(mktemp -d)
trap 'rm -rf "${vllm_source_dir}"' EXIT
git clone --depth 1 --branch "${VLLM_VERSION}" \
https://github.com/vllm-project/vllm "${vllm_source_dir}"
local out=("${export_args[@]/'{{IMAGE}}'/${base_image_name}}")
docker build "${out[@]}" \
--target vllm-openai \
-f "${vllm_source_dir}/docker/Dockerfile.xpu" \
"${vllm_source_dir}"
)
if [ -z "$(docker images -q "${base_image_name}")" ]; then
docker_build "${base_image_name}" --target vllm-base --build-arg "VLLM_VERSION=${VLLM_VERSION}"
build_vllm_base
fi
# Try building the docker image
@@ -167,9 +167,11 @@ def main() -> None:
]
native_cmd = [
sys.executable,
"examples/offline_inference/text_to_video/text_to_video_lingbot.py",
"examples/offline_inference/text_to_video/text_to_video.py",
"--model",
args.model,
"--model-class-name",
"LingBotVideoPipeline",
"--prompt",
args.prompt,
"--output",
+28
View File
@@ -139,6 +139,34 @@ python benchmarks/tts/bench_tts.py \
--output-dir ./results
```
#### Fixed IndexTTS 2.5 performance sweep
IndexTTS 2.5 is registered in `model_configs.yaml` like the other TTS models.
Use `--served-model-name` when the server was launched from a local native
bundle instead of the registry key. The command below reproduces the acceptance
workload: Seed-TTS Eval EN, `n=500`, concurrency `4/8/16/32`, five warmups, and
dataset seed 0.
```bash
python benchmarks/tts/bench_tts.py \
--model IndexTeam/IndexTTS-2.5 \
--served-model-name /path/to/indextts-2.5 \
--task voice_clone --locale en \
--dataset-path /path/to/seedtts_testset \
--host 127.0.0.1 --port 8092 \
--concurrency 4 8 16 32 \
--num-prompts 500 --num-warmups 5 \
--output-dir ./results/indextts2_5 \
-- \
--tokenizer /path/to/indextts-2.5/qwen0.6bemo4-merge \
--seed 0 --metric-percentiles 50,95,99 --disable-tqdm --save-detailed
```
The wrapper benchmarks an already-running server. For reproducible quality
comparisons, add `--request-seed 42`; production-performance runs should omit
it. The trailing `--seed 0` controls Seed-TTS row selection rather than model
sampling.
### 4. Plot a sweep
```bash
+29 -4
View File
@@ -84,12 +84,17 @@ def build_bench_args(
result_filename: str | None,
extra_cli_args: list[str],
output_len: int | None = None,
served_model_name: str | None = None,
num_warmups: int = 2,
request_seed: int | None = None,
) -> list[str]:
"""Build the ``vllm bench serve --omni`` command for one (task, concurrency) run."""
dataset_name = _TASK_TO_DATASET[task]
backend: str = model_cfg["backend"]
endpoint: str = model_cfg["endpoint"]
task_extra_body: dict[str, Any] = (model_cfg.get("task_extra_body") or {}).get(task) or {}
task_extra_body: dict[str, Any] = dict((model_cfg.get("task_extra_body") or {}).get(task) or {})
if request_seed is not None:
task_extra_body["seed"] = request_seed
# Resolve dataset path
if dataset_path:
@@ -109,7 +114,7 @@ def build_bench_args(
"--port",
str(port),
"--model",
model,
served_model_name or model,
"--backend",
backend,
"--endpoint",
@@ -119,7 +124,7 @@ def build_bench_args(
"--num-prompts",
str(num_prompts),
"--num-warmups",
"2",
str(num_warmups),
"--percentile-metrics",
"ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun",
]
@@ -152,7 +157,8 @@ def build_bench_args(
if result_filename:
cmd += ["--result-filename", result_filename]
cmd += extra_cli_args
passthrough = extra_cli_args[1:] if extra_cli_args[:1] == ["--"] else extra_cli_args
cmd += passthrough
return cmd
@@ -225,6 +231,12 @@ def main() -> None:
parser.add_argument(
"--model", required=True, help="HuggingFace model ID (e.g. Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice)"
)
parser.add_argument(
"--served-model-name",
default=None,
help="Model name/path accepted by the running server. Use this when --model selects a registry entry "
"but the server was launched from a local bundle path.",
)
parser.add_argument("--task", default="all", help="Task type: voice_clone | default_voice | voice_design | all")
parser.add_argument("--locale", default="en", choices=["en", "zh"])
parser.add_argument("--concurrency", type=int, nargs="+", default=[1, 4], metavar="N")
@@ -236,6 +248,13 @@ def main() -> None:
metavar="N",
help="Number of prompts per run. If one value, applied to all concurrency levels.",
)
parser.add_argument("--num-warmups", type=int, default=2, help="Warmup requests before each measured run")
parser.add_argument(
"--request-seed",
type=int,
default=None,
help="Optional model sampling seed added to every request body for reproducible quality runs",
)
parser.add_argument(
"--dataset-path", default=None, help="Root of seed-tts-eval dataset (required for voice_clone/default_voice)"
)
@@ -256,6 +275,9 @@ def main() -> None:
parser.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed directly to vllm bench serve")
args = parser.parse_args()
if args.num_warmups < 0:
parser.error("--num-warmups cannot be negative")
model_configs = load_model_configs(Path(args.model_configs))
if args.model not in model_configs:
known = "\n ".join(model_configs.keys())
@@ -308,6 +330,9 @@ def main() -> None:
result_filename=result_filename,
extra_cli_args=args.extra or [],
output_len=args.output_len,
served_model_name=args.served_model_name,
num_warmups=args.num_warmups,
request_seed=args.request_seed,
)
result = run_one_benchmark(cmd)
if result is not None:
+11
View File
@@ -7,6 +7,17 @@
# is tracked here.
models:
IndexTeam/IndexTTS-2.5:
supported_tasks: [voice_clone]
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
voice_clone:
extra_params:
lang: en
text_normalization: true
# -CustomVoice checkpoints lack speaker_encoder weights, so voice_clone is
# NOT supported (an attempt raises ValueError from _extract_speaker_embedding
# at model runtime). Use -Base for voice_clone.
+5 -4
View File
@@ -88,7 +88,7 @@ ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL}
ENV UV_INDEX_STRATEGY="unsafe-best-match"
ENV UV_LINK_MODE="copy"
ARG VLLM_VERSION=v0.26.0
ARG VLLM_VERSION=v0.27.0
RUN git clone -b ${VLLM_VERSION} https://github.com/vllm-project/vllm
WORKDIR /workspace/vllm
@@ -108,7 +108,7 @@ ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip uninstall triton triton-xpu && \
uv pip install triton-xpu==3.7.1 && \
uv pip install triton-xpu==3.7.2 && \
uv pip install --no-build-isolation --no-deps .
CMD ["/bin/bash"]
@@ -144,12 +144,13 @@ WORKDIR /workspace/vllm-omni
COPY . .
ENV VLLM_OMNI_TARGET_DEVICE=xpu
# Keep the upstream oneCCL packages paired with the XPU torch runtime.
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --no-build-isolation \
--extra-index-url https://mirrors.ustc.edu.cn/pypi/simple/ \
".[dev]" && \
uv pip uninstall triton oneccl oneccl-devel && \
uv pip install triton-xpu==3.7.1 --reinstall
uv pip uninstall triton && \
uv pip install triton-xpu==3.7.2 --reinstall
FROM vllm-omni AS vllm-omni-openai
+32 -26
View File
@@ -40,30 +40,12 @@ nav:
- Models:
- models/supported_models.md
- Features:
- Sleep Mode: features/sleep_mode.md
- Diffusion Features:
- Overview: user_guide/diffusion_features.md
- Feature Compatibility: user_guide/feature_compatibility.md
- Regional Compilation: user_guide/diffusion/regional_compilation.md
- Cache Acceleration:
- TeaCache: user_guide/diffusion/cache_acceleration/teacache.md
- Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md
- Attention Backends: user_guide/diffusion/attention_backends.md
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md
- Parallelism:
- Overview: user_guide/diffusion/parallelism/overview.md
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
- Expert Parallel: user_guide/diffusion/parallelism/expert_parallel.md
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
- VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md
- CPU Offloading: user_guide/diffusion/cpu_offload.md
- LoRA: user_guide/diffusion/lora.md
- Custom Pipeline: features/custom_pipeline.md
- Execution Modes: user_guide/diffusion/execution_modes.md
- Overview: features/README.md
- Runtime and Stage Execution:
- Execution Modes and Streaming: user_guide/diffusion/execution_modes.md
- Sleep Mode: features/sleep_mode.md
- Quantization:
- Quantization: user_guide/quantization/overview.md
- Overview: user_guide/quantization/overview.md
- Online Quantization: user_guide/quantization/online.md
- Quantized KV Cache: user_guide/quantization/quantized_kvcache.md
- FP8 W8A8: user_guide/quantization/fp8.md
@@ -72,9 +54,33 @@ nav:
- MXFP8 W8A8: user_guide/quantization/mxfp8.md
- MXFP4 W4A4: user_guide/quantization/mxfp4.md
- ModelOpt: user_guide/quantization/modelopt.md
- GGUF: user_guide/quantization/gguf.md
- AutoRound: user_guide/quantization/autoround.md
- msModelSlim: user_guide/quantization/msmodelslim.md
- Diffusion Acceleration:
- Overview and Compatibility:
- Overview: user_guide/diffusion_features.md
- Feature Compatibility: user_guide/feature_compatibility.md
- CPU Offloading: user_guide/diffusion/cpu_offload.md
- Cache Acceleration:
- TeaCache: user_guide/diffusion/cache_acceleration/teacache.md
- Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md
- Parallelism:
- Overview: user_guide/diffusion/parallelism/overview.md
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
- Expert Parallel: user_guide/diffusion/parallelism/expert_parallel.md
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
- VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md
- Attention Backends: user_guide/diffusion/attention_backends.md
- Regional Compilation: user_guide/diffusion/regional_compilation.md
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md
- Startup and Loading: user_guide/diffusion/startup_and_loading.md
- LoRA: user_guide/diffusion/lora.md
- Experimental:
- Session State Manager: features/session_state_manager.md
- Integrations:
- Custom Diffusion Pipeline: features/custom_pipeline.md
- ComfyUI: features/comfyui.md
- Fault Tolerance:
- Failure Modes: user_guide/fault_injection_reliability_matrix.md
@@ -108,6 +114,7 @@ nav:
- Shared Memory Connector: design/feature/omni_connectors/shared_memory_connector.md
- Yuanrong Store Connector: design/feature/omni_connectors/yuanrong_connector.md
- Yuanrong Transfer Engine Connector: design/feature/omni_connectors/yuanrong_transfer_engine_connector.md
- Quantization: design/feature/quantization.md
- Diffusion Acceleration:
- Parallelism:
- CFG-Parallel: design/feature/cfg_parallel.md
@@ -117,9 +124,8 @@ nav:
- Sequence Parallel: design/feature/sequence_parallel.md
- Tensor Parallel: design/feature/tensor_parallel.md
- VAE Patch Parallelism: design/feature/vae_parallel.md
- Attention Backends:
- Attention Optimization:
- Skip-Softmax: design/feature/skip_softmax.md
- Quantization: design/feature/quantization.md
- Cache-DiT: design/feature/cache_dit.md
- TeaCache: design/feature/teacache.md
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md
+13 -5
View File
@@ -11,6 +11,10 @@ in the active navigation.
## Feature Design Documents
For user-facing configuration and current compatibility, see the
[Features overview](../features/README.md). A design document defines an
implementation contract; it is not, by itself, a general support claim.
### Runtime and stage execution
- [Disaggregated Inference](feature/disaggregated_inference.md)
@@ -30,6 +34,10 @@ in the active navigation.
- [Yuanrong Store Connector](feature/omni_connectors/yuanrong_connector.md)
- [Yuanrong Transfer Engine Connector](feature/omni_connectors/yuanrong_transfer_engine_connector.md)
### Quantization
- [Quantization](feature/quantization.md)
### Diffusion acceleration
#### Parallelism
@@ -42,14 +50,14 @@ in the active navigation.
- [Tensor Parallel](feature/tensor_parallel.md)
- [VAE Patch Parallelism](feature/vae_parallel.md)
#### Attention Backends
#### Attention optimization
The [Diffusion Attention Backends](../user_guide/diffusion/attention_backends.md)
guide lists all selectable backends and their platform defaults. Skip-Softmax
is the backend-related optimization with a standalone feature-design contract:
- [Skip-Softmax](feature/skip_softmax.md)
#### Quantization
- [Quantization](feature/quantization.md)
- [Cache-DiT](feature/cache_dit.md)
- [TeaCache](feature/teacache.md)
- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md)
+67
View File
@@ -0,0 +1,67 @@
# Features
Use this section to choose and configure vLLM-Omni runtime and optimization
features. The navigation follows the active
[Feature Design](../design/index.md#feature-design-documents) taxonomy whenever
an implementation contract has a user-facing workflow.
!!! note "User guides, design documents, and recipes"
User guides explain how to enable a feature and where it is supported.
Design documents define internal contracts and do not, by themselves,
imply general or production support. For model-specific launch flags and
hardware requirements, use the linked examples and validated recipes from
[Supported Models](../models/supported_models.md).
## Runtime and Stage Execution
| Goal | User guide | Related design contract |
| --- | --- | --- |
| Choose serial, batched, step-wise, or streaming diffusion execution | [Execution Modes and Streaming](../user_guide/diffusion/execution_modes.md) | [Diffusion Continuous Batching](../design/feature/diffusion_continuous_batching.md), [Async Diffusion Output](../design/feature/async_diffusion_output.md) |
| Reclaim stage memory without restarting the server | [Sleep Mode](sleep_mode.md) | Runtime lifecycle behavior is documented in the user guide |
Some runtime designs are deliberately not promoted as standalone User Guide
features yet:
- [Disaggregated Inference](../design/feature/disaggregated_inference.md) and
[OmniConnector implementations](../design/index.md#communication) describe
topology and transport contracts. Practical deployment configuration remains
under [Pipeline and deploy configurations](../configuration/stage_configs.md).
- [Async Chunk](../design/feature/async_chunk.md) and
[Async Omni Output Materialization](../design/feature/omni_async_output_materialization.md)
are model- and pipeline-dependent. Use the selected model's deploy
configuration and recipe for the supported settings.
- [Automatic Prefix Caching](../design/feature/prefix_caching.md) remains a
design-level contract until its user-facing configuration and compatibility
surface is consolidated.
## Quantization
Quantization is a cross-model feature rather than a diffusion-only
optimization. The unified [`quantization_config` guide](../user_guide/quantization/overview.md)
covers diffusion-only models, multi-stage omni/TTS models, and multi-stage
diffusion models. Its [design contract](../design/feature/quantization.md)
defines the shared configuration and backend extension points.
## Diffusion Acceleration
| Goal | User guide | Related design contract |
| --- | --- | --- |
| Compare acceleration methods and supported combinations | [Overview](../user_guide/diffusion_features.md), [Feature Compatibility](../user_guide/feature_compatibility.md) | [Diffusion acceleration designs](../design/index.md#diffusion-acceleration) |
| Move weights between host and device memory | [CPU Offloading](../user_guide/diffusion/cpu_offload.md) | [Distributed Layerwise Offload](../design/feature/distributed_layerwise_offload.md) |
| Reuse denoising computation | [Cache Acceleration](../user_guide/diffusion/cache_acceleration/cache_dit.md) | [Cache-DiT](../design/feature/cache_dit.md), [TeaCache](../design/feature/teacache.md) |
| Distribute diffusion work across devices | [Parallelism](../user_guide/diffusion/parallelism/overview.md) | [Parallelism designs](../design/index.md#parallelism) |
| Select dense, sparse, or quantized attention paths | [Attention Backends](../user_guide/diffusion/attention_backends.md) | [Skip-Softmax](../design/feature/skip_softmax.md) |
| Compile repeated diffusion regions | [Regional Compilation](../user_guide/diffusion/regional_compilation.md) | User-facing optimization guide |
| Add generated video frames | [Frame Interpolation](../user_guide/diffusion/frame_interpolation.md) | User-facing extension guide |
| Reduce diffusion model startup time | [Startup and Loading](../user_guide/diffusion/startup_and_loading.md) | User-facing loading guide |
| Apply diffusion adapters | [LoRA](../user_guide/diffusion/lora.md) | User-facing extension guide |
The [Pipeline Parallelism guide](../user_guide/diffusion/parallelism/pipeline_parallel.md)
remains available by direct link, but it is not promoted in the primary User
Guide navigation until its user-facing support and placement are settled.
## Experimental
[Session State Manager](session_state_manager.md) is opt-in and experimental.
Its APIs and compatibility may change without notice.
+1
View File
@@ -80,6 +80,7 @@ th {
| `HiggsAudioV2ForConditionalGeneration` | Higgs-Audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✅︎ | | | | — |
| `HiggsMultimodalQwen3ForConditionalGeneration` | Higgs-Audio v3 (TTS) | `bosonai/higgs-audio-v3-tts-4b` | ✅︎ | | | | [Published](https://recipes.vllm.ai/bosonai/higgs-audio-v3-tts-4b) |
| `IndexTTS2TalkerForConditionalGeneration` | IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✅︎ | | | | — |
| `IndexTTS25TalkerForConditionalGeneration` | IndexTTS-2.5 | native `checkpoints/` bundle | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/IndexTeam/IndexTTS-2_5.md) |
| `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ | — |
| `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | | — |
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — |
@@ -0,0 +1,60 @@
# Diffusion Startup and Loading
Large diffusion models can take several minutes to load at startup. vLLM-Omni
loads safetensors shards in parallel to reduce this initialization time.
Multi-thread weight loading is enabled by default with four threads. No
configuration is needed for the default behavior.
## Configuration
| Parameter | CLI flag | Default | Description |
| --- | --- | --- | --- |
| `enable_multithread_weight_load` | `--disable-multithread-weight-load` | `True` | Pass the flag to disable multi-thread loading |
| `num_weight_load_threads` | `--num-weight-load-threads` | `4` | Number of parallel weight-loading threads |
!!! tip
The default balances startup speed and disk I/O contention. Fast NVMe
storage may benefit from more threads, while network storage or hard disks
may not.
## Online Serving
```bash
# Default: multi-thread loading with four threads
vllm serve Qwen/Qwen-Image --omni --port 8091
# Increase the thread count
vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers --omni \
--num-weight-load-threads 8
# Disable multi-thread loading
vllm serve Qwen/Qwen-Image --omni --disable-multithread-weight-load
```
## Offline Inference
```python
from vllm_omni import Omni
# Default: multi-thread loading with four threads
omni = Omni(model="Qwen/Qwen-Image")
# Increase the thread count
omni = Omni(
model="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
num_weight_load_threads=8,
)
```
## Reference Benchmarks
The following measurements were collected on NVIDIA H800 hardware. Treat them
as reference results rather than a guarantee for other storage or hardware
configurations.
| Model | Sequential loading | Multi-thread loading | Speedup |
| --- | ---: | ---: | ---: |
| **Qwen/Qwen-Image** (53.7 GiB) | 168 s | 27 s | **6.2x** |
| **Wan-AI/Wan2.2-I2V-A14B-Diffusers** (64.5 GiB) | 283 s | 56 s | **5.1x** |
+37 -85
View File
@@ -49,7 +49,7 @@ Parallelism methods distribute computation across GPUs without quality loss (mat
| Method | Description | Best For |
|--------|-------------|----------|
| **[Multi-Thread Weight Loading](#multi-thread-weight-loading)** | Loads safetensors shards in parallel using a thread pool | All diffusion models; reduces startup from minutes to seconds |
| **[Multi-Thread Weight Loading](diffusion/startup_and_loading.md)** | Loads safetensors shards in parallel using a thread pool | All diffusion models; reduces startup from minutes to seconds |
**Note:** Some acceleration methods can be combined together for optimized performance. See [Feature Compatibility Table](#feature-compatibility) and [Feature Compatibility Tutorial](feature_compatibility.md) for detailed configuration examples.
@@ -100,7 +100,9 @@ The following tables show which models support each feature:
- **🔀SP (Ulysses & Ring)**: Includes both Ulysses-SP and Ring-Attention methods
- ✅ = Fully supported
- ✅* = Supported with the constraint listed below the table
- ❌ = Not supported
- ❓ = Not verified; not recommended
> Notes:
@@ -119,7 +121,7 @@ The following tables show which models support each feature:
| **FLUX.2-dev** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **Hidream-I1-Full** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅* |
| **Krea 2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
@@ -145,6 +147,7 @@ The following tables show which models support each feature:
> 2. `Tongyi-MAI/Z-Image-Turbo` and `SII-GAIR/daVinci-MagiHuman-Base-1080p` are distilled models with minimal NFEs; CFG-Parallel is not necessary.
> 3. Cosmos3 T2I uses `Cosmos3OmniDiffusersPipeline` with `modalities=["image"]`. Model-level CPU offload swaps the nested UND reasoner and GEN generator pathways; layerwise offload remains available for blockwise GEN/UND offload.
> 4. Krea 2 currently supports single-GPU inference plus LoRA, Cache-DiT, HSDP, CPU/layerwise offload, and VAE-patch-parallel (decode). TP/SP/CFG-Parallel are not yet wired. The few-step distilled (Turbo) checkpoint uses `is_distilled=true` (fixed timestep shift `mu=1.15`); generate at 2048x2048 by default with `num_inference_steps≈8` and `guidance_scale=0`. The Raw checkpoint uses 1024x1024, `num_inference_steps=28`, and `guidance_scale=4.5`.
> 5. HunyuanImage3 supports step execution. Multi-request step execution requires `TORCH_SDPA`; see [Diffusion Execution Modes](diffusion/execution_modes.md#step-execution).
### VideoGen
@@ -155,13 +158,16 @@ The following tables show which models support each feature:
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅* |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **LongCat-Video-Avatar-1.5** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **MiniMax-H3** | ✅ (FL2VA) | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ |
> **Step execution note:** Helios supports single-request step execution only;
> use `max_num_seqs=1`.
**Frame Interpolation Support**
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
@@ -190,7 +196,7 @@ The following tables show which models support each feature:
| **🔀Ring-Attn** | ✅ | ✅ | ✅ | | | | | | | | | | | |
| **🔀CFG-Parallel** | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | |
| **🔀Tensor Parallel** | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | |
| **🔀HSDP** | ❓ | ❓ | ❓ | ❓ | ❓ | ❌ | | | | | | | | |
| **🔀HSDP** | ❓ | ❓ | ✅ | ❓ | ✅ | ❌ | | | | | | | | |
| **🔀Expert Parallel** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | | | | | | |
| **💾CPU Offloading (Layerwise)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | | | | | |
| **💾CPU Offloading (Module-wise)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ❌ | | | | | |
@@ -201,97 +207,43 @@ The following tables show which models support each feature:
!!! info
1. Tensor Parallel and HSDP are not compatible.
1. HSDP can be combined with Ulysses-SP or CFG-Parallel. Tensor Parallel
and HSDP are not compatible; other HSDP combinations in the table
remain unverified.
2. TeaCache and Cache-DiT are not compatible.
3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible.
4. CPU Offloading (Layerwise) supports single-card for now.
5. Using FP8-Quant as an example of qunatization methods.
4. The CPU Offloading (Layerwise) row describes local layerwise offload.
Multi-device Distributed Layerwise Offload has a separate topology and
compatibility matrix in the [CPU Offloading guide](diffusion/cpu_offload.md#distributed-layerwise-offloading).
5. The compatibility matrix uses FP8 as the representative quantization method.
6. Step Execution is not compatible with any diffusion cache backend. LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches).
## Multi-Thread Weight Loading
Large diffusion models can take several minutes to load weights at startup (e.g., ~3 min for Qwen-Image, ~5 min for Wan2.2 I2V 14B). Multi-thread weight loading speeds up this process by loading safetensors shards in parallel using a thread pool instead of sequentially.
This optimization is **enabled by default** with 4 threads. No configuration is needed for the default behavior.
### Configuration
| Parameter | CLI Flag | Default | Description |
|-----------|----------|---------|-------------|
| `enable_multithread_weight_load` | `--disable-multithread-weight-load` | `True` (enabled) | Pass the flag to disable multi-thread loading |
| `num_weight_load_threads` | `--num-weight-load-threads` | `4` | Number of threads for parallel weight loading |
!!! tip
The default of 4 threads balances speed and disk I/O contention. On fast NVMe storage you may benefit from more threads (e.g., 8). On HDD or network storage, the default of 4 avoids saturating I/O bandwidth.
### Online Serving
```bash
# Default (multi-thread enabled, 4 threads)
vllm serve Qwen/Qwen-Image --omni --port 8091
# Custom thread count
vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers --omni --num-weight-load-threads 8
# Disable multi-thread loading
vllm serve Qwen/Qwen-Image --omni --disable-multithread-weight-load
```
### Offline Inference
```python
from vllm_omni import Omni
# Default (multi-thread enabled, 4 threads)
omni = Omni(model="Qwen/Qwen-Image")
# Custom thread count
omni = Omni(
model="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
num_weight_load_threads=8,
)
```
### Benchmarks
Measured on NVIDIA H800:
| Model | Before | After | Speedup |
|-------|--------|-------|---------|
| **Qwen/Qwen-Image** (53.7 GiB) | 168s | 27s | **6.2x** |
| **Wan-AI/Wan2.2-I2V-A14B-Diffusers** (64.5 GiB) | 283s | 56s | **5.1x** |
The loading guide now lives at [Diffusion Startup and
Loading](diffusion/startup_and_loading.md). This heading remains so existing
links to this section continue to work.
## Learn More
**Cache Acceleration:**
The Diffusion Acceleration navigation groups the remaining guides as follows:
- **[TeaCache Configuration Guide](diffusion/cache_acceleration/teacache.md)** - Parameter tuning, performance tips, troubleshooting
- **[Cache-DiT Advanced Guide](diffusion/cache_acceleration/cache_dit.md)** - DBCache, TaylorSeer, SCM techniques and optimization
| Area | Guide |
| --- | --- |
| Compatibility | [Feature Compatibility](feature_compatibility.md) |
| CPU offloading | [CPU Offloading](diffusion/cpu_offload.md) |
| Cache acceleration | [TeaCache](diffusion/cache_acceleration/teacache.md), [Cache-DiT](diffusion/cache_acceleration/cache_dit.md) |
| Parallelism | [Parallelism Overview](diffusion/parallelism/overview.md) |
| Attention | [Attention Backends](diffusion/attention_backends.md) |
| Compilation | [Regional Compilation](diffusion/regional_compilation.md) |
| Video extension | [Frame Interpolation](diffusion/frame_interpolation.md) |
| Startup | [Startup and Loading](diffusion/startup_and_loading.md) |
| Adapters | [LoRA](diffusion/lora.md) |
**Parallelism Methods:**
Related cross-model and runtime features are documented separately:
- **[Parallelism Overview](diffusion/parallelism/overview.md)** - Tensor Parallelism, Sequence Parallelism, CFG Parallelism, Pipeline Parallelism, HSDP, and Expert Parallelism
**Memory Optimization:**
- **[CPU Offload Guide](diffusion/cpu_offload.md)** - Offload model components to CPU, reduce GPU memory usage
- **[VAE Parallelism Guide](diffusion/parallelism/vae_parallelism.md)** - Distribute VAE decode work across GPUs for high-resolution images and videos
- **[Quantization](quantization/overview.md)** - Quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models
**Extensions:**
- **[LoRA Inference Guide](diffusion/lora.md)** - Low-Rank Adaptation for style customization and fine-tuning
- **[Frame Interpolation Guide](diffusion/frame_interpolation.md)** - Worker-side post-generation video frame interpolation for smoother motion
**Execution Modes:**
- **[Diffusion Execution Modes](diffusion/execution_modes.md)** - Configure request batching, step execution, continuous batching, and streaming output
**Startup Optimization:**
- **[Multi-Thread Weight Loading](#multi-thread-weight-loading)** - Speed up model startup by loading safetensors shards in parallel
**Advanced Topics:**
- **[Feature Compatibility](feature_compatibility.md)** - How to combine multiple features for maximum performance
- [Quantization](quantization/overview.md) covers diffusion-only models,
multi-stage omni/TTS models, and multi-stage diffusion models.
- [Execution Modes and Streaming](diffusion/execution_modes.md) covers the
diffusion runtime, including batching, step execution, and streaming output.
@@ -12,6 +12,7 @@ For backend selection and SageAttention usage, see the [Diffusion Attention Back
| Model | Default Resolution | Default Frames | Default Steps | Guidance | VRAM (BF16) |
|---|---|---|---|---|---|
| `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB |
| `robbyant/lingbot-video-dense-1.3b` / `robbyant/lingbot-video-moe-30b-a3b` | 192x320 | 9 | 2 | 3.0 | ~68 GiB (MoE smoke) |
| `Lightricks/LTX-2` | 512x768 | 121 | 40 | video 3.0 / audio 7.0 | Model-dependent |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required |
@@ -35,6 +36,25 @@ python text_to_video.py \
--output t2v_out.mp4
```
### LingBot-Video
The shared runner recognizes both official LingBot checkpoint IDs and selects
the native `LingBotVideoPipeline` automatically:
```bash
python text_to_video.py \
--model robbyant/lingbot-video-dense-1.3b \
--prompt "a robotic arm picks up a red block" \
--height 192 --width 320 --num-frames 9 --num-inference-steps 2 \
--guidance-scale 3.0 --flow-shift 3.0 --fps 24 \
--output lingbot_t2v.mp4
```
Use `robbyant/lingbot-video-moe-30b-a3b` for the MoE checkpoint. For local
paths without `lingbot` in the directory name, add
`--model-class-name LingBotVideoPipeline`. Model-only options use
`--extra-body`, for example `'{"batch_cfg": true, "output_type": "np"}'`.
### LTX-2
```bash
@@ -63,7 +63,9 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
from vllm_omni.model_extras import (
build_image_to_video_prompt,
build_image_to_video_prompt as build_model_image_to_video_prompt,
)
from vllm_omni.model_extras import (
get_extra_body_params,
get_model_class_name,
)
@@ -85,6 +87,22 @@ def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
def build_image_to_video_prompt(
prompt: str,
negative_prompt: str | None,
media_inputs: dict[str, Any],
) -> dict[str, Any]:
"""Build the canonical request envelope for the shared I2V example."""
result: dict[str, Any] = {
"prompt": prompt,
"modalities": ["video"],
"multi_modal_data": media_inputs,
}
if negative_prompt is not None:
result["negative_prompt"] = negative_prompt
return result
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
@@ -566,10 +584,13 @@ def main():
# Preserve the historical empty-prompt behavior for non-LTX examples.
negative_prompt = ""
prompt_dict = build_image_to_video_prompt(
model_class_name=model_class_name,
prompt=args.prompt,
negative_prompt=negative_prompt,
media_inputs=media_inputs,
)
prompt_dict = build_model_image_to_video_prompt(
model_class_name=model_class_name,
prompt=prompt_dict,
height=height,
width=width,
num_frames=num_frames,
@@ -57,8 +57,9 @@ python examples/offline_inference/text_to_image/text_to_image.py \
--output lingbot_t2i.png
```
The LingBot prompt builder selects the `image` output modality and requests one
frame. Passing an input image is not supported in LingBot T2I mode.
The shared T2I entry point selects the `image` output modality. The LingBot
pipeline validates that T2I requests produce one frame and do not include an
input image.
## Quick Start
@@ -21,7 +21,9 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
from vllm_omni.model_extras import (
build_text_to_image_prompt,
build_text_to_image_prompt as build_model_text_to_image_prompt,
)
from vllm_omni.model_extras import (
get_extra_body_params,
get_model_class_name,
should_init_extra_args_for_non_diffusion_stages,
@@ -56,6 +58,17 @@ def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
def build_text_to_image_prompt(prompt: str, negative_prompt: str | None) -> dict[str, Any]:
"""Build the canonical request envelope for the shared T2I example."""
result: dict[str, Any] = {
"prompt": prompt,
"modalities": ["image"],
}
if negative_prompt is not None:
result["negative_prompt"] = negative_prompt
return result
def _normalize_images_for_save(images: list[Any]) -> list[Any]:
"""Convert NumPy diffusion outputs to PIL images before saving."""
normalized = []
@@ -523,9 +536,12 @@ def main():
generation_start = time.perf_counter()
prompt_dict = build_text_to_image_prompt(
model_class_name=model_class_name,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
)
prompt_dict = build_model_text_to_image_prompt(
model_class_name=model_class_name,
prompt=prompt_dict,
height=args.height,
width=args.width,
)
@@ -24,6 +24,7 @@ list of supported architectures across all modalities, see
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants | 24 kHz |
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | ✓ (online) | continuation | 48 kHz |
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | 2 (AR talker + S2Mel DiT + BigVGAN) | ✓ (required) | — | emotion control (`--emo-audio`, `--emo-text`, `--emo-vector`) | 22.05 kHz |
| IndexTTS-2.5 | native `checkpoints/` bundle | 2 (AR talker + EnhancedCodec + S2Mel DiT + BigVGAN) | ✓ (required) | — | multilingual (`--lang`) + emotion control | 22.05 kHz |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets | 24 kHz |
## Common Quick Start
@@ -437,11 +438,14 @@ Streaming is exposed through the online OpenAI Speech API (`stream=true`). See [
---
## IndexTTS-2
## IndexTTS-2 and IndexTTS-2.5
2-stage TTS pipeline (GPT AR talker + S2Mel CFM DiT + BigVGAN vocoder) at 22.05 kHz. Every request requires reference audio for zero-shot voice cloning. Supports emotion conditioning via audio, text, or 8-dim vector.
Both versions use a 2-stage TTS pipeline and produce 22.05 kHz mono audio. Stage 0 is a GPT AR talker. IndexTTS-2 Stage 1 uses RepCodec semantic embeddings, GPT latent, S2Mel CFM/DiT, and BigVGAN. IndexTTS-2.5 replaces RepCodec with EnhancedCodec and uses the official code-only path (`use_gpt_latent=false`). Every request requires reference audio for zero-shot voice cloning.
### Quick start
IndexTTS-2:
```bash
python examples/offline_inference/text_to_speech/indextts2/end2end.py \
--model IndexTeam/IndexTTS-2 \
@@ -449,6 +453,24 @@ python examples/offline_inference/text_to_speech/indextts2/end2end.py \
--ref-audio /path/to/reference.wav
```
IndexTTS-2 uses its existing frontend and does not require the IndexTTS-2.5 text dependencies. Before running IndexTTS-2.5, install its optional frontend dependencies:
```bash
pip install 'vllm-omni[indextts2]'
```
IndexTTS-2.5 with a native repository/lab bundle whose model root contains `checkpoints/`:
```bash
python examples/offline_inference/text_to_speech/indextts2/end2end.py \
--model /path/to/indextts-2.5 \
--model-version 2.5 \
--lang ja \
--text "こんにちは、音声合成のテストです。" \
--ref-audio /path/to/reference.wav
```
### Emotion control
```bash
# Emotion from reference audio
@@ -474,10 +496,13 @@ python examples/offline_inference/text_to_speech/indextts2/end2end.py \
```
### Notes
- `--ref-audio` is **required** — IndexTTS-2 does not support text-only synthesis.
- Stage 0 (AR Talker): GPT-2 generates mel codes from text + reference audio.
- Stage 1 (S2Mel + BigVGAN): CFM DiT converts mel codes to waveform at 22.05 kHz.
- Deploy config: `vllm_omni/deploy/indextts2.yaml`. Stage 1 runs with `enforce_eager: true` (DiT has dynamic shapes).
- `--ref-audio` is **required** — neither version provides text-only synthesis.
- IndexTTS-2.5 Stage 0 (AR Talker) generates mel codes from text + reference audio using plain vLLM sampling. It does not reproduce the official default `num_beams=3` beam search. For parity comparisons, run upstream with `num_beams=1`; output quality can differ from the official beam-search result.
- IndexTTS-2.5 accepts language codes such as `zh`, `en`, `zhen` (mixed Chinese/English), `ja`, and `yue`. `Mandarin` is a vLLM-Omni convenience alias for `zh`. Japanese (`ja`) uses `fugashi` tokenization and produces audio, but it does not automatically expand numbers, dates, or percentages; write those inputs as readable Japanese text before calling the model. Use `--no-text-normalization` only when the input is already normalized.
- A seed controls Stage 0 AR sampling and per-request CFM noise. Different concurrent batch compositions do not guarantee a bit-identical waveform.
- Stage 1 (semantic codec + S2Mel + BigVGAN): CFM/DiT converts the complete semantic-code sequence to waveform.
- Deploy configs: `vllm_omni/deploy/indextts2.yaml` and `indextts2_5.yaml`.
- `async_chunk=false` is intentional for IndexTTS-2.5 correctness: S2Mel consumes the completed semantic sequence.
---
@@ -1,13 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline inference example for IndexTTS2 via vLLM-Omni.
"""Offline inference example for IndexTTS 2.0 and 2.5 via vLLM-Omni.
Two-stage pipeline: GPT AR (Stage 0) → S2Mel + BigVGAN (Stage 1).
Two-stage pipeline: GPT AR (Stage 0) → semantic codec + S2Mel + BigVGAN
(Stage 1).
Output is 22050 Hz mono WAV.
Usage:
python end2end.py \
--model /path/to/IndexTeam/IndexTTS-2 \
--model /path/to/indextts-2.5 \
--model-version 2.5 \
--lang zh \
--text "你好,这是一个语音合成测试。" \
--ref-audio /path/to/ref.wav
@@ -47,6 +50,10 @@ def build_request(
emo_alpha: float | None = None,
use_emo_text: bool = False,
use_random: bool = False,
model_type: str = "indextts2",
lang: str = "zh",
text_normalization: bool = True,
tokenizer_file: str | None = None,
) -> dict:
additional: dict = {"text": [text]}
if ref_audio_path:
@@ -63,12 +70,44 @@ def build_request(
additional["use_emo_text"] = [True]
if use_random:
additional["use_random"] = [True]
if model_type == "indextts2_5":
additional["lang"] = [lang]
additional["text_normalization"] = [text_normalization]
prompt_kwargs = {
"model_type": model_type,
"lang": lang,
"text_normalization": text_normalization,
}
if tokenizer_file is not None:
prompt_kwargs["tokenizer_file"] = tokenizer_file
return {
"prompt_token_ids": build_indextts2_prefill_prompt_ids(model, text),
"prompt_token_ids": build_indextts2_prefill_prompt_ids(
model,
text,
**prompt_kwargs,
),
"additional_information": additional,
}
def _get_stage0_tokenizer_file(omni: Omni) -> str | None:
"""Read the effective tokenizer override from the resolved Stage 0 config."""
stage_configs = omni.stage_configs
if not stage_configs:
return None
engine_args = getattr(stage_configs[0], "engine_args", None)
if engine_args is None:
return None
hf_overrides = getattr(engine_args, "hf_overrides", None)
if hf_overrides is None and isinstance(engine_args, dict):
hf_overrides = engine_args.get("hf_overrides")
if hf_overrides is None:
return None
if hasattr(hf_overrides, "get"):
return hf_overrides.get("tokenizer_file")
return getattr(hf_overrides, "tokenizer_file", None)
def save_audio(waveform: torch.Tensor, path: str, sample_rate: int = 22050) -> None:
audio_np = waveform.float().numpy()
sf.write(path, audio_np, sample_rate)
@@ -95,9 +134,14 @@ def extract_audio(mm: dict) -> tuple[torch.Tensor | None, int]:
def main(args) -> None:
model_type = "indextts2_5" if args.model_version == "2.5" else "indextts2"
deploy_config = args.deploy_config
if deploy_config is None and args.model_version == "2.5":
repo_root = Path(__file__).resolve().parents[4]
deploy_config = str(repo_root / "vllm_omni" / "deploy" / "indextts2_5.yaml")
omni = Omni(
model=args.model,
deploy_config=args.deploy_config,
deploy_config=deploy_config,
stage_init_timeout=args.stage_init_timeout,
)
@@ -126,7 +170,7 @@ def main(args) -> None:
print(f"Synthesizing: {args.text!r}")
if args.ref_audio:
print(f" ref_audio: {args.ref_audio}")
inputs = build_request(
request_kwargs = dict(
model=args.model,
text=args.text,
ref_audio_path=args.ref_audio,
@@ -136,7 +180,17 @@ def main(args) -> None:
emo_alpha=args.emo_alpha,
use_emo_text=args.use_emo_text,
use_random=args.use_random,
model_type=model_type,
lang=args.lang,
text_normalization=args.text_normalization,
)
if model_type == "indextts2_5":
tokenizer_file = _get_stage0_tokenizer_file(omni)
if tokenizer_file is not None:
request_kwargs["tokenizer_file"] = tokenizer_file
inputs = build_request(**request_kwargs)
if model_type == "indextts2_5":
inputs["additional_information"]["duration_factor"] = [1.0 / getattr(args, "speed", 1.0)]
for i, omni_out in enumerate(omni.generate(inputs, sampling_params_list=sampling_params)):
mm = omni_out.multimodal_output
@@ -154,8 +208,37 @@ def main(args) -> None:
def parse_args():
parser = FlexibleArgumentParser(description="IndexTTS2 offline inference")
parser.add_argument("--model", required=True, help="HF model path for IndexTTS2.")
parser = FlexibleArgumentParser(description="IndexTTS 2.0/2.5 offline inference")
parser.add_argument(
"--model",
required=True,
help="HF model path or native bundle containing checkpoints/.",
)
parser.add_argument(
"--model-version",
choices=("2.0", "2.5"),
default="2.0",
)
parser.add_argument(
"--lang",
default="zh",
help=(
"IndexTTS 2.5 language code, for example zh/en/zhen/ja/yue; "
"zhen is mixed Chinese/English and Mandarin is a vLLM-Omni alias for zh."
),
)
parser.add_argument(
"--no-text-normalization",
action="store_false",
dest="text_normalization",
help="Disable IndexTTS 2.5 text normalization.",
)
parser.add_argument(
"--speed",
type=float,
default=1.0,
help="IndexTTS 2.5 native synthesis speed in [0.5, 2.0]; 2.0 is faster.",
)
parser.add_argument("--text", default="你好,这是IndexTTS2语音合成测试。")
parser.add_argument("--ref-audio", required=True, help="Reference audio for voice cloning.")
parser.add_argument("--emo-audio", default=None, help="Emotion reference audio.")
@@ -170,7 +253,15 @@ def parse_args():
parser.add_argument("--emo-alpha", type=float, default=None, help="Emotion weight in [0, 1].")
parser.add_argument("--use-emo-text", action="store_true", help="Infer emotion vector from emo-text or text.")
parser.add_argument("--use-random", action="store_true", help="Use random emotion prototypes.")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument(
"--seed",
type=int,
default=None,
help=(
"Seed Stage 0 AR sampling and per-request CFM noise; changing the "
"concurrent batch composition may still change the final waveform."
),
)
parser.add_argument(
"--output-dir",
default=os.path.join(
@@ -180,7 +271,10 @@ def parse_args():
)
parser.add_argument("--deploy-config", default=None)
parser.add_argument("--stage-init-timeout", type=int, default=600)
return parser.parse_args()
args = parser.parse_args()
if args.model_version == "2.5" and not 0.5 <= args.speed <= 2.0:
parser.error("IndexTTS 2.5 --speed must be between 0.5 and 2.0")
return args
if __name__ == "__main__":
@@ -8,6 +8,7 @@ A unified script for text-to-video generation. Supports multiple models with mod
|---|---|---|---|---|---|
| `Wan-AI/Wan2.1-VACE-1.3B-diffusers` | 480x832 | 81 | 30 | 5.0 | ~20 GiB (RTX 5090, VAE tiling) |
| `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB |
| `robbyant/lingbot-video-dense-1.3b` / `robbyant/lingbot-video-moe-30b-a3b` | 192x320 | 9 | 2 | 3.0 | ~68 GiB (MoE smoke) |
| `Lightricks/LTX-2` | 512x768 | 121 | 40 | video 3.0 / audio 7.0 | Model-dependent |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required |
@@ -57,18 +58,31 @@ python text_to_video.py \
### LingBot-Video
The dedicated LingBot example explicitly selects the video output modality:
The shared runner recognizes the official dense and MoE checkpoint IDs, selects
`LingBotVideoPipeline`, and builds the canonical video request envelope:
```bash
python examples/offline_inference/text_to_video/text_to_video_lingbot.py \
python text_to_video.py \
--model robbyant/lingbot-video-dense-1.3b \
--prompt "a robotic arm picks up a red block" \
--height 192 --width 320 --num-frames 9 --num-inference-steps 2 \
--guidance-scale 3.0 --flow-shift 3.0 --fps 24 \
--output lingbot_t2v.mp4
```
Use the same command with `robbyant/lingbot-video-moe-30b-a3b` for the MoE
checkpoint. Requested frame counts are rounded upward to the causal
VAE's `4n+1` grid.
VAE's `4n+1` grid. For a local checkpoint path whose name does not contain
`lingbot`, pass `--model-class-name LingBotVideoPipeline`.
LingBot-only options such as `batch_cfg` and `output_type` use the shared
model-extra channel, for example:
```bash
python text_to_video.py \
--model robbyant/lingbot-video-dense-1.3b \
--extra-body '{"batch_cfg": true, "output_type": "np"}'
```
### LTX-2
@@ -16,7 +16,7 @@ from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
from vllm_omni.model_extras import get_extra_body_params, get_model_class_name
from vllm_omni.model_extras import get_extra_body_params, get_model_class_name, get_output_tensor_range
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -80,6 +80,17 @@ _MODEL_PRESETS = {
"fps": 16,
"output": "helios_output.mp4",
},
"lingbot": {
"model_class_name": "LingBotVideoPipeline",
"height": 192,
"width": 320,
"num_frames": 9,
"num_inference_steps": 2,
"guidance_scale": 3.0,
"fps": 24,
"flow_shift": 3.0,
"output": "lingbot_video_output.mp4",
},
"ltx2": {
"height": 512,
"width": 768,
@@ -110,27 +121,51 @@ _MODEL_PRESETS = {
def _detect_preset(model: str, model_class_name: str | None = None) -> dict:
model_lower = model.lower()
class_lower = (model_class_name or "").lower()
if "distilled" in class_lower or "distilled" in model_lower:
return _MODEL_PRESETS["ltx2_distilled"]
if "ltx23" in class_lower or "ltx-2.3" in model_lower or "ltx_2.3" in model_lower:
return _MODEL_PRESETS["ltx23"]
if "ltx2" in class_lower or "ltx-2" in model_lower or "ltx_2" in model_lower:
if "lingbot" in model_lower or "lingbotvideo" in class_lower:
return _MODEL_PRESETS["lingbot"]
if "ltx" in class_lower or "ltx" in model_lower:
if "distilled" in class_lower or "distilled" in model_lower:
return _MODEL_PRESETS["ltx2_distilled"]
if "ltx23" in class_lower or "ltx-2.3" in model_lower or "ltx_2.3" in model_lower:
return _MODEL_PRESETS["ltx23"]
return _MODEL_PRESETS["ltx2"]
if "vace" in model_lower:
if "vace" in model_lower or "vace" in class_lower:
return _MODEL_PRESETS["vace"]
# Edge must be matched before the generic cosmos branch (its "cosmos" substring would
# otherwise pick up the Nano/Super 720p / gs 6.0 / flow_shift 10.0 preset).
if ("cosmos" in model_lower or "cosmos" in class_lower) and ("edge" in model_lower or "edge" in class_lower):
return _MODEL_PRESETS["cosmos3_edge"]
if "cosmos" in model_lower:
if "cosmos" in model_lower or "cosmos" in class_lower:
return _MODEL_PRESETS["cosmos"]
if "hunyuan" in model_lower:
if "hunyuan" in model_lower or "hunyuan" in class_lower:
return _MODEL_PRESETS["hunyuan"]
if "helios" in model_lower:
if "helios" in model_lower or "helios" in class_lower:
return _MODEL_PRESETS["helios"]
return _MODEL_PRESETS["wan"]
def build_text_to_video_prompt(prompt: str, negative_prompt: str | None) -> dict[str, Any]:
"""Build the canonical request envelope for the shared T2V example."""
result: dict[str, Any] = {
"prompt": prompt,
"modalities": ["video"],
}
if negative_prompt is not None:
result["negative_prompt"] = negative_prompt
return result
def _normalize_float_tensor(tensor: torch.Tensor, source_range: str) -> torch.Tensor:
"""Normalize decoded frames according to the pipeline's output contract."""
if not tensor.is_floating_point():
return tensor
if source_range == "negative_one_to_one":
return tensor.clamp(-1, 1) * 0.5 + 0.5
if source_range == "zero_to_one":
return tensor.clamp(0, 1)
raise ValueError(f"Unsupported floating-point tensor range: {source_range!r}")
def parse_profiler_config(value: str) -> dict[str, Any]:
try:
config = json.loads(value)
@@ -160,7 +195,7 @@ def parse_extra_body(value: str) -> dict[str, Any]:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a video from a text prompt. "
"Supports Wan2.2, HunyuanVideo-1.5, Helios, and other text-to-video models."
"Supports Wan2.2, HunyuanVideo-1.5, Helios, LingBot-Video, and other text-to-video models."
)
parser.add_argument(
"--model",
@@ -408,6 +443,7 @@ def main():
for key, default_val in preset.items():
if getattr(args, key.replace("-", "_"), None) is None:
setattr(args, key.replace("-", "_"), default_val)
model_class_name = args.model_class_name
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
# Cache-dit config (Wan2.2 only)
@@ -480,6 +516,7 @@ def main():
omni = Omni(**omni_kwargs)
model_class_name = get_model_class_name(omni) or model_class_name
declared_extra_body_params = get_extra_body_params(model_class_name)
output_tensor_range = get_output_tensor_range(model_class_name)
if profiler_enabled:
print("[Profiler] Starting profiling...")
@@ -513,12 +550,11 @@ def main():
lora_path=lora_path,
)
prompt_dict = {"prompt": args.prompt}
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
elif preset not in (_MODEL_PRESETS["ltx2"], _MODEL_PRESETS["ltx23"]):
negative_prompt = args.negative_prompt
if negative_prompt is None and all(preset is not _MODEL_PRESETS[name] for name in ("lingbot", "ltx2", "ltx23")):
# Preserve the historical empty-prompt behavior for non-LTX examples.
prompt_dict["negative_prompt"] = ""
negative_prompt = ""
prompt_dict = build_text_to_video_prompt(args.prompt, negative_prompt)
extra_args = {}
if lora_request:
@@ -630,8 +666,7 @@ def main():
frame_tensor = frame_tensor[0]
if frame_tensor.dim() == 3 and frame_tensor.shape[0] in (3, 4):
frame_tensor = frame_tensor.permute(1, 2, 0)
if frame_tensor.is_floating_point():
frame_tensor = frame_tensor.clamp(-1, 1) * 0.5 + 0.5
frame_tensor = _normalize_float_tensor(frame_tensor, output_tensor_range)
return frame_tensor.float().numpy()
if isinstance(frame, np.ndarray):
frame_array = frame
@@ -683,8 +718,7 @@ def main():
video_tensor = video_tensor[0]
elif video_tensor.dim() == 4 and video_tensor.shape[0] in (3, 4):
video_tensor = video_tensor.permute(1, 2, 3, 0)
if video_tensor.is_floating_point():
video_tensor = video_tensor.clamp(-1, 1) * 0.5 + 0.5
video_tensor = _normalize_float_tensor(video_tensor, output_tensor_range)
video_array = video_tensor.float().numpy()
elif isinstance(frames, np.ndarray):
video_array = frames
@@ -1,131 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import argparse
import time
from pathlib import Path
from typing import Any
import numpy as np
import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate text-to-video with a LingBot-Video checkpoint.")
parser.add_argument("--model", default="robbyant/lingbot-video-dense-1.3b")
parser.add_argument("--prompt", default="a robotic arm picks up a red block")
parser.add_argument("--negative-prompt", default=None)
parser.add_argument("--output", default="lingbot_video_output.mp4")
parser.add_argument("--height", type=int, default=192)
parser.add_argument("--width", type=int, default=320)
parser.add_argument("--num-frames", type=int, default=9)
parser.add_argument("--num-inference-steps", type=int, default=2)
parser.add_argument("--guidance-scale", type=float, default=3.0)
parser.add_argument("--flow-shift", type=float, default=3.0)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--fps", type=int, default=24)
parser.add_argument("--output-type", choices=["pt", "np"], default="pt")
parser.add_argument("--batch-cfg", action="store_true")
return parser.parse_args()
def _peak_memory_mb(output: Any) -> float:
result = output[0] if isinstance(output, list) and output else output
return float(getattr(result, "peak_memory_mb", 0.0) or 0.0)
def _extract_video(output: Any) -> Any:
if isinstance(output, list):
output = output[0] if output else None
if isinstance(output, OmniRequestOutput):
if output.final_output_type != "image":
raise ValueError(f"Unexpected output type {output.final_output_type!r}.")
if not output.images:
raise ValueError("No video frames found in OmniRequestOutput.")
return output.images[0] if len(output.images) == 1 else output.images
return output
def _to_frame_array(video: Any) -> np.ndarray:
if isinstance(video, torch.Tensor):
tensor = video.detach().cpu()
if tensor.dim() == 5:
tensor = tensor[0]
if tensor.dim() == 4 and tensor.shape[-1] not in (3, 4):
if tensor.shape[0] in (3, 4):
tensor = tensor.permute(1, 2, 3, 0)
elif tensor.shape[1] in (3, 4):
tensor = tensor.permute(0, 2, 3, 1)
if tensor.is_floating_point():
if float(tensor.min()) < 0.0:
tensor = tensor.clamp(-1, 1) * 0.5 + 0.5
else:
tensor = tensor.clamp(0, 1)
return tensor.float().numpy()
if isinstance(video, np.ndarray):
array = video[0] if video.ndim == 5 else video
if np.issubdtype(array.dtype, np.integer):
return array.astype(np.float32) / 255.0
return array.astype(np.float32, copy=False)
if isinstance(video, list):
return np.stack([_to_frame_array(frame) for frame in video], axis=0)
return np.asarray(video, dtype=np.float32)
def main() -> None:
args = parse_args()
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
omni = Omni(
model=args.model,
model_class_name="LingBotVideoPipeline",
flow_shift=args.flow_shift,
parallel_config=DiffusionParallelConfig(),
)
prompt: dict[str, Any] = {
"prompt": args.prompt,
"modalities": ["video"],
"num_frames": args.num_frames,
}
if args.negative_prompt is not None:
prompt["negative_prompt"] = args.negative_prompt
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_frames=args.num_frames,
num_inference_steps=args.num_inference_steps,
guidance_scale=args.guidance_scale,
generator=generator,
output_type=args.output_type,
extra_args={"batch_cfg": args.batch_cfg, "shift": args.flow_shift},
)
start = time.perf_counter()
output = omni.generate(prompt, sampling_params)
elapsed = time.perf_counter() - start
print(f"Total generation time: {elapsed:.4f} seconds ({elapsed * 1000:.2f} ms)")
peak_mb = _peak_memory_mb(output)
if peak_mb:
print(f"Worker peak GPU memory (reserved): {peak_mb:.2f} MiB ({peak_mb / 1024:.2f} GiB)")
video = _to_frame_array(_extract_video(output))
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
from diffusers.utils import export_to_video
export_to_video(list(video), str(output_path), fps=args.fps)
print(f"Saved generated video to {output_path}")
omni.close()
if __name__ == "__main__":
main()
@@ -1,6 +1,9 @@
# Image-To-Video
This example demonstrates how to deploy the Wan2.2 image-to-video model for online video generation using vLLM-Omni.
This example demonstrates online image-to-video generation with vLLM-Omni.
The startup script defaults to Wan2.2, but `MODEL` can select any supported
image-to-video model. The existing curl helper remains a concrete Wan2.2
request through the standard Videos API.
## Start Server
@@ -20,9 +23,7 @@ bash run_server.sh
The script allows overriding:
- `MODEL` (default: `Wan-AI/Wan2.2-I2V-A14B-Diffusers`)
- `PORT` (default: `8091`)
- `BOUNDARY_RATIO` (default: `0.875`)
- `FLOW_SHIFT` (default: `12.0`)
- `PORT` (default: `8099`)
- `CACHE_BACKEND` (default: `none`)
- `ENABLE_CACHE_DIT_SUMMARY` (default: `0`)
@@ -1,12 +1,12 @@
#!/bin/bash
# Wan2.2 image-to-video server start script
# Shared image-to-video server start script
MODEL="${MODEL:-Wan-AI/Wan2.2-I2V-A14B-Diffusers}"
PORT="${PORT:-8099}"
CACHE_BACKEND="${CACHE_BACKEND:-none}"
ENABLE_CACHE_DIT_SUMMARY="${ENABLE_CACHE_DIT_SUMMARY:-0}"
echo "Starting Wan2.2 I2V server..."
echo "Starting image-to-video server..."
echo "Model: $MODEL"
echo "Port: $PORT"
echo "Cache backend: $CACHE_BACKEND"
@@ -1,6 +1,9 @@
# Text-To-Image
This example demonstrates how to deploy Qwen-Image model for online image generation service using vLLM-Omni.
This example demonstrates online text-to-image generation with vLLM-Omni. The
startup script and Python clients default to Qwen-Image, but `MODEL` can select
any supported text-to-image model. The existing curl helper remains a concrete
Qwen-Image request through the standard Images API.
## Start Server
@@ -242,8 +245,8 @@ count, use `size` and `n` rather than `height`, `width`, or
| `height` | int | None | Image height in pixels |
| `width` | int | None | Image width in pixels |
| `size` | str | None | Image size (e.g., "1024x1024") |
| `num_inference_steps` | int | 50 | Number of denoising steps |
| `true_cfg_scale` | float | 4.0 | Qwen-Image CFG scale |
| `num_inference_steps` | int | model | Number of denoising steps |
| `true_cfg_scale` | float | model | True CFG scale when supported |
| `seed` | int | None | Random seed (reproducible) |
| `negative_prompt` | str | None | Negative prompt |
| `num_outputs_per_prompt` | int | 1 | Number of images to generate |
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Qwen-Image Gradio Demo for online serving.
Shared text-to-image Gradio demo for online serving.
Usage:
python gradio_demo.py [--server http://localhost:8091] [--port 7860]
@@ -77,9 +77,9 @@ def generate_image(
def create_demo(server_url: str):
"""Create Gradio demo interface."""
with gr.Blocks(title="Qwen-Image Demo") as demo:
gr.Markdown("# Qwen-Image Online Generation")
gr.Markdown("Generate images using Qwen-Image model")
with gr.Blocks(title="Text-to-Image Demo") as demo:
gr.Markdown("# Text-to-Image Online Generation")
gr.Markdown("Generate images using the model configured on the server")
with gr.Row():
with gr.Column(scale=1):
@@ -115,7 +115,6 @@ def create_demo(server_url: str):
label="Inference Steps",
minimum=10,
maximum=100,
# Default steps aligned with run_curl_text_to_image.sh to 100
value=100,
step=5,
)
@@ -173,7 +172,7 @@ def create_demo(server_url: str):
def main():
parser = argparse.ArgumentParser(description="Qwen-Image Gradio Demo")
parser = argparse.ArgumentParser(description="Shared text-to-image Gradio demo")
parser.add_argument("--server", default="http://localhost:8091", help="Server URL")
parser.add_argument("--port", type=int, default=7860, help="Gradio port")
parser.add_argument("--share", action="store_true", help="Create public link")
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Qwen-Image OpenAI-compatible image generation client.
Shared OpenAI-compatible text-to-image generation client.
Usage:
python openai_chat_client.py --prompt "A beautiful landscape" --output output.png
@@ -39,7 +39,7 @@ def generate_image(
height: Image height in pixels
width: Image width in pixels
steps: Number of diffusion steps
true_cfg_scale: Qwen-Image CFG scale
true_cfg_scale: True CFG scale for models that support it
seed: Random seed
negative_prompt: Negative prompt
num_outputs_per_prompt: Number of images to generate
@@ -70,7 +70,7 @@ def generate_image(
payload["num_inference_steps"] = steps
if true_cfg_scale is not None:
payload["true_cfg_scale"] = true_cfg_scale
if negative_prompt:
if negative_prompt is not None:
payload["negative_prompt"] = negative_prompt
if seed is not None:
payload["seed"] = seed
@@ -115,15 +115,15 @@ def generate_image(
def main():
parser = argparse.ArgumentParser(description="Qwen-Image chat client")
parser = argparse.ArgumentParser(description="Shared text-to-image client")
parser.add_argument("--prompt", "-p", default="a cup of coffee on the table", help="Text prompt")
parser.add_argument("--output", "-o", default="qwen_image_output.png", help="Output file")
parser.add_argument("--output", "-o", default="text_to_image_output.png", help="Output file")
parser.add_argument("--server", "-s", default="http://localhost:8091", help="Server URL")
parser.add_argument("--height", type=int, default=1024, help="Image height")
parser.add_argument("--width", type=int, default=1024, help="Image width")
parser.add_argument("--steps", type=int, default=50, help="Inference steps")
parser.add_argument("--cfg-scale", type=float, default=4.0, help="True CFG scale")
parser.add_argument("--seed", type=int, default=0, help="Random seed")
parser.add_argument("--height", type=int, help="Image height; defaults to the model setting")
parser.add_argument("--width", type=int, help="Image width; defaults to the model setting")
parser.add_argument("--steps", type=int, help="Inference steps; defaults to the model setting")
parser.add_argument("--cfg-scale", type=float, help="True CFG scale when supported")
parser.add_argument("--seed", type=int, help="Random seed")
parser.add_argument("--negative", help="Negative prompt")
parser.add_argument("--lora-path", default=None, help="Server-local LoRA adapter folder (PEFT format)")
@@ -1,10 +1,10 @@
#!/bin/bash
# Qwen-Image online serving startup script
# Shared text-to-image online serving startup script
MODEL="${MODEL:-Qwen/Qwen-Image}"
PORT="${PORT:-8091}"
echo "Starting Qwen-Image server..."
echo "Starting text-to-image server..."
echo "Model: $MODEL"
echo "Port: $PORT"
@@ -16,7 +16,8 @@ For the full list of supported architectures across all modalities, see
|---|---|---|---|---|---|
| Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ |
| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ |
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✓ (`ref_audio` or uploaded `voice`) | compat only, non-chunk | uploaded audio voice only; no presets | — |
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — |
| IndexTTS-2.5 | native `checkpoints/` bundle | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — |
| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | ✓ (`ref_audio` / `speaker_embedding`) | ✓ (PCM stream) | IP labels + structured `instructions` | — |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | — (caption-controlled) | — | caption fields (`instructions`) | — |
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ |
@@ -138,15 +139,29 @@ bash examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh
---
## IndexTTS-2
## IndexTTS-2 and IndexTTS-2.5
2-stage TTS (GPT AR + S2Mel CFM DiT + BigVGAN) at 22.05 kHz. Requests use `ref_audio` for voice cloning, or an uploaded audio `voice` from `/v1/audio/voices`. Supports emotion conditioning via `emo_audio`, `emo_text`, or `emo_vector` passed in `extra_params`.
2-stage TTS at 22.05 kHz. Requests use `ref_audio` for voice cloning, or an uploaded audio `voice` from `/v1/audio/voices`. IndexTTS-2.5 uses a multilingual tokenizer, CAMPPlus speaker projection, EnhancedCodec, and code-only Stage 0→1 transfer by default. Both versions support emotion conditioning via `emo_audio`, `emo_text`, or `emo_vector` passed in `extra_params`.
### Prerequisites
IndexTTS-2 uses its existing frontend and does not require the IndexTTS-2.5 text dependencies. Before launching IndexTTS-2.5, install its optional frontend dependencies:
```bash
pip install 'vllm-omni[indextts2]'
```
### Launch
```bash
vllm serve IndexTeam/IndexTTS-2 --omni --trust-remote-code --port 8092
# or, to pass the bundled deploy config explicitly:
bash examples/online_serving/text_to_speech/indextts2/run_server.sh
# IndexTTS-2.5 default: use_gpt_latent=false
MODEL_VERSION=2.5 \
MODEL=/path/to/indextts-2.5 \
bash examples/online_serving/text_to_speech/indextts2/run_server.sh
```
### Sending requests
@@ -161,14 +176,35 @@ python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--text "今天心情很好!" \
--ref-audio /path/to/ref.wav \
--emo-audio /path/to/happy.wav
# IndexTTS-2.5 Japanese request
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 \
--model /path/to/indextts-2.5 \
--lang ja \
--text "こんにちは、音声合成のテストです。" \
--ref-audio /path/to/ref.wav
# IndexTTS-2.5 native speed control
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 \
--model /path/to/indextts-2.5 \
--speed 2.0 \
--text "这是两倍速度的语音合成测试。" \
--ref-audio /path/to/ref.wav
```
### Notes
- Output: 22.05 kHz mono WAV.
- Provide `ref_audio` on the documented raw request path, or pass `voice` only when it names an uploaded audio voice; IndexTTS-2 does not provide a built-in text-only preset voice.
- Provide `ref_audio` on the documented raw request path, or pass `voice` only when it names an uploaded audio voice; neither version provides a built-in text-only preset voice.
- IndexTTS-2.5 request controls `lang` and `text_normalization` are carried in `extra_params`; the client does this for `--model-version 2.5`.
- IndexTTS-2.5 accepts the public `speed` request field in `[0.5, 2.0]`: `2.0` generates shorter, faster speech and `0.5` generates longer, slower speech. This is native Stage 1 duration control, so serving does not apply the generic playback-speed adjustment a second time. IndexTTS-2 does not use this control.
- IndexTTS-2.5 accepts language codes such as `zh`, `en`, `zhen` (mixed Chinese/English), `ja`, and `yue`. `Mandarin` is a vLLM-Omni convenience alias for `zh`. Japanese (`ja`) uses `fugashi` tokenization and produces audio, but it does not automatically expand numbers, dates, or percentages; callers should first write those inputs as readable Japanese text.
- A request `seed` controls Stage 0 AR sampling and per-request CFM noise. Different concurrent batch compositions do not guarantee a bit-identical waveform.
- IndexTTS-2.5 Stage 0 uses plain vLLM sampling and does not reproduce the official default `num_beams=3` beam search. For parity comparisons, run upstream with `num_beams=1`; output quality can differ from the official beam-search result.
- Emotion params (`emo_audio`, `emo_text`, `emo_vector`, `emo_alpha`, `use_emo_text`, `use_random`) are passed via the `extra_params` field. Official precedence is `use_emo_text` > `emo_vector` > `emo_audio` > same emotion as the speaker reference.
- `stream=true` is accepted as an OpenAI-compatible response path, but IndexTTS-2 is not async-chunk streaming; audio is produced after S2Mel receives the full mel-code sequence.
- Deploy config: `vllm_omni/deploy/indextts2.yaml` (auto-loaded).
- IndexTTS-2.5 uses `vllm_omni/deploy/indextts2_5.yaml` with the official code-only Stage 0 to Stage 1 contract.
- For IndexTTS-2.5, set `MODEL` to the local native bundle; asset discovery accepts its nested `checkpoints/` layout.
---
@@ -1,20 +1,37 @@
#!/bin/bash
# Launch vLLM-Omni server for IndexTTS2
# Launch vLLM-Omni server for IndexTTS 2.0 or 2.5.
#
# Usage from repository root:
# examples/online_serving/text_to_speech/indextts2/run_server.sh
# CUDA_VISIBLE_DEVICES=0 PORT=8092 MODEL=/path/to/IndexTeam/IndexTTS-2 examples/online_serving/text_to_speech/indextts2/run_server.sh
# MODEL_VERSION=2.5 MODEL=/path/to/native/bundle \
# examples/online_serving/text_to_speech/indextts2/run_server.sh
set -e
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
MODEL="${MODEL:-IndexTeam/IndexTTS-2}"
MODEL_VERSION="${MODEL_VERSION:-2.0}"
PORT="${PORT:-8092}"
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$ROOT_DIR/vllm_omni/deploy/indextts2.yaml}"
echo "Starting IndexTTS2 server with model: $MODEL"
if [[ "$MODEL_VERSION" == "2.5" ]]; then
if [[ -z "${MODEL:-}" ]]; then
echo "Usage: MODEL_VERSION=2.5 MODEL=/path/to/native/bundle bash $0" >&2
exit 2
fi
DEFAULT_DEPLOY_CONFIG="$ROOT_DIR/vllm_omni/deploy/indextts2_5.yaml"
elif [[ "$MODEL_VERSION" == "2.0" ]]; then
MODEL="${MODEL:-IndexTeam/IndexTTS-2}"
DEFAULT_DEPLOY_CONFIG="$ROOT_DIR/vllm_omni/deploy/indextts2.yaml"
else
echo "MODEL_VERSION must be 2.0 or 2.5" >&2
exit 2
fi
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$DEFAULT_DEPLOY_CONFIG}"
echo "Starting IndexTTS $MODEL_VERSION server with model: $MODEL"
echo "Deploy config: $DEPLOY_CONFIG"
FLASHINFER_DISABLE_VERSION_CHECK=1 \
vllm serve "$MODEL" \
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""OpenAI-compatible client for IndexTTS2 TTS via /v1/audio/speech endpoint.
"""OpenAI-compatible client for IndexTTS 2.0/2.5 via /v1/audio/speech.
Examples:
# With reference audio for voice cloning
@@ -54,13 +54,44 @@ def main() -> None:
parser.add_argument("--emo-alpha", type=float, default=None, help="Emotion weight in [0, 1]")
parser.add_argument("--use-emo-text", action="store_true", help="Infer emotion vector from emo-text or text")
parser.add_argument("--use-random", action="store_true", help="Use random emotion prototypes")
parser.add_argument("--model", type=str, default="IndexTeam/IndexTTS-2")
parser.add_argument(
"--model-version",
choices=("2.0", "2.5"),
default="2.0",
)
parser.add_argument("--model", type=str, default=None)
parser.add_argument(
"--lang",
default="zh",
help=(
"IndexTTS 2.5 language code, for example zh/en/zhen/ja/yue; "
"zhen is mixed Chinese/English and Mandarin is a vLLM-Omni alias for zh"
),
)
parser.add_argument(
"--no-text-normalization",
action="store_false",
dest="text_normalization",
help="Disable IndexTTS 2.5 text normalization",
)
parser.add_argument(
"--speed",
type=float,
default=1.0,
help="IndexTTS 2.5 native synthesis speed in [0.5, 2.0]; 2.0 is faster",
)
parser.add_argument("--voice", type=str, default=None, help="Uploaded voice name to use instead of --ref-audio")
parser.add_argument("--output", type=str, default="output.wav")
parser.add_argument("--api-base", type=str, default=DEFAULT_API_BASE)
parser.add_argument("--api-key", type=str, default=DEFAULT_API_KEY)
parser.add_argument("--response-format", type=str, default="wav")
args = parser.parse_args()
if args.model is None:
if args.model_version == "2.5":
parser.error("--model is required for IndexTTS 2.5")
args.model = "IndexTeam/IndexTTS-2"
if args.model_version == "2.5" and not 0.5 <= args.speed <= 2.0:
parser.error("IndexTTS 2.5 --speed must be between 0.5 and 2.0")
if not args.ref_audio and not args.voice:
parser.error("IndexTTS2 requires --ref-audio or --voice for voice cloning")
@@ -97,6 +128,10 @@ def main() -> None:
extra_params["use_emo_text"] = True
if args.use_random:
extra_params["use_random"] = True
if args.model_version == "2.5":
payload["speed"] = args.speed
extra_params["lang"] = args.lang
extra_params["text_normalization"] = args.text_normalization
if extra_params:
payload["extra_params"] = extra_params
+11
View File
@@ -84,6 +84,13 @@ forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
]
indextts2 = [
"fugashi>=1.2.0",
"unidic-lite>=1.0.0",
"wetext>=0.0.9; sys_platform != 'linux'",
"WeTextProcessing; sys_platform == 'linux'",
]
# Local-test-only extras
local = [
"FlagEmbedding",
@@ -327,3 +334,7 @@ byt5 = "byt5"
# "seeked" is the standard HTML media element DOM event name (fires when a
# seek completes), used by the word-timestamps Gradio demo. Not a typo.
seeked = "seeked"
# IndexTTS 2.5 language codes and speech-emotion-recognition event token.
fo = "fo"
ba = "ba"
SER = "SER"
+308
View File
@@ -0,0 +1,308 @@
# IndexTTS-2.5 for multilingual voice-cloned TTS on 1x GPU
## Summary
- Vendor: IndexTeam
- Model: `IndexTeam/IndexTTS-2.5`
- Task: Multilingual text-to-speech with voice cloning, emotion control, and
native speed control
- Mode: Online serving with the OpenAI-compatible `/v1/audio/speech` API, or
offline inference through `Omni`
- Maintainer: Community
## When to use this recipe
Use this recipe to serve IndexTTS-2.5 as a two-stage TTS system. Stage 0 is an
autoregressive talker; Stage 1 uses EnhancedCodec, S2Mel CFM/DiT, and BigVGAN
to produce 22.05 kHz mono speech. Each request supplies synthesis text and
reference audio, or an uploaded audio voice, for zero-shot voice cloning.
IndexTTS-2.5 adds multilingual text processing and model-native speed control
to the IndexTTS-2 serving contract.
## References
- Upstream model:
[IndexTeam/IndexTTS-2.5 on Hugging Face](https://huggingface.co/IndexTeam/IndexTTS-2.5)
- Online serving example:
[`examples/online_serving/text_to_speech/README.md#indextts-2-and-indextts-25`](../../examples/online_serving/text_to_speech/README.md#indextts-2-and-indextts-25)
- Offline inference example:
[`examples/offline_inference/text_to_speech/README.md#indextts-2-and-indextts-25`](../../examples/offline_inference/text_to_speech/README.md#indextts-2-and-indextts-25)
- OpenAI-compatible client:
[`examples/online_serving/text_to_speech/indextts2/speech_client.py`](../../examples/online_serving/text_to_speech/indextts2/speech_client.py)
- Standard deploy config:
[`vllm_omni/deploy/indextts2_5.yaml`](../../vllm_omni/deploy/indextts2_5.yaml)
## Hardware Support
### GPU
### 1x NVIDIA H20 96GB
#### Environment
- OS: Linux
- Python: 3.10+
- Driver / runtime: NVIDIA CUDA environment
- vLLM version: Match the repository requirements for your checkout
- vLLM-Omni version or commit: Use the commit you are deploying from
Install vLLM-Omni with the IndexTTS text-processing dependencies:
```bash
pip install 'vllm-omni[indextts2]'
```
Obtain the native IndexTTS-2.5 bundle and point `MODEL` at its root. The model
loader accepts the upstream nested `checkpoints/` layout.
#### Command
Start the standard code-only server from the repository root:
```bash
MODEL_VERSION=2.5 \
MODEL=/path/to/indextts-2.5 \
bash examples/online_serving/text_to_speech/indextts2/run_server.sh
```
This selects `vllm_omni/deploy/indextts2_5.yaml`, which uses
`use_gpt_latent=false`. To launch directly:
```bash
vllm serve /path/to/indextts-2.5 \
--omni \
--trust-remote-code \
--port 8092 \
--deploy-config vllm_omni/deploy/indextts2_5.yaml
```
#### Optional: NVIDIA MPS for higher single-GPU throughput
Stage 0 and Stage 1 run in separate processes on the same GPU. Enabling
NVIDIA Multi-Process Service (MPS) can improve steady-state throughput by
allowing work from both stages to overlap more effectively. MPS is an opt-in
deployment optimization: this recipe does not start it automatically, and the
gain depends on the request mix and concurrency, so benchmark it with the
intended workload before enabling it in production.
Pay attention to CUDA device renumbering. For example, if the MPS control
daemon is started with physical `CUDA_VISIBLE_DEVICES=1`, that GPU is exposed
to MPS clients as logical device `0`; launch the server with
`CUDA_VISIBLE_DEVICES=0`, not `1`. Use a dedicated MPS pipe/log directory when
the host is shared with other services, and stop only the MPS daemon owned by
this deployment.
#### Verification
Send a multilingual voice-cloning request with the bundled client. Local audio
paths are converted to base64 data URLs before transmission:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--api-base http://localhost:8092 \
--model-version 2.5 \
--model /path/to/indextts-2.5 \
--lang zh \
--text "你好,这是 IndexTTS-2.5 语音合成测试。" \
--ref-audio /path/to/reference.wav \
--output indextts2_5.wav
```
##### Native speed control
The public `speed` field is handled natively. Its accepted range is
`[0.5, 2.0]`; values above `1.0` produce shorter, faster speech. The serving
adapter converts the API convention to the model-native control with
`duration_factor = 1.0 / speed`, so `speed=1.25` uses
`duration_factor=0.8`:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--api-base http://localhost:8092 \
--model-version 2.5 \
--model /path/to/indextts-2.5 \
--speed 1.25 \
--text "这是使用模型原生语速控制的测试。" \
--ref-audio /path/to/reference.wav \
--output indextts2_5_speed.wav
```
##### Upload and reuse a named voice
Upload a reference recording once when callers should reuse the same voice
without sending `ref_audio` on every request. `consent` is required by the
voice-storage API:
```bash
curl -X POST http://localhost:8092/v1/audio/voices \
-F "audio_sample=@/path/to/reference.wav" \
-F "consent=user-consent-id" \
-F "name=indextts_demo_voice" \
-F "speaker_description=IndexTTS-2.5 demonstration voice"
```
Confirm that the voice was stored, then synthesize with its name instead of a
reference-audio payload:
```bash
curl http://localhost:8092/v1/audio/voices
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--api-base http://localhost:8092 \
--model-version 2.5 \
--model /path/to/indextts-2.5 \
--voice indextts_demo_voice \
--lang zh \
--text "你好,这是复用已上传音色的语音合成测试。" \
--output indextts2_5_named_voice.wav
```
There are no built-in text-only preset voices. Each named IndexTTS voice must
first be created from an uploaded reference recording.
##### Multilingual synthesis and text normalization
The client accepts `zh` (Mandarin), `en` (English), `zhen` (mixed Chinese and
English), `ja` (Japanese), and `yue` (Cantonese). The following commands use
the same reference voice across all supported language modes:
```bash
CLIENT=examples/online_serving/text_to_speech/indextts2/speech_client.py
MODEL=/path/to/indextts-2.5
REF_AUDIO=/path/to/reference.wav
python "$CLIENT" --model-version 2.5 --model "$MODEL" \
--ref-audio "$REF_AUDIO" --lang zh \
--text "你好,这是中文语音合成测试。" --output indextts2_5_zh.wav
python "$CLIENT" --model-version 2.5 --model "$MODEL" \
--ref-audio "$REF_AUDIO" --lang en \
--text "Hello, this is an English speech synthesis test." \
--output indextts2_5_en.wav
python "$CLIENT" --model-version 2.5 --model "$MODEL" \
--ref-audio "$REF_AUDIO" --lang zhen \
--text "Hello,欢迎使用 IndexTTS 二点五。" --output indextts2_5_zhen.wav
python "$CLIENT" --model-version 2.5 --model "$MODEL" \
--ref-audio "$REF_AUDIO" --lang ja \
--text "こんにちは、これは日本語の音声合成テストです。" \
--output indextts2_5_ja.wav
python "$CLIENT" --model-version 2.5 --model "$MODEL" \
--ref-audio "$REF_AUDIO" --lang yue \
--text "你好,呢段係粵語語音合成測試。" --output indextts2_5_yue.wav
```
Text normalization is enabled by default. Disable it only when the input is
already written exactly as it should be spoken:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 --model /path/to/indextts-2.5 \
--ref-audio /path/to/reference.wav --lang en --no-text-normalization \
--text "The temperature is twenty five degrees." \
--output indextts2_5_pre_normalized.wav
```
For raw HTTP requests, put the same language and text-normalization controls
under `extra_params`:
```bash
curl http://localhost:8092/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "/path/to/indextts-2.5",
"input": "This is an IndexTTS-2.5 test.",
"response_format": "wav",
"speed": 1.0,
"ref_audio": "data:audio/wav;base64,<BASE64_ENCODED_AUDIO>",
"extra_params": {
"lang": "en",
"text_normalization": true
}
}' \
--output indextts2_5_en.wav
```
##### Emotion control
IndexTTS-2.5 supports an explicit eight-value emotion vector, an emotion text
description, or a separate emotion-reference recording. Use one source at a
time. Vector values are ordered as `happy`, `angry`, `sad`, `afraid`,
`disgusted`, `melancholic`, `surprised`, and `calm`; each value must be in
`[0, 1.2]`. `emo_alpha` controls the strength in `[0, 1]`.
Use an explicit emotion vector for deterministic control:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 --model /path/to/indextts-2.5 \
--ref-audio /path/to/reference.wav --lang zh \
--text "今天真是令人开心的一天!" \
--emo-vector 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 \
--emo-alpha 0.8 --output indextts2_5_happy_vector.wav
```
Infer the emotion from a natural-language description with `use_emo_text`:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 --model /path/to/indextts-2.5 \
--ref-audio /path/to/reference.wav --lang zh \
--text "我们终于完成了这项工作!" \
--use-emo-text --emo-text "开心、兴奋而且充满活力" \
--emo-alpha 0.8 --output indextts2_5_happy_text.wav
```
Transfer emotion from a recording while retaining the speaker identity from
`ref_audio`:
```bash
python examples/online_serving/text_to_speech/indextts2/speech_client.py \
--model-version 2.5 --model /path/to/indextts-2.5 \
--ref-audio /path/to/reference.wav --lang zh \
--text "请用情绪参考音频中的表达方式朗读这句话。" \
--emo-audio /path/to/emotion_reference.wav \
--emo-alpha 0.8 --output indextts2_5_emotion_audio.wav
```
If multiple emotion sources are supplied, the official precedence is
`use_emo_text` > `emo_vector` > `emo_audio` > the emotion in the speaker
reference. `--use-random` is also available for exploratory random emotion
prototypes, but should not be used when reproducible conditioning is required.
For offline inference:
```bash
python examples/offline_inference/text_to_speech/indextts2/end2end.py \
--model /path/to/indextts-2.5 \
--model-version 2.5 \
--lang zh \
--speed 1.0 \
--text "你好,这是离线语音合成测试。" \
--ref-audio /path/to/reference.wav
```
#### Notes
- Hardware scope: the standard recipe has been exercised on one NVIDIA H200.
It does not claim an unverified throughput or quality result.
- Audio output: 22.05 kHz mono WAV.
- Voice cloning: reference audio is required on the documented raw request
path. Alternatively, `voice` may name an uploaded audio voice; there is no
built-in text-only preset voice.
- Native speed: serving maps `speed` to the model's duration factor and skips
generic waveform speed adjustment, so speed is applied exactly once.
- Languages: common codes include `zh`, `en`, `zhen` (mixed Chinese/English),
`ja`, and `yue`; `Mandarin` is accepted as an alias for `zh`.
- Japanese normalization: `ja` tokenization does not automatically expand
numbers, dates, or percentages. Write these inputs as readable Japanese text
before inference.
- Emotion controls: `use_emo_text`, `emo_vector`, and `emo_audio` are
alternative conditioning modes. Their precedence is `use_emo_text` >
`emo_vector` > `emo_audio` > the speaker-reference emotion.
- Sampling difference: Stage 0 uses plain vLLM sampling, not the upstream
default `num_beams=3` beam search. Use upstream `num_beams=1` for parity
comparisons; output may differ from the official beam-search result.
@@ -0,0 +1,198 @@
# MiniMax-H3 on RTX PRO 5000 Blackwell GPUs
This recipe runs MiniMax-H3 in BF16 on 72 GiB RTX PRO 5000 Blackwell GPUs. It
contains the validated two-GPU DLO configuration and the recommended resident
configurations: TP1 x Ulysses2 with 20 resident layers on two GPUs, TP2 x
Ulysses2 on four GPUs, and TP4 x Ulysses2 on eight GPUs. The four- and
eight-GPU routes do not require offload.
## Capacity requirements
| Resource | Requirement |
| --- | ---: |
| GPUs | 2, 4, or 8 x RTX PRO 5000 Blackwell |
| GPU HBM | 72 GiB per GPU |
| Checkpoint storage | 135 GiB per partition |
| Available system RAM | 200 GiB minimum |
| Recommended system RAM | 384 GiB |
`FL2VA` and `Ref2VA` are separate checkpoint partitions. Start one server at a
time on a host sized for the minimum system-memory requirement.
## PCIe topology and GPU order
RTX PRO 5000 does not provide NVLink. Before starting the server, identify the
closest PCIe pairs and their NUMA affinity:
```bash
nvidia-smi topo -m
nvidia-smi nvlink -s
```
For two GPUs, select the closest PCIe pair on one NUMA node. For four GPUs,
select all cards from one NUMA node. On the validated host,
physical GPUs `(0,1)` and `(2,3)` are the two `PXB` pairs on NUMA node 0. The
order `CUDA_VISIBLE_DEVICES=0,2,1,3` maps the Ulysses groups to those local
pairs. For eight GPUs, the validated order is `0,4,1,5,2,6,3,7`, with host
memory interleaved across NUMA nodes 0 and 1. Do not copy these IDs blindly:
reproduce the same PCIe and NUMA relationships on the target host.
## Recommended serving configurations
### Two GPUs
Two 72 GiB cards require distributed layerwise offload. The validated route
uses TP1 x Ulysses2, keeps 20 leading DiT layers resident, and streams
rank-local weights without AllGather. Eager execution avoids regional-compile
instability on this offload path.
```bash
export MODEL_ROOT=/path/to/MiniMax-H3
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
numactl --cpunodebind=0 --membind=0 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 2 \
--tensor-parallel-size 1 \
--usp 2 \
--ring 1 \
--text-encoder-tp-size 2 \
--vae-patch-parallel-size 2 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend CUDNN_ATTN \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--dlo-resident-layers 20 \
--enforce-eager
```
### Four GPUs
The validated baseline uses TP2 x Ulysses2, text-encoder TP4, VAE patch
parallelism 4, and explicit cuDNN BF16 attention. Selecting the backend
explicitly keeps the recipe independent of platform-default backend changes.
```bash
export MODEL_ROOT=/path/to/MiniMax-H3
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,2,1,3 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
numactl --cpunodebind=0 --membind=0 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 4 \
--tensor-parallel-size 2 \
--usp 2 \
--ring 1 \
--text-encoder-tp-size 4 \
--vae-patch-parallel-size 4 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend CUDNN_ATTN
```
### Eight GPUs
The recommended eight-GPU route uses TP4 x Ulysses2, text-encoder TP8, VAE
patch parallelism 8, and host-memory interleaving across both NUMA nodes.
```bash
export MODEL_ROOT=/path/to/MiniMax-H3
export MODEL="${MODEL_ROOT}/FL2VA"
export PORT=8091
CUDA_VISIBLE_DEVICES=0,4,1,5,2,6,3,7 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
numactl --interleave=0,1 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 8 \
--tensor-parallel-size 4 \
--usp 2 \
--ring 1 \
--text-encoder-tp-size 8 \
--vae-patch-parallel-size 8 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend CUDNN_ATTN
```
For the four- and eight-GPU resident routes, do not add `--enforce-eager`.
Warm the server once before measuring so regional compilation is outside the
measured request. The two-GPU DLO route intentionally remains eager.
For Ref2VA, stop the FL2VA server and restart the same command with
`MODEL="${MODEL_ROOT}/Ref2VA"`.
## Target-hardware validation
All three configurations were exercised on a PCIe-only, dual-socket host with
eight RTX PRO 5000 GPUs. The run used PyTorch 2.11.0+cu130, CUDA 13.0, driver
580.95.05, 1344x768 output, 124 frames, and two warmups.
The four-GPU route also received a five-step profiling validation for GPU
balance:
| Measurement | Result |
| --- | ---: |
| Maximum externally sampled peak | 69,219 MiB (67.60 GiB) per GPU |
| T2VA worker-reported peak | 65,314 MiB |
| First-frame FL2VA worker-reported peak | 66,780 MiB |
| T2VA maximum GPU kernel-time deviation | 0.56% |
| First-frame FL2VA maximum GPU kernel-time deviation | 0.09% |
The recommended routes produced the following 50-step results. MiniMax-H3
requested 50 denoise steps and executed 49 denoise updates, so per-step latency
is `denoise / 49`.
| GPUs | Workload | Parallelism | E2E (s) | Text encode (s) | Denoise (s) | VAE decode (s) | Per step (ms) | Peak memory (GiB) |
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 2 | T2VA | TP1 x Ulysses2, DLO resident 20 | 515.57 | 1.19 | 504.69 | 8.79 | 10,300 | 36.38 |
| 2 | FL2VA first-frame I2VA | TP1 x Ulysses2, DLO resident 20 | 553.45 | 1.27 | 541.90 | 8.76 | 11,059 | 36.38 |
| 4 | T2VA | TP2 x Ulysses2 | 284.01 | 0.03 | 278.73 | 4.36 | 5,688 | 67.58 |
| 4 | FL2VA first-frame I2VA | TP2 x Ulysses2 | 305.72 | 0.28 | 299.85 | 4.36 | 6,119 | 67.58 |
| 8 | T2VA | TP4 x Ulysses2 | 163.20 | 0.04 | 159.40 | 2.58 | 3,253 | 45.83 |
| 8 | FL2VA first-frame I2VA | TP4 x Ulysses2 | 171.62 | 0.27 | 167.25 | 2.57 | 3,413 | 45.83 |
Peak memory is the maximum per-GPU value sampled externally with
`nvidia-smi`. The two-GPU route leaves about 34.7 GiB below the reported
73,415 MiB device capacity, the four-GPU route leaves about 4.1 GiB, and the
eight-GPU route leaves about 25.9 GiB. Re-measure memory for longer reference
inputs, concurrency greater than one, or a different output shape.
## T2VA request example
```bash
export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync"
curl -sS --max-time 1800 -X POST "${API_URL}" \
-F 'prompt=At night, three cats march into a bedroom playing tiny brass instruments, then abruptly file out, with synchronized room ambience.' \
-F 'width=1344' \
-F 'height=768' \
-F 'aspect_ratio=16:9' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=1101' \
-F 'extra_params={"task":"t2va","duration":5.0,"audio_flow_shift":3.0}' \
-o t2va.mp4
```
+1
View File
@@ -50,6 +50,7 @@ recipes/
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
| [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) |
| [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU |
| [`IndexTeam/IndexTTS-2_5.md`](./IndexTeam/IndexTTS-2_5.md) | Online + offline multilingual voice-cloned TTS with native speed and emotion control | 1x NVIDIA H20 96GB |
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
| [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) |
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
+3 -3
View File
@@ -27,7 +27,7 @@ parallel, cache, quantized, or expert-kernel backends.
- Dense checkpoint: <https://huggingface.co/robbyant/lingbot-video-dense-1.3b>
- MoE checkpoint: <https://huggingface.co/robbyant/lingbot-video-moe-30b-a3b>
- Upstream project: <https://github.com/Robbyant/lingbot-video>
- Related offline example: [`examples/offline_inference/text_to_video/text_to_video_lingbot.py`](../../examples/offline_inference/text_to_video/text_to_video_lingbot.py)
- Related offline example: [`examples/offline_inference/text_to_video/text_to_video.py`](../../examples/offline_inference/text_to_video/text_to_video.py)
- Related online video API docs: [`docs/serving/videos_api.md`](../../docs/serving/videos_api.md)
## Hardware Support
@@ -52,7 +52,7 @@ The MoE path is validated with BF16 expert weights.
```bash
CUDA_VISIBLE_DEVICES=0 \
python examples/offline_inference/text_to_video/text_to_video_lingbot.py \
python examples/offline_inference/text_to_video/text_to_video.py \
--model robbyant/lingbot-video-dense-1.3b \
--prompt "a robotic arm picks up a red block" \
--output lingbot_t2v.mp4 \
@@ -70,7 +70,7 @@ python examples/offline_inference/text_to_video/text_to_video_lingbot.py \
```bash
CUDA_VISIBLE_DEVICES=0 \
python examples/offline_inference/text_to_video/text_to_video_lingbot.py \
python examples/offline_inference/text_to_video/text_to_video.py \
--model robbyant/lingbot-video-moe-30b-a3b \
--prompt "a robotic arm picks up a red block" \
--output lingbot_moe_t2v.mp4 \
+36
View File
@@ -51,6 +51,14 @@ def test_load_model_configs(model_configs_path: Path) -> None:
assert configs["test/ModelA"]["supported_tasks"] == ["voice_clone", "default_voice"]
def test_indextts25_is_registered_in_shared_model_configs() -> None:
config_path = Path(bench_tts.__file__).with_name("model_configs.yaml")
config = bench_tts.load_model_configs(config_path)["IndexTeam/IndexTTS-2.5"]
assert config["supported_tasks"] == ["voice_clone"]
assert config["task_extra_body"]["voice_clone"]["extra_params"]["lang"] == "en"
def test_build_bench_args_voice_clone(model_configs_path: Path) -> None:
configs = bench_tts.load_model_configs(model_configs_path)
cmd = bench_tts.build_bench_args(
@@ -120,6 +128,34 @@ def test_build_bench_args_wer_eval_adds_flag(model_configs_path: Path) -> None:
assert "--seed-tts-wer-eval" in cmd
def test_build_bench_args_supports_local_model_and_shared_sweep_options(model_configs_path: Path) -> None:
configs = bench_tts.load_model_configs(model_configs_path)
cmd = bench_tts.build_bench_args(
host="localhost",
port=8092,
model="test/ModelA",
served_model_name="/models/indextts25",
task="voice_clone",
model_cfg=configs["test/ModelA"],
locale="en",
num_prompts=500,
num_warmups=5,
request_seed=42,
concurrency=8,
dataset_path="/data/seed-tts",
wer_eval=False,
output_dir=None,
result_filename=None,
extra_cli_args=["--", "--tokenizer", "/models/indextts25/qwen0.6bemo4-merge"],
)
assert cmd[cmd.index("--model") + 1] == "/models/indextts25"
assert cmd[cmd.index("--num-warmups") + 1] == "5"
assert cmd[-2:] == ["--tokenizer", "/models/indextts25/qwen0.6bemo4-merge"]
extra_body = json.loads(cmd[cmd.index("--extra-body") + 1])
assert extra_body == {"task_type": "Base", "seed": 42}
def test_unsupported_task_exits(model_configs_path: Path, capsys: pytest.CaptureFixture, mocker) -> None:
# ModelB does not support voice_design
mocker.patch.object(
+10
View File
@@ -2276,6 +2276,16 @@ class TestPlatformOverrides:
# Stage 2 unaffected fields stay at base
assert deploy.stages[2].enforce_eager is False
def test_qwen2_5_omni_xpu_uses_eager_ar_stages(self):
deploy_path = Path(get_deploy_config_path("qwen2_5_omni.yaml"))
deploy = load_deploy_config(deploy_path)
deploy = _apply_platform_overrides(deploy, platform="xpu")
assert deploy.stages[0].enforce_eager is True
assert deploy.stages[1].enforce_eager is True
assert deploy.stages[2].enforce_eager is True
def test_xpu_overrides(self):
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
if not deploy_path.exists():
@@ -126,6 +126,7 @@ class TestFullPayloadCoordinatorSelection(unittest.TestCase):
("AudexCode2Wav", "audex_code2wav"),
("AudexXCodec1", "audex_xcodec"),
("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"),
("IndexTTS25S2MelDecoder", "indextts2_5_s2mel_decoder"),
("DyninOmniForConditionalGeneration", "token2image"),
("DyninOmniForConditionalGeneration", "token2audio"),
}
+152
View File
@@ -6,6 +6,9 @@ explicitly patch values that differ from vLLM.
import argparse
import inspect
import json
import os
import shutil
from types import SimpleNamespace
from unittest.mock import Mock
@@ -194,6 +197,155 @@ def test_qwen3_tts_code2wav_injects_max_position_embeddings(monkeypatch):
}
def test_patch_missing_local_hf_config(tmp_path):
"""Native model bundles may only contain a checkpoints/ directory."""
(tmp_path / "checkpoints").mkdir()
args = object.__new__(OmniEngineArgs)
args.model = str(tmp_path)
args.model_arch = "IndexTTS25TalkerForConditionalGeneration"
args.hf_config_path = None
try:
args._patch_empty_hf_config("indextts2_5")
assert args.hf_config_path is not None
config_path = args.hf_config_path + "/config.json"
with open(config_path, encoding="utf-8") as config_file:
config = json.load(config_file)
assert config == {
"model_type": "indextts2_5",
"architectures": ["IndexTTS25TalkerForConditionalGeneration"],
}
finally:
if hasattr(args, "_temp_config_dir"):
shutil.rmtree(args._temp_config_dir, ignore_errors=True)
@pytest.mark.parametrize(
"config_entry",
["malformed", "directory", "broken_symlink", "permission_error"],
)
def test_non_missing_local_hf_config_error_reaches_parent_loader(tmp_path, monkeypatch, config_entry):
"""Non-missing config errors must reach vLLM's normal loader."""
config_path = tmp_path / "config.json"
if config_entry == "malformed":
config_path.write_text("{not valid json", encoding="utf-8")
loader_error = json.JSONDecodeError("invalid config", "{not valid json", 1)
elif config_entry == "directory":
config_path.mkdir()
loader_error = IsADirectoryError(str(config_path))
elif config_entry == "broken_symlink":
config_path.symlink_to(tmp_path / "missing-target.json")
loader_error = FileNotFoundError(str(config_path))
else:
config_path.write_text("{}", encoding="utf-8")
loader_error = PermissionError(str(config_path))
parent_inputs = {}
def controlled_get_config_dict(cls, model, **kwargs):
if model == str(tmp_path):
raise loader_error
with open(os.path.join(model, "config.json"), encoding="utf-8") as config_file:
return json.load(config_file), {}
def fake_parent_create_model_config(self):
parent_inputs["called"] = True
parent_inputs["model"] = self.model
parent_inputs["hf_config_path"] = self.hf_config_path
config_root = self.hf_config_path or self.model
parent_inputs["config_root"] = config_root
return PretrainedConfig.get_config_dict(config_root)[0]
monkeypatch.setattr(PretrainedConfig, "get_config_dict", classmethod(controlled_get_config_dict))
monkeypatch.setattr(OmniEngineArgs, "_ensure_omni_models_registered", lambda self: True)
monkeypatch.setattr(EngineArgs, "create_model_config", fake_parent_create_model_config)
monkeypatch.setattr(
OmniModelConfig,
"from_vllm_model_config",
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
)
args = OmniEngineArgs(
model=str(tmp_path),
model_arch="IndexTTS25TalkerForConditionalGeneration",
)
with pytest.raises(type(loader_error)) as exc_info:
args.create_model_config()
assert str(exc_info.value) == str(loader_error)
assert parent_inputs["called"] is True
assert parent_inputs["model"] == str(tmp_path)
assert parent_inputs["config_root"] == str(tmp_path)
assert parent_inputs["hf_config_path"] is None
assert args.model == str(tmp_path)
assert args.hf_config_path is None
assert not hasattr(args, "_temp_config_dir")
def test_patch_valid_hf_config_without_model_type_preserves_keys(tmp_path):
"""A valid partial config should be copied before required keys are added."""
original_config = {
"hidden_size": 1024,
"custom_key": {"nested": True},
}
(tmp_path / "config.json").write_text(json.dumps(original_config), encoding="utf-8")
args = object.__new__(OmniEngineArgs)
args.model = str(tmp_path)
args.model_arch = "IndexTTS25TalkerForConditionalGeneration"
args.hf_config_path = None
try:
args._patch_empty_hf_config("indextts2_5")
assert args.hf_config_path is not None
with open(os.path.join(args.hf_config_path, "config.json"), encoding="utf-8") as config_file:
patched_config = json.load(config_file)
assert patched_config["hidden_size"] == original_config["hidden_size"]
assert patched_config["custom_key"] == original_config["custom_key"]
assert patched_config["model_type"] == "indextts2_5"
assert patched_config["architectures"] == ["IndexTTS25TalkerForConditionalGeneration"]
finally:
if hasattr(args, "_temp_config_dir"):
shutil.rmtree(args._temp_config_dir, ignore_errors=True)
def test_remote_hf_config_error_reaches_parent_loader(monkeypatch):
"""Remote resolution failures must stay on vLLM's normal error path."""
loader_error = OSError("remote config resolution failed")
parent_inputs = {}
def controlled_get_config_dict(cls, model, **kwargs):
raise loader_error
def fake_parent_create_model_config(self):
parent_inputs["called"] = True
parent_inputs["model"] = self.model
parent_inputs["hf_config_path"] = self.hf_config_path
return PretrainedConfig.get_config_dict(self.model)[0]
monkeypatch.setattr(PretrainedConfig, "get_config_dict", classmethod(controlled_get_config_dict))
monkeypatch.setattr(OmniEngineArgs, "_ensure_omni_models_registered", lambda self: True)
monkeypatch.setattr(EngineArgs, "create_model_config", fake_parent_create_model_config)
args = OmniEngineArgs(
model="remote/model",
model_arch="IndexTTS25TalkerForConditionalGeneration",
)
with pytest.raises(OSError) as exc_info:
args.create_model_config()
assert str(exc_info.value) == str(loader_error)
assert parent_inputs["called"] is True
assert parent_inputs["model"] == "remote/model"
assert parent_inputs["hf_config_path"] is None
assert args.model == "remote/model"
assert args.hf_config_path is None
assert not hasattr(args, "_temp_config_dir")
def test_stage_specific_text_config_override():
"""Stage swap must refresh hf_text_config, dependent attrs, and model_arch_config."""
vllm_config = EngineArgs().create_model_config()
@@ -412,7 +412,7 @@ def test_stage_runtime_passes_log_stats_to_llm_replica_launch(monkeypatch):
monkeypatch.setattr(
runtime_mod.StageEngineCoreClientBase,
"make_async_mp_client",
lambda **kwargs: (captured.__setitem__("client_log_stats", kwargs["log_stats"]) or stage_client),
lambda **kwargs: captured.__setitem__("client_log_stats", kwargs["log_stats"]) or stage_client,
)
assert runtime._initialize_local_llm_replica(plan, stage_init_timeout=1) is stage_client
@@ -807,9 +807,9 @@ def test_build_stage0_input_processor_uses_omni_input_preprocessor(monkeypatch):
import vllm_omni.engine.stage_init_utils as init_mod
class DummyInputProcessor:
def __init__(self, vllm_config):
def __init__(self, vllm_config, renderer=None):
self.vllm_config = vllm_config
self.renderer = object()
self.renderer = renderer or object()
self.input_preprocessor = None
class DummyOmniInputPreprocessor:
@@ -828,6 +828,50 @@ def test_build_stage0_input_processor_uses_omni_input_preprocessor(monkeypatch):
assert input_processor.input_preprocessor.renderer is input_processor.renderer
def test_build_stage0_input_processor_does_not_resolve_tokenizer_when_skipped(
monkeypatch,
):
import vllm_omni.engine.stage_init_utils as init_mod
token_only_renderer = object()
seen = {}
class DummyInputProcessor:
def __init__(self, vllm_config, renderer=None):
if renderer is None:
raise AssertionError("skip_tokenizer_init must supply a renderer")
seen["renderer"] = renderer
self.renderer = renderer
self.input_preprocessor = None
class DummyOmniInputPreprocessor:
def __init__(self, vllm_config, renderer=None):
seen["preprocessor_renderer"] = renderer
monkeypatch.setattr(init_mod, "InputProcessor", DummyInputProcessor)
monkeypatch.setattr(
init_mod,
"_build_token_only_renderer",
lambda _config: token_only_renderer,
)
monkeypatch.setattr(
init_mod,
"OmniInputPreprocessor",
DummyOmniInputPreprocessor,
)
config = types.SimpleNamespace(
model_config=types.SimpleNamespace(
skip_tokenizer_init=True,
try_get_generation_config=lambda: {},
)
)
build_stage0_input_processor(config)
assert seen["renderer"] is token_only_renderer
assert seen["preprocessor_renderer"] is token_only_renderer
def test_inject_kv_stage_info_infers_sender_tp_topology():
from vllm_omni.engine.stage_init_utils import inject_kv_stage_info
+1
View File
@@ -52,6 +52,7 @@ def test_positional_construction_matches_base_request():
assert req.external_req_id is None
assert req.additional_information is None
assert req.prompt_embeds_payload is None
assert req.num_stale_output_tokens == 0
def test_positional_prompt_embeds_does_not_collide():
@@ -743,6 +743,48 @@ class TestTTSMethods:
assert speech_server._is_tts is False
assert speech_server._tts_stage is None
@pytest.mark.parametrize(
("model_type", "expected_speed"),
[("indextts2_5", 1.0), ("qwen3_tts", 2.0)],
)
def test_audio_encode_speed_respects_adapter_native_control(
self,
speech_server,
model_type,
expected_speed,
):
speech_server._tts_model_type = model_type
request = OpenAICreateSpeechRequest(input="Hello", speed=2.0)
assert speech_server._audio_encode_speed(request) == expected_speed
@pytest.mark.asyncio
async def test_non_streaming_native_speed_skips_generic_audio_adjustment(
self,
speech_server,
mocker: MockerFixture,
):
async def mock_generate():
yield create_mock_audio_output_for_test()
speech_server._tts_model_type = "indextts2_5"
mocker.patch.object(
speech_server,
"_prepare_speech_generation",
new=mocker.AsyncMock(return_value=("speech-native-speed", mock_generate(), {})),
)
create_audio = mocker.patch.object(
speech_server,
"create_audio",
return_value=SimpleNamespace(audio_data=b"dummy", media_type="audio/wav"),
)
await speech_server._generate_audio_bytes(OpenAICreateSpeechRequest(input="Hello", speed=2.0))
audio_obj = create_audio.call_args.args[0]
assert isinstance(audio_obj, CreateAudio)
assert audio_obj.speed == 1.0
def test_is_tts_detection_with_tts_stage(self, mocker: MockerFixture):
"""Test TTS model detection when TTS stage exists."""
mock_engine_client = mocker.MagicMock()
@@ -2178,17 +2220,17 @@ class TestStreamingProtocolValidation:
assert req.is_streaming() is False
def test_stream_validation_errors(self):
"""stream=True requires response_format in ('pcm', 'wav') and speed=1.0."""
"""The request schema validates formats; model-aware speed checks happen in serving."""
with pytest.raises(ValidationError, match="requires response_format='pcm' or 'wav'"):
OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="mp3")
with pytest.raises(ValidationError, match="Speed adjustment is not supported"):
OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm", speed=2.0)
request = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm", speed=2.0)
assert request.speed == 2.0
def test_stream_format_audio_validation_errors(self):
with pytest.raises(ValidationError, match="requires response_format='pcm' or 'wav'"):
OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="mp3")
with pytest.raises(ValidationError, match="Speed adjustment is not supported"):
OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm", speed=2.0)
request = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm", speed=2.0)
assert request.speed == 2.0
def test_stream_valid(self):
"""stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted as SSE."""
@@ -2271,6 +2313,18 @@ class TestStreamingResponse:
models=mock_models,
request_logger=mocker.MagicMock(),
)
speech_server._tts_model_type = None
speech_server._adapter = None
speech_server._diffusion_mode = False
mocker.patch.object(speech_server, "_uses_native_speed_control", return_value=False)
mocker.patch.object(
speech_server,
"create_error_response",
side_effect=lambda message, **_: JSONResponse(
status_code=400,
content={"error": {"message": message}},
),
)
original_create_speech = speech_server.create_speech
sig = signature(original_create_speech)
@@ -2285,6 +2339,7 @@ class TestStreamingResponse:
app = FastAPI()
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
app.state.speech_server = speech_server
return app
@staticmethod
@@ -2393,6 +2448,39 @@ class TestStreamingResponse:
assert response.status_code in (400, 422)
assert "audio/" not in response.headers.get("content-type", "")
@pytest.mark.parametrize("stream_format", ["sse", "audio"])
def test_native_speed_control_accepts_streaming_speed(
self,
streaming_app,
mocker: MockerFixture,
stream_format: str,
):
speech_server = streaming_app.state.speech_server
speech_server._tts_model_type = "indextts2_5"
speech_server._uses_native_speed_control.return_value = True
async def prepare(request, request_id=None, **kwargs):
del kwargs
generator = speech_server.engine_client.generate(prompt={}, request_id=request_id)
return request_id, generator, {"duration_factor": [1.0 / request.speed]}
mocker.patch.object(speech_server, "_prepare_speech_generation", side_effect=prepare)
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={
"input": "Hello",
"stream_format": stream_format,
"response_format": "pcm",
"speed": 2.0,
},
)
assert response.status_code == 200
expected_media_type = "text/event-stream" if stream_format == "sse" else "audio/pcm"
assert expected_media_type in response.headers["content-type"]
@pytest.fixture
def erroring_streaming_app(self, mocker: MockerFixture):
"""Test app whose mock engine raises mid-stream, to exercise the SSE error event."""
@@ -2616,6 +2704,18 @@ def test_streaming_speech_session_config_accepts_non_streaming_mode():
assert config.non_streaming_mode is True
def test_streaming_speech_session_config_scopes_native_speed_to_http():
with pytest.raises(
ValidationError,
match="native speed control is only available through HTTP streaming",
):
StreamingSpeechSessionConfig(
stream_audio=True,
response_format="pcm",
speed=1.5,
)
class TestAsyncOmniSupportedTasks:
"""Test that AsyncOmni reports correct supported tasks based on output modalities."""
@@ -4,6 +4,9 @@
Pure-Python registry/resolution logic; no model or GPU resources are loaded.
"""
import asyncio
from types import SimpleNamespace
import pytest
from vllm_omni.entrypoints.openai.tts_adapters import (
@@ -11,9 +14,19 @@ from vllm_omni.entrypoints.openai.tts_adapters import (
ARTTSAdapter,
DiffusionTTSAdapter,
all_tts_model_types,
detect_tts_model_type,
resolve_adapter,
)
from vllm_omni.entrypoints.openai.tts_adapters.indextts2 import (
IndexTTS2Adapter,
IndexTTS25Adapter,
indextts2_conditioning_cache_salt,
)
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter
from vllm_omni.model_executor.models.indextts2 import prompt_utils
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -35,6 +48,8 @@ EXPECTED_MODEL_TYPES = {
"higgs_audio_v3",
"glm_tts",
"step_audio2",
"indextts2",
"indextts2_5",
}
@@ -84,6 +99,113 @@ def test_qwen3_tts_metadata():
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
def test_indextts_adapters_are_versioned():
assert resolve_adapter("indextts2") is IndexTTS2Adapter
assert resolve_adapter("indextts2_5") is IndexTTS25Adapter
assert IndexTTS25Adapter.stage_keys == frozenset({"indextts2_5_talker"})
assert detect_tts_model_type("indextts2_5_talker", None) == "indextts2_5"
def test_indextts25_validates_explicit_language():
adapter = IndexTTS25Adapter(type("Context", (), {"server": object()})())
assert adapter._validate_extra_params({"lang": "ja"}) is None
assert "Unsupported IndexTTS 2.5 language" in adapter._validate_extra_params({"lang": "xx-invalid"})
def _indextts25_adapter_and_request(*, speed: float):
server = SimpleNamespace(
uploaded_speakers={},
_validate_ref_audio_format=lambda ref_audio: None,
)
adapter = IndexTTS25Adapter(SimpleNamespace(server=server))
request = SimpleNamespace(
input="hello",
voice="alloy",
ref_audio=object(),
max_new_tokens=None,
extra_params=None,
speed=speed,
)
return adapter, request
def test_indextts25_uses_native_speed_control_duration_factor():
adapter, fast_request = _indextts25_adapter_and_request(speed=2.0)
_, slow_request = _indextts25_adapter_and_request(speed=0.5)
assert adapter.native_speed_control is True
assert adapter.validate(fast_request) is None
assert adapter.validate(slow_request) is None
assert asyncio.run(adapter._build_params(fast_request))["duration_factor"] == [0.5]
assert asyncio.run(adapter._build_params(slow_request))["duration_factor"] == [2.0]
@pytest.mark.parametrize("speed", [0.49, 2.01])
def test_indextts25_rejects_out_of_range_native_speed(speed):
adapter, request = _indextts25_adapter_and_request(speed=speed)
assert adapter.validate(request) == "IndexTTS 2.5 speed must be between 0.5 and 2.0"
def test_indextts25_speed_does_not_change_conditioning_cache_salt():
adapter, fast_request = _indextts25_adapter_and_request(speed=2.0)
_, slow_request = _indextts25_adapter_and_request(speed=0.5)
slow_request.ref_audio = fast_request.ref_audio
fast_params = asyncio.run(adapter._build_params(fast_request))
slow_params = asyncio.run(adapter._build_params(slow_request))
assert indextts2_conditioning_cache_salt(
fast_request,
fast_params,
) == indextts2_conditioning_cache_salt(slow_request, slow_params)
@pytest.mark.parametrize(
("hf_config", "expected_tokenizer_file"),
[
(SimpleNamespace(tokenizer_file="custom-tokenizer.tiktoken"), "custom-tokenizer.tiktoken"),
(SimpleNamespace(), INDEXTTS25_TOKENIZER_FILE),
],
)
def test_indextts25_build_uses_configured_tokenizer_file(
monkeypatch,
hf_config,
expected_tokenizer_file,
):
captured = {}
def fake_estimate(*args, **kwargs):
captured.update(kwargs)
return 4
async def fake_build_params(request):
return {"lang": ["en"], "text_normalization": [True]}
monkeypatch.setattr(
prompt_utils,
"estimate_indextts2_prefill_prompt_len",
fake_estimate,
)
server = SimpleNamespace(
engine_client=SimpleNamespace(
model_config=SimpleNamespace(
model="/model",
hf_config=hf_config,
)
)
)
adapter = IndexTTS25Adapter(SimpleNamespace(server=server))
monkeypatch.setattr(adapter, "_build_params", fake_build_params)
request = SimpleNamespace(input="hello", ref_audio=None)
prepared = asyncio.run(adapter.build(request, [], False))
assert prepared.prompt["prompt_token_ids"] == [1] * 4
assert captured["tokenizer_file"] == expected_tokenizer_file
def test_diffusion_adapter_extra_body_params_fallback():
class _DiffAdapter(DiffusionTTSAdapter):
name = "diff_probe"
@@ -149,6 +149,7 @@ _PIPELINE_STAGES = [
"glm_tts_dit",
"higgs_audio_v2",
"higgs_audio_v3",
"indextts2_5_talker",
"indextts2_s2mel_decoder",
"indextts2_talker",
"latent_generator",
@@ -213,18 +214,23 @@ _CLAIMED_ARCH_CASES = [
("fused_thinker_talker", "Qwen3TTSForConditionalGeneration"),
]
_REACHABLE = list(itertools.product(_STAGES, _UNCLAIMED_ARCHS)) + _CLAIMED_ARCH_CASES
# The frozen ladder can only be compared over stages it knew about. New
# adapter-owned stages are covered by their adapter tests and by the registry
# completeness checks below.
_NEW_ADAPTER_STAGE_KEYS = all_tts_stage_keys() - _LEGACY_TTS_MODEL_STAGES
_LEGACY_REACHABLE_STAGES = [stage for stage in _STAGES if stage not in _NEW_ADAPTER_STAGE_KEYS]
_REACHABLE = list(itertools.product(_LEGACY_REACHABLE_STAGES, _UNCLAIMED_ARCHS)) + _CLAIMED_ARCH_CASES
@pytest.mark.parametrize(("model_stage", "model_arch"), _REACHABLE)
def test_detection_matches_legacy_ladder(model_stage, model_arch):
"""Registry detection is identical to the ladder it replaced.
Covers every ``model_stage`` any in-tree pipeline emits (``_PIPELINE_STAGES``,
plus negatives) against every architecture no adapter claims, and each
claimed architecture against the stage keys it really ships with. Pairing a
claimed architecture with an unrelated model's stage key is excluded because
no pipeline can produce it, and is covered separately by
Covers every stage the frozen ladder knew about, plus non-TTS pipeline
stages and negatives, against every architecture no adapter claims. New
adapter-owned stages are tested by their adapter suites. Pairing a claimed
architecture with an unrelated model's stage key is excluded because no
pipeline can produce it, and is covered separately by
``test_arch_matching_is_a_fallback_not_an_override``.
"""
assert detect_tts_model_type(model_stage, model_arch) == _legacy_detect(model_stage, model_arch)
@@ -251,7 +257,7 @@ def test_arch_matching_is_a_fallback_not_an_override():
def test_stage_keys_cover_legacy_stage_set():
"""No stage key was dropped when the module constants were deleted."""
assert all_tts_stage_keys() == frozenset(_LEGACY_TTS_MODEL_STAGES)
assert _LEGACY_TTS_MODEL_STAGES <= all_tts_stage_keys()
def test_pipeline_stage_list_is_complete():
@@ -0,0 +1,47 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import pytest
from PIL import Image
from examples.offline_inference.image_to_video.image_to_video import build_image_to_video_prompt
from examples.offline_inference.text_to_image.text_to_image import build_text_to_image_prompt
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize(
("negative_prompt", "expected_negative_prompt"),
[(None, None), ("", ""), ("blurry", "blurry")],
)
def test_text_to_image_builds_canonical_prompt(
negative_prompt: str | None,
expected_negative_prompt: str | None,
) -> None:
result = build_text_to_image_prompt("a red fox", negative_prompt)
assert result["prompt"] == "a red fox"
assert result["modalities"] == ["image"]
if expected_negative_prompt is None:
assert "negative_prompt" not in result
else:
assert result["negative_prompt"] == expected_negative_prompt
def test_image_to_video_builds_canonical_prompt() -> None:
image = Image.new("RGB", (32, 16), "red")
result = build_image_to_video_prompt(
prompt="the fox turns toward the camera",
negative_prompt="flicker",
media_inputs={"image": image},
)
assert result == {
"prompt": "the fox turns toward the camera",
"modalities": ["video"],
"multi_modal_data": {"image": image},
"negative_prompt": "flicker",
}
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import pytest
import torch
from examples.offline_inference.text_to_video.text_to_video import (
_MODEL_PRESETS,
_detect_preset,
_normalize_float_tensor,
build_text_to_video_prompt,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize(
("negative_prompt", "expected_negative_prompt"),
[(None, None), ("", ""), ("blurry", "blurry")],
)
def test_text_to_video_builds_canonical_prompt(
negative_prompt: str | None,
expected_negative_prompt: str | None,
) -> None:
result = build_text_to_video_prompt("a robot waves", negative_prompt)
assert result["prompt"] == "a robot waves"
assert result["modalities"] == ["video"]
if expected_negative_prompt is None:
assert "negative_prompt" not in result
else:
assert result["negative_prompt"] == expected_negative_prompt
@pytest.mark.parametrize(
("model", "model_class_name", "preset_name"),
[
("robbyant/lingbot-video-dense-1.3b", None, "lingbot"),
("/models/custom-checkpoint", "LingBotVideoPipeline", "lingbot"),
("Lightricks/LTX-2", "LTX2DistilledPipeline", "ltx2_distilled"),
("BestWishYsh/Helios-Distilled", None, "helios"),
("/models/cosmos-edge", "Cosmos3EdgeVFMTransformer", "cosmos3_edge"),
("/models/vace", "WanVACEPipeline", "vace"),
],
)
def test_detect_preset_is_scoped_to_model_family(
model: str,
model_class_name: str | None,
preset_name: str,
) -> None:
assert _detect_preset(model, model_class_name) is _MODEL_PRESETS[preset_name]
def test_lingbot_preset_matches_lingbot_defaults() -> None:
assert _MODEL_PRESETS["lingbot"] == {
"model_class_name": "LingBotVideoPipeline",
"height": 192,
"width": 320,
"num_frames": 9,
"num_inference_steps": 2,
"guidance_scale": 3.0,
"fps": 24,
"flow_shift": 3.0,
"output": "lingbot_video_output.mp4",
}
def test_normalize_float_tensor_preserves_zero_to_one_frames() -> None:
frames = torch.tensor([0.0, 0.25, 1.0, 1.5])
assert torch.equal(_normalize_float_tensor(frames, "zero_to_one"), torch.tensor([0.0, 0.25, 1.0, 1.0]))
def test_normalize_float_tensor_maps_negative_one_to_one_frames() -> None:
frames = torch.tensor([-1.5, -1.0, 0.0, 1.0, 1.5])
assert torch.equal(
_normalize_float_tensor(frames, "negative_one_to_one"),
torch.tensor([0.0, 0.0, 0.5, 1.0, 1.0]),
)
def test_normalize_float_tensor_uses_one_contract_for_ambiguous_frames() -> None:
frames = [torch.tensor([0.0, 0.5, 1.0]), torch.tensor([-1.0, 0.0, 1.0])]
normalized = [_normalize_float_tensor(frame, "negative_one_to_one") for frame in frames]
assert torch.equal(normalized[0], torch.tensor([0.5, 0.75, 1.0]))
assert torch.equal(normalized[1], torch.tensor([0.0, 0.5, 1.0]))
@@ -0,0 +1,108 @@
import os
import re
import subprocess
from pathlib import Path
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "examples/online_serving/text_to_speech/indextts2/run_server.sh"
README = ROOT / "examples/online_serving/text_to_speech/README.md"
MACHINE_SPECIFIC_HOME_PATH = re.compile(r"(?:/home|/Users)/[^/\s`]+/")
def run_server(
tmp_path: Path,
*,
model_version: str | None = None,
model: str | None = None,
) -> tuple[subprocess.CompletedProcess[str], Path]:
argv_file = tmp_path / "vllm.argv"
stub = tmp_path / "vllm"
stub.write_text(
'#!/bin/bash\nprintf "%s\\0" "$@" > "$VLLM_ARGV_FILE"\n',
encoding="utf-8",
)
stub.chmod(0o755)
env = os.environ.copy()
for name in ("MODEL_VERSION", "MODEL", "DEPLOY_CONFIG", "PORT"):
env.pop(name, None)
env["PATH"] = f"{tmp_path}{os.pathsep}{env['PATH']}"
env["VLLM_ARGV_FILE"] = str(argv_file)
if model_version is not None:
env["MODEL_VERSION"] = model_version
if model is not None:
env["MODEL"] = model
result = subprocess.run(
["bash", str(SCRIPT)],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
check=False,
)
return result, argv_file
def read_argv(argv_file: Path) -> list[str]:
return argv_file.read_bytes().decode().rstrip("\0").split("\0")
@pytest.mark.parametrize("model", [None, ""])
def test_version_2_5_requires_model_before_invoking_vllm(
tmp_path: Path,
model: str | None,
) -> None:
result, argv_file = run_server(tmp_path, model_version="2.5", model=model)
assert result.returncode == 2
assert "MODEL=/path/to/native/bundle" in result.stderr
assert not argv_file.exists()
def test_version_2_5_selects_deploy_config(
tmp_path: Path,
) -> None:
model = "/models/indextts-2.5"
result, argv_file = run_server(
tmp_path,
model_version="2.5",
model=model,
)
assert result.returncode == 0, result.stderr
argv = read_argv(argv_file)
assert argv[:2] == ["serve", model]
deploy_config_index = argv.index("--deploy-config") + 1
assert Path(argv[deploy_config_index]) == ROOT / "vllm_omni/deploy/indextts2_5.yaml"
@pytest.mark.parametrize("model_version", [None, "2.0"])
def test_default_and_version_2_0_use_huggingface_model(
tmp_path: Path,
model_version: str | None,
) -> None:
result, argv_file = run_server(tmp_path, model_version=model_version)
assert result.returncode == 0, result.stderr
argv = read_argv(argv_file)
assert argv[:2] == ["serve", "IndexTeam/IndexTTS-2"]
deploy_config_index = argv.index("--deploy-config") + 1
assert Path(argv[deploy_config_index]) == ROOT / "vllm_omni/deploy/indextts2.yaml"
def test_invalid_model_version_exits_before_invoking_vllm(tmp_path: Path) -> None:
result, argv_file = run_server(tmp_path, model_version="3.0")
assert result.returncode == 2
assert "MODEL_VERSION must be 2.0 or 2.5" in result.stderr
assert not argv_file.exists()
@pytest.mark.parametrize("path", [SCRIPT, README])
def test_public_example_does_not_contain_machine_specific_home_path(path: Path) -> None:
assert MACHINE_SPECIFIC_HOME_PATH.search(path.read_text(encoding="utf-8")) is None
Whitespace-only changes.
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from pathlib import Path
import yaml
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
from vllm_omni.model_executor.models.indextts2.configuration_indextts2 import (
IndexTTS2Config,
IndexTTS25Config,
)
from vllm_omni.model_executor.models.indextts2.pipeline import INDEXTTS25_PIPELINE
def test_indextts25_defaults_are_distinct_from_v2():
v2 = IndexTTS2Config()
v25 = IndexTTS25Config()
assert v2.model_type == "indextts2"
assert v2.gpt["number_text_tokens"] == 12000
assert v2.gpt["condition_num_latent"] == 32
assert v2.use_gpt_latent is True
assert v2.semantic_codec_type == "repcodec"
assert v2.vocab_size == v2.gpt["number_mel_codes"]
assert v25.model_type == "indextts2_5"
assert v25.gpt["number_text_tokens"] == 60509
assert v25.gpt["condition_num_latent"] == 32
assert v25.use_gpt_latent is False
assert v25.semantic_codec_type == "enhanced"
assert v25.semantic_codec_checkpoint == "codec.pth"
assert v25.tokenizer_file == "multilingual_zh_ja_yue_char_del.tiktoken"
assert v25.vocab_size == v25.gpt["number_mel_codes"]
def test_indextts25_explicit_overrides_win_over_defaults():
config = IndexTTS25Config(
use_gpt_latent=True,
gpt={
"number_text_tokens": 70000,
"condition_num_latent": 2,
},
vocab_size=9000,
)
assert config.use_gpt_latent is True
assert config.gpt["number_text_tokens"] == 70000
assert config.gpt["condition_num_latent"] == 2
assert config.gpt["model_dim"] == config.hidden_size
assert config.vocab_size == 9000
def test_indextts25_pipeline_is_registered_with_two_distinct_stages():
assert OMNI_PIPELINES["indextts2_5"] is INDEXTTS25_PIPELINE
assert INDEXTTS25_PIPELINE.default_deploy_config_name == "indextts2_5.yaml"
assert [stage.model_stage for stage in INDEXTTS25_PIPELINE.stages] == [
"indextts2_5_talker",
"indextts2_5_s2mel_decoder",
]
assert [stage.model_arch for stage in INDEXTTS25_PIPELINE.stages] == [
None,
"IndexTTS25S2MelDecoder",
]
assert all("tokenizer" not in stage.extras for stage in INDEXTTS25_PIPELINE.stages)
def test_indextts25_default_deploy_selects_validated_triton_backend():
deploy_path = Path(__file__).parents[4] / "vllm_omni" / "deploy" / "indextts2_5.yaml"
config = yaml.safe_load(deploy_path.read_text(encoding="utf-8"))
stage0 = next(stage for stage in config["stages"] if stage["stage_id"] == 0)
assert stage0["attention_backend"] == "TRITON_ATTN"
assert stage0["enable_chunked_prefill"] is False
def test_indextts25_deploy_configs_keep_latent_policy_in_sync():
deploy_dir = Path(__file__).parents[4] / "vllm_omni" / "deploy"
expected = {"indextts2_5.yaml": False}
for filename, use_gpt_latent in expected.items():
with open(deploy_dir / filename, encoding="utf-8") as deploy_file:
config = yaml.safe_load(deploy_file)
assert config["pipeline"] == "indextts2_5"
assert config["async_chunk"] is False
assert all("tokenizer" not in stage for stage in config["stages"])
assert [stage["hf_overrides"]["use_gpt_latent"] for stage in config["stages"]] == [
use_gpt_latent,
use_gpt_latent,
]
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
from vllm_omni.model_executor.models.indextts2.dit_cuda_graph import (
CUDAGraphDiTRunner,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _CopySpy:
def __init__(self):
self.sources = []
def copy_(self, source):
self.sources.append(source)
return self
class _GraphSpy:
def __init__(self):
self.replays = 0
def replay(self):
self.replays += 1
def test_replay_only_updates_dynamic_inputs_and_reuses_static_output():
runner = CUDAGraphDiTRunner(transformer=object())
graph = _GraphSpy()
static_x = _CopySpy()
static_c = _CopySpy()
static_pos = _CopySpy()
static_mask = _CopySpy()
static_out = object()
entry = SimpleNamespace(
graph=graph,
static_x=static_x,
static_c=static_c,
static_pos=static_pos,
static_mask=static_mask,
static_out=static_out,
)
x_in = object()
c = object()
output = runner._replay(entry, x_in=x_in, c=c)
assert static_x.sources == [x_in]
assert static_c.sources == [c]
assert static_pos.sources == []
assert static_mask.sources == []
assert graph.replays == 1
assert output is static_out
def test_graph_stats_start_empty_and_remain_bounded():
runner = CUDAGraphDiTRunner(transformer=object(), max_graphs=4)
stats = runner.stats_snapshot()
stats["hits"] = 99
assert runner.stats_snapshot() == {
"calls": 0,
"hits": 0,
"captures": 0,
"capture_failures": 0,
"evictions": 0,
"eager": 0,
"cache_size": 0,
}
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import torch
from vllm_omni.model_executor.models.indextts2 import codec as codec_module
from vllm_omni.model_executor.models.indextts2 import preprocess_utils
from vllm_omni.model_executor.models.indextts2.codec import EnhancedCodec
def test_enhanced_codec_decode_restores_hidden_size_and_doubles_time():
codec = EnhancedCodec(
codebook_size=8,
hidden_size=4,
codebook_dim=2,
vocos_dim=4,
vocos_intermediate_dim=8,
vocos_num_layers=1,
downsample_scale=2,
).eval()
decoded = codec.decode(torch.tensor([[0, 1, 2]]))
assert decoded.shape == (1, 6, 4)
def test_enhanced_codec_inference_module_does_not_allocate_unused_encoder():
codec = EnhancedCodec(
codebook_size=8,
hidden_size=4,
codebook_dim=2,
vocos_dim=4,
vocos_intermediate_dim=8,
vocos_num_layers=1,
)
parameter_names = set(dict(codec.named_parameters()))
assert not any(name.startswith("encoder.") for name in parameter_names)
assert not any(name.startswith("down.") for name in parameter_names)
assert any(name.startswith("decoder.") for name in parameter_names)
assert any(name.startswith("quantizer.") for name in parameter_names)
def test_semantic_codec_loader_selects_enhanced_checkpoint(tmp_path, monkeypatch):
checkpoint = tmp_path / "codec.pth"
checkpoint.touch()
seen = {}
class FakeCodec:
def load_checkpoint(self, path):
seen["path"] = path
def to(self, **kwargs):
seen["to"] = kwargs
return self
def eval(self):
return self
def parameters(self):
return ()
monkeypatch.setattr(codec_module, "EnhancedCodec", lambda **kwargs: FakeCodec())
preprocess_utils._semantic_codec_cache.clear()
codec = preprocess_utils.load_semantic_codec(
str(tmp_path),
{"codebook_size": 8192},
torch.device("cpu"),
codec_type="enhanced",
checkpoint_name="codec.pth",
)
assert isinstance(codec, FakeCodec)
assert seen["path"] == str(checkpoint)
assert seen["to"] == {"device": torch.device("cpu"), "dtype": torch.float32}
def test_model_asset_resolver_supports_native_checkpoints_layout(tmp_path):
checkpoint = tmp_path / "checkpoints" / "codec.pth"
checkpoint.parent.mkdir()
checkpoint.touch()
assert preprocess_utils.resolve_model_file(str(tmp_path), "codec.pth") == str(checkpoint)
@@ -0,0 +1,202 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import pytest
from vllm_omni.model_executor.models.indextts2 import prompt_utils
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _load_indextts2_offline_example():
example_path = (
Path(__file__).resolve().parents[4]
/ "examples"
/ "offline_inference"
/ "text_to_speech"
/ "indextts2"
/ "end2end.py"
)
spec = importlib.util.spec_from_file_location(
"indextts2_offline_example_test",
example_path,
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_offline_main_uses_resolved_stage0_tokenizer_file(monkeypatch, tmp_path):
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
example = _load_indextts2_offline_example()
stage0 = SimpleNamespace(engine_args=SimpleNamespace(hf_overrides={"tokenizer_file": "custom-tokenizer.tiktoken"}))
fake_omni = SimpleNamespace(
stage_configs=[stage0],
generate=lambda *args, **kwargs: [],
)
captured = {}
monkeypatch.setattr(example, "Omni", lambda **kwargs: fake_omni)
monkeypatch.setattr(example, "SamplingParams", lambda **kwargs: kwargs)
monkeypatch.setattr(
example,
"build_request",
lambda **kwargs: captured.update(kwargs) or {"additional_information": {}},
)
example.main(
SimpleNamespace(
model="/model",
model_version="2.5",
deploy_config="/deploy/custom.yaml",
use_gpt_latent=False,
stage_init_timeout=300,
seed=42,
output_dir=str(tmp_path),
text="hello",
ref_audio=None,
emo_audio=None,
emo_text=None,
emo_vector=None,
emo_alpha=None,
use_emo_text=False,
use_random=False,
lang="en",
text_normalization=True,
)
)
assert captured["tokenizer_file"] == "custom-tokenizer.tiktoken"
def test_indextts25_prompt_len_uses_three_conditioning_tokens(monkeypatch):
captured = {}
def fake_prepare(*args, **kwargs):
captured.update(kwargs)
return [11, 12, 13, 14], 0
monkeypatch.setattr(
prompt_utils,
"prepare_indextts25_text",
fake_prepare,
)
prompt_len = prompt_utils.estimate_indextts2_prefill_prompt_len(
"/model",
"hello",
model_type="indextts2_5",
lang="en",
text_normalization=False,
)
# Official prepare_gpt_inputs wraps the language-prefixed tokenizer IDs
# with start_text=0 and stop_text=1 before appending start_mel.
assert prompt_len == 3 + (4 + 2) + 1
assert captured["text_normalization"] is False
assert captured["tokenizer_file"] == INDEXTTS25_TOKENIZER_FILE
def test_indextts25_prompt_len_uses_custom_tokenizer_file(monkeypatch):
captured = {}
def fake_prepare(*args, **kwargs):
captured.update(kwargs)
return [11, 12], 0
monkeypatch.setattr(
prompt_utils,
"prepare_indextts25_text",
fake_prepare,
)
prompt_utils.estimate_indextts2_prefill_prompt_len(
"/model",
"hello",
model_type="indextts2_5",
tokenizer_file="custom-tokenizer.tiktoken",
)
assert captured["tokenizer_file"] == "custom-tokenizer.tiktoken"
def test_indextts25_prompt_len_filters_existing_text_wrapper_ids(monkeypatch):
monkeypatch.setattr(
prompt_utils,
"prepare_indextts25_text",
lambda *args, **kwargs: ([0, 58838, 1, 42, 0], 0),
)
prompt_len = prompt_utils.estimate_indextts2_prefill_prompt_len(
"/model",
"hello",
model_type="indextts2_5",
lang="en",
)
assert prompt_len == 3 + (2 + 2) + 1
def test_indextts2_prompt_len_ignores_v25_tokenizer_file(monkeypatch):
class FakeTokenizer:
def encode(self, text, *, add_special_tokens):
assert text == "hello"
assert add_special_tokens is False
return [11, 12]
def fail_prepare(*args, **kwargs):
raise AssertionError("IndexTTS 2 must not use the IndexTTS 2.5 tokenizer")
monkeypatch.setattr(
prompt_utils,
"_get_text_tokenizer",
lambda model_id_or_path: FakeTokenizer(),
)
monkeypatch.setattr(
prompt_utils,
"prepare_indextts25_text",
fail_prepare,
)
prompt_len = prompt_utils.estimate_indextts2_prefill_prompt_len(
"/model",
"hello",
model_type="indextts2",
tokenizer_file="custom.tiktoken",
)
assert prompt_len == 34 + (2 + 2) + 1
def test_indextts25_prompt_ids_preserve_placeholder_value(monkeypatch):
captured = {}
def fake_estimate(*args, **kwargs):
captured.update(kwargs)
return 10
monkeypatch.setattr(
prompt_utils,
"estimate_indextts2_prefill_prompt_len",
fake_estimate,
)
prompt_ids = prompt_utils.build_indextts2_prefill_prompt_ids(
"/model",
"hello",
model_type="indextts2_5",
lang="en",
tokenizer_file="custom-tokenizer.tiktoken",
placeholder_token_id=17,
)
assert prompt_ids == [17] * 10
assert captured["tokenizer_file"] == "custom-tokenizer.tiktoken"
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from pathlib import Path
import yaml
def test_indextts25_default_recipe_enables_validated_cfm_batching():
recipe_path = Path(__file__).parents[4] / "vllm_omni" / "deploy" / "indextts2_5.yaml"
recipe = yaml.safe_load(recipe_path.read_text(encoding="utf-8"))
stage1 = next(stage for stage in recipe["stages"] if stage["stage_id"] == 1)
assert stage1["hf_overrides"]["s2mel_cfm_batch_size"] == 4
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.stage_input_processors.indextts2 import (
STOP_MEL_TOKEN,
talker2s2mel_full_payload,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_no_latent_full_payload_uses_sampled_output_ids_and_strips_stop():
request = SimpleNamespace(
request_id="req-1",
output_token_ids=[17, 23, STOP_MEL_TOKEN],
sampling_params=SimpleNamespace(seed=42),
)
payload = {
"meta": {
"use_gpt_latent": False,
"S_ref": torch.ones(1, 4),
"ref_mel": torch.ones(1, 80, 3),
"style": torch.ones(1, 192),
}
}
result = talker2s2mel_full_payload(None, payload, request)
assert result is not None
assert result["mel_codes"].tolist() == [[17, 23]]
assert result["code_lens"].tolist() == [2]
assert result["use_gpt_latent"] is False
assert result["seed"] == 42
def test_no_latent_full_payload_rejects_legacy_payload_codes():
request = SimpleNamespace(
request_id="legacy",
output_token_ids=[],
sampling_params=SimpleNamespace(seed=None),
)
payload = {
"codes": {"mel": torch.tensor([31, 37, STOP_MEL_TOKEN])},
"meta": {"use_gpt_latent": False},
}
with pytest.raises(ValueError, match="no completed output_token_ids"):
talker2s2mel_full_payload(None, payload, request)
@@ -0,0 +1,476 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm.config import CompilationMode
from vllm_omni.model_executor.models.indextts2.indextts2_talker import (
IndexTTS2TalkerForConditionalGeneration,
_normalize_indextts_checkpoint_key,
_require_exact_indextts_prefill_span,
resolve_indextts_conditioning_policy,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_v25_conditioning_policy():
policy = resolve_indextts_conditioning_policy("indextts2_5")
assert policy.prefix_tokens == 3
assert policy.uses_conformer_perceiver is False
assert policy.uses_campplus_projection is True
assert policy.uses_language_embedding is True
def test_v2_conditioning_policy_is_unchanged():
policy = resolve_indextts_conditioning_policy("indextts2")
assert policy.prefix_tokens == 34
assert policy.uses_conformer_perceiver is True
assert policy.uses_campplus_projection is False
assert policy.uses_language_embedding is False
def test_v25_text_tokens_wrap_language_prefixed_ids_with_start_and_stop(
monkeypatch,
):
import vllm_omni.model_executor.models.indextts2.text_processing_v2_5 as text_processing
monkeypatch.setattr(
text_processing,
"prepare_indextts25_text",
lambda *args, **kwargs: ([58838, 42], 7),
)
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
talker.conditioning_policy = resolve_indextts_conditioning_policy("indextts2_5")
talker.model_path = "/model"
talker.config = SimpleNamespace(tokenizer_file="tokenizer.tiktoken")
token_ids, language_id = talker._tokenize_text(
"hello",
torch.device("cpu"),
lang="ja",
)
assert token_ids.tolist() == [[0, 58838, 42, 1]]
assert language_id.tolist() == [7]
def test_v25_text_tokens_filter_existing_start_and_stop_ids(monkeypatch):
import vllm_omni.model_executor.models.indextts2.text_processing_v2_5 as text_processing
monkeypatch.setattr(
text_processing,
"prepare_indextts25_text",
lambda *args, **kwargs: ([0, 58838, 1, 42, 0], 7),
)
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
talker.conditioning_policy = resolve_indextts_conditioning_policy("indextts2_5")
talker.model_path = "/model"
talker.config = SimpleNamespace(tokenizer_file="tokenizer.tiktoken")
token_ids, language_id = talker._tokenize_text(
"hello",
torch.device("cpu"),
lang="en",
)
assert token_ids.tolist() == [[0, 58838, 42, 1]]
assert language_id.tolist() == [7]
def test_native_fsdp_checkpoint_keys_are_normalized_at_every_nesting_level():
assert _normalize_indextts_checkpoint_key("_fsdp_wrapped_module.spk_emb_proj.weight") == "spk_emb_proj.weight"
assert (
_normalize_indextts_checkpoint_key("_fsdp_wrapped_module.mel_embedding._fsdp_wrapped_module.weight")
== "mel_embedding.weight"
)
def test_prefill_span_mismatch_fails_instead_of_padding_or_truncating():
for actual_span_len in (9, 11):
with pytest.raises(
ValueError,
match="model_type=indextts2_5.*lang=es.*text_token_count=6",
):
_require_exact_indextts_prefill_span(
actual_span_len=actual_span_len,
scheduled_span_len=10,
model_type="indextts2_5",
lang="es",
text_token_count=6,
)
def test_matching_prefill_span_is_accepted():
_require_exact_indextts_prefill_span(
actual_span_len=10,
scheduled_span_len=10,
model_type="indextts2_5",
lang="en",
text_token_count=6,
)
def _make_minimal_talker(
monkeypatch,
*,
model_type,
use_gpt_latent,
):
import vllm_omni.model_executor.models.indextts2.indextts2_talker as talker_module
seen = {}
def fake_parallel_lm_head(vocab_size, hidden_size, *, bias=False, **_kwargs):
seen["bias"] = bias
return torch.nn.Linear(hidden_size, vocab_size, bias=bias)
monkeypatch.setattr(talker_module, "ParallelLMHead", fake_parallel_lm_head)
monkeypatch.setattr(
talker_module,
"make_layers",
lambda *_args, **_kwargs: (
0,
1,
torch.nn.ModuleList([torch.nn.Linear(8, 8)]),
),
)
monkeypatch.setattr(
talker_module,
"get_pp_group",
lambda: SimpleNamespace(is_last_rank=lambda: True),
)
monkeypatch.setattr(talker_module, "get_speaker_cache", object)
monkeypatch.setattr(
talker_module,
"LogitsProcessor",
lambda *_args, **_kwargs: object(),
)
monkeypatch.setattr(
talker_module,
"ConformerEncoder",
lambda **_kwargs: torch.nn.Linear(1, 1),
)
monkeypatch.setattr(
talker_module,
"PerceiverResampler",
lambda *_args, **_kwargs: torch.nn.Linear(1, 1),
)
gpt = {
"model_dim": 8,
"layers": 0,
"heads": 1,
"max_mel_tokens": 4,
"max_text_tokens": 4,
"number_mel_codes": 16,
"start_mel_token": 14,
"stop_mel_token": 15,
"number_text_tokens": 32,
"condition_num_latent": 1,
"condition_module": {},
"emo_condition_module": {},
}
config = SimpleNamespace(
gpt=gpt,
model_type=model_type,
use_gpt_latent=use_gpt_latent,
gpt_checkpoint="gpt.pth",
)
vllm_config = SimpleNamespace(
model_config=SimpleNamespace(model="/model", hf_config=config),
cache_config=None,
quant_config=None,
compilation_config=SimpleNamespace(mode=CompilationMode.NONE),
)
return (
IndexTTS2TalkerForConditionalGeneration(vllm_config=vllm_config),
seen,
)
@pytest.mark.parametrize(
("model_type", "use_gpt_latent"),
[
("indextts2", True),
("indextts2_5", False),
],
)
def test_mel_head_preserves_official_checkpoint_bias_and_hidden_payload_policy(
monkeypatch,
model_type,
use_gpt_latent,
):
talker, seen = _make_minimal_talker(
monkeypatch,
model_type=model_type,
use_gpt_latent=use_gpt_latent,
)
assert seen["bias"] is True
assert talker.mel_head.bias is not None
assert talker.omni_pooler_payload_include_hidden is use_gpt_latent
assert talker.omni_payload_at_request_end is (model_type == "indextts2_5")
def test_v2_load_weights_loads_exact_mel_head_bias_parameter(monkeypatch):
import vllm_omni.model_executor.models.indextts2.indextts2_talker as talker_module
talker, _ = _make_minimal_talker(
monkeypatch,
model_type="indextts2",
use_gpt_latent=True,
)
checkpoint = {
name: parameter.detach().clone() for name, parameter in talker.named_parameters(remove_duplicate=False)
}
expected_bias = torch.arange(
talker.number_mel_codes,
dtype=talker.mel_head.bias.dtype,
)
checkpoint["mel_head.bias"] = expected_bias
with torch.no_grad():
talker.mel_head.bias.zero_()
monkeypatch.setattr(
talker_module,
"resolve_model_file",
lambda *_args, **_kwargs: "/model/gpt.pth",
)
monkeypatch.setattr(
talker_module.torch,
"load",
lambda *_args, **_kwargs: checkpoint,
)
monkeypatch.setattr(
talker_module,
"is_pp_missing_parameter",
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(
talker,
"_warmup_external_models",
lambda _device: None,
)
loaded = talker.load_weights([])
assert "mel_head.bias" in loaded
torch.testing.assert_close(talker.mel_head.bias, expected_bias)
def test_compute_logits_passes_mel_head_bias_to_logits_processor():
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
torch.nn.Module.__init__(talker)
talker.mel_head = torch.nn.Linear(3, 4, bias=True)
hidden_states = torch.zeros(1, 3)
seen = {}
def fake_logits_processor(head, hidden, **kwargs):
seen["head"] = head
seen["hidden"] = hidden
seen.update(kwargs)
return hidden
talker.logits_processor = fake_logits_processor
result = talker.compute_logits(hidden_states)
assert result is hidden_states
assert seen["head"] is talker.mel_head
assert seen["hidden"] is hidden_states
assert seen["embedding_bias"] is talker.mel_head.bias
def _make_cached_v25_prefill_talker(monkeypatch):
talker, _ = _make_minimal_talker(
monkeypatch,
model_type="indextts2_5",
use_gpt_latent=False,
)
talker._speaker_cache = SimpleNamespace(
make_cache_key=lambda *_args: "cached-speaker",
get=lambda _key: {
"S_ref": torch.ones(1, 3, 8),
"ref_mel": torch.ones(1, 80, 3),
"style": torch.ones(1, 192),
"spk_cond_emb": torch.ones(1, 3, 8),
},
)
monkeypatch.setattr(
talker,
"_compute_emotion_vector",
lambda **_kwargs: torch.zeros(1, talker.model_dim),
)
monkeypatch.setattr(
talker,
"_tokenize_text",
lambda *_args, **_kwargs: (torch.tensor([[1]]), None),
)
return talker
def test_prefill_preserves_duration_factor_in_metadata(monkeypatch):
talker = _make_cached_v25_prefill_talker(monkeypatch)
_, _, info_update = talker._preprocess_prefill(
torch.zeros(5, dtype=torch.long),
None,
{
"text": ["hello"],
"voice": ["/speaker.wav"],
"voice_name": ["cached"],
"duration_factor": [0.5],
},
span_len=5,
)
assert info_update["meta"]["duration_factor"] == 0.5
@pytest.mark.parametrize("duration_factor", [0.49, 2.01])
def test_prefill_rejects_duration_factor_outside_supported_range(monkeypatch, duration_factor):
talker = _make_cached_v25_prefill_talker(monkeypatch)
with pytest.raises(
ValueError,
match="^IndexTTS 2.5 duration_factor must be between 0.5 and 2.0$",
):
talker._preprocess_prefill(
torch.zeros(5, dtype=torch.long),
None,
{
"text": ["hello"],
"voice": ["/speaker.wav"],
"voice_name": ["cached"],
"duration_factor": [duration_factor],
},
span_len=5,
)
def test_no_latent_sampled_token_payload_emits_only_first_step_metadata():
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
talker.use_gpt_latent = False
hidden = torch.zeros(1, 8)
info = {
"codes": {"mel": torch.tensor([17])},
"meta": {
"mel_code_count": 1,
"S_ref": torch.ones(1, 4),
"ref_mel": torch.ones(1, 80, 3),
"style": torch.ones(1, 192),
"duration_factor": 0.5,
},
}
first_decode_output = talker.make_omni_output(
hidden,
model_intermediate_buffer=[info],
)
assert "codes" not in first_decode_output.multimodal_outputs
assert "hidden_states" not in first_decode_output.multimodal_outputs
assert first_decode_output.multimodal_outputs["meta"][0]["S_ref"] is info["meta"]["S_ref"]
assert first_decode_output.multimodal_outputs["meta"][0]["duration_factor"] == 0.5
later = talker.make_omni_output(
hidden,
model_intermediate_buffer=[
{
"codes": {"mel": torch.tensor([23])},
"meta": {"mel_code_count": 2},
}
],
)
assert later.multimodal_outputs == {"meta": [{"use_gpt_latent": False}]}
def test_first_decode_payload_defaults_missing_duration_factor_to_one():
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
talker.use_gpt_latent = False
hidden = torch.zeros(1, 8)
info = {
"codes": {"mel": torch.tensor([17])},
"meta": {
"mel_code_count": 1,
"S_ref": torch.ones(1, 4),
"ref_mel": torch.ones(1, 80, 3),
"style": torch.ones(1, 192),
},
}
output = talker.make_omni_output(
hidden,
model_intermediate_buffer=[info],
)
assert output.multimodal_outputs["meta"][0]["duration_factor"] == 1.0
def test_latent_sampled_token_payload_keeps_aligned_gpu_deltas():
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
talker.use_gpt_latent = True
hidden = torch.zeros(1, 8)
info = {
"codes": {"mel": torch.tensor([17])},
"meta": {
"mel_code_count": 1,
"latent_acc": torch.arange(8, dtype=torch.float32).reshape(1, 8),
"duration_factor": 1.0,
},
}
output = talker.make_omni_output(
hidden,
model_intermediate_buffer=[info],
)
assert output.multimodal_outputs["codes"]["mel"][0].tolist() == [[17]]
assert output.multimodal_outputs["hidden_states"]["latent"][0].tolist() == [list(range(8))]
def test_v25_default_emotion_reuses_speaker_w2v_features(monkeypatch):
talker = object.__new__(IndexTTS2TalkerForConditionalGeneration)
speaker_features = torch.zeros(1, 4, 1024)
expected = torch.ones(1, 1280)
seen = {}
def fake_merge(spk_cond_emb, emo_cond_emb, alpha):
seen["spk"] = spk_cond_emb
seen["emo"] = emo_cond_emb
seen["alpha"] = alpha
return expected
monkeypatch.setattr(talker, "_merge_emovec", fake_merge)
result = talker._compute_emotion_vector(
wav_16k=None,
speaker_audio_path="/speaker.wav",
emo_audio_path=None,
emo_voice_name=None,
main_text="hello",
use_emo_text=False,
emo_text=None,
emo_vector=None,
emo_alpha=0.25,
use_random=False,
style=torch.zeros(1, 192),
spk_cond_emb=speaker_features,
w2v_model=None,
w2v_proc=None,
device=torch.device("cpu"),
)
assert result is expected
assert seen == {
"spk": speaker_features,
"emo": speaker_features,
"alpha": 1.0,
}
@@ -0,0 +1,244 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import builtins
import pytest
from vllm_omni.model_executor.models.indextts2 import text_processing_v2_5
from vllm_omni.model_executor.models.indextts2.text_processing_v2_5 import (
apply_pronunciation_annotations,
clean_indextts25_text,
prepare_indextts25_text,
)
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_SPECIAL_TOKENS,
INDEXTTS25_VOCAB_SIZE,
LANGUAGE_DICT,
lang_to_token,
normalize_language_code,
resolve_indextts25_tokenizer_file,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_special_token_layout_matches_official_checkpoint_vocab():
assert len(INDEXTTS25_SPECIAL_TOKENS) == 1673
assert len(set(INDEXTTS25_SPECIAL_TOKENS)) == 1673
assert 58836 + len(INDEXTTS25_SPECIAL_TOKENS) == INDEXTTS25_VOCAB_SIZE
assert INDEXTTS25_VOCAB_SIZE == 60509
def test_pronunciation_annotations_select_english_and_chinese_markers():
assert apply_pronunciation_annotations("<going|G OW1 . IH0 NG>") == (
"<|SPECIAL_TOKEN_1|>G OW1 . IH0 NG<|SPECIAL_TOKEN_1|>"
)
assert apply_pronunciation_annotations("<行|xing2>") == ("<|SPECIAL_TOKEN_2|>XING2<|SPECIAL_TOKEN_2|>")
def test_prepare_text_applies_language_prefix_case_and_annotation(monkeypatch):
captured = {}
def fake_encode(text, *, model_dir, tokenizer_file):
captured["text"] = text
captured["model_dir"] = model_dir
captured["tokenizer_file"] = tokenizer_file
return [7, 8, 9]
monkeypatch.setattr(text_processing_v2_5, "encode_indextts25_text", fake_encode)
token_ids, lang_id = prepare_indextts25_text(
"HELLO <行|xing2>",
lang="zh",
model_dir="/model",
text_normalization=False,
)
assert token_ids == [7, 8, 9]
assert lang_id == LANGUAGE_DICT["zh"]
assert captured == {
"text": "<|zh|> hello <|SPECIAL_TOKEN_2|>XING2<|SPECIAL_TOKEN_2|>",
"model_dir": "/model",
"tokenizer_file": "multilingual_zh_ja_yue_char_del.tiktoken",
}
def test_mandarin_alias_is_an_intentional_vllm_omni_convenience():
assert normalize_language_code("Mandarin") == "zh"
assert lang_to_token("mandarin") == LANGUAGE_DICT["zh"]
def test_invalid_language_does_not_fall_back_to_common():
with pytest.raises(ValueError, match="Unsupported IndexTTS 2.5 language"):
lang_to_token("xx-invalid")
def test_zhen_uses_mixed_normalization():
calls = []
def fake_mixed_normalizer(text):
calls.append(text)
return "MIXED"
original = text_processing_v2_5._normalize_zh_or_en
text_processing_v2_5._normalize_zh_or_en = fake_mixed_normalizer
try:
result = text_processing_v2_5._normalize_with_official_backend(
"中文 and English",
"zhen",
)
finally:
text_processing_v2_5._normalize_zh_or_en = original
assert calls == ["中文 and English"]
assert result == "MIXED"
def test_spanish_normalization_does_not_hide_broken_dependencies(monkeypatch):
original_import = builtins.__import__
def controlled_import(name, *args, **kwargs):
if name == "nemo_text_processing.text_normalization.normalize":
raise RuntimeError("broken NeMo grammar")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", controlled_import)
with pytest.raises(RuntimeError, match="broken NeMo grammar"):
text_processing_v2_5._normalize_with_official_backend("hola", "es")
def test_zhen_keeps_literal_prefix_and_uses_common_embedding(monkeypatch):
captured = {}
def fake_encode(text, *, model_dir, tokenizer_file):
captured["text"] = text
return [7, 8, 9]
monkeypatch.setattr(text_processing_v2_5, "encode_indextts25_text", fake_encode)
token_ids, lang_id = prepare_indextts25_text(
"HELLO 中文",
lang="zhen",
model_dir="/model",
text_normalization=False,
)
assert token_ids == [7, 8, 9]
assert captured["text"] == "<|zhen|> hello 中文"
assert lang_id == LANGUAGE_DICT["common"]
assert "<|zhen|>" not in INDEXTTS25_SPECIAL_TOKENS
assert "<|common|>" not in INDEXTTS25_SPECIAL_TOKENS
@pytest.mark.parametrize(
("text", "expected"),
[
(",,,", "…"),
("测试,,,结束", "测试…结束"),
],
)
def test_repeated_full_width_commas_use_longest_first_cleanup(text, expected):
assert clean_indextts25_text(text) == expected
def test_default_text_normalizer_is_shared_by_prompt_and_talker(monkeypatch):
calls = []
def fake_normalizer(text, lang):
calls.append((text, lang))
return "twenty five percent"
monkeypatch.setattr(
text_processing_v2_5,
"_normalize_with_official_backend",
fake_normalizer,
)
result = text_processing_v2_5.normalize_indextts25_text(
"25%",
lang="en",
text_normalization=True,
)
assert calls == [("25%", "en")]
assert result == "twenty five percent"
def test_mixed_text_uses_official_content_routing_and_protection(monkeypatch):
calls = []
class FakeNormalizer:
def __init__(self, lang):
self.lang = lang
def normalize(self, text):
calls.append((self.lang, text))
return text.replace("<H>", " <H> ").replace("5", "five")
monkeypatch.setattr(
text_processing_v2_5,
"_load_wetext_normalizer",
lambda lang: FakeNormalizer(lang),
)
result = text_processing_v2_5._normalize_with_official_backend(
"GPT-5-nano 中文 xuan4",
"en",
)
assert calls[0][0] == "zh"
assert result == "GPT-five-nano 中文 XVAN4"
def test_tokenizer_resolves_native_checkpoints_subdirectory(tmp_path):
tokenizer = tmp_path / "checkpoints" / "multilingual_zh_ja_yue_char_del.tiktoken"
tokenizer.parent.mkdir()
tokenizer.touch()
assert resolve_indextts25_tokenizer_file(str(tmp_path)) == str(tokenizer)
@pytest.mark.parametrize(
("lang", "text", "expected"),
[
(
"es",
"En la actualidad se continúa utilizando una corona abierta.",
"<|es|> EN LA ACTUALIDAD SE CONTINÚA UTILIZANDO UNA CORONA ABIERTA.",
),
(
"ar",
"فكلُّها خياراتٌ اتَّخذتَها بنفسِك.",
"<|ar|> فكلُّها خياراتٌ اتَّخذتَها بنفسِك.",
),
],
)
def test_official_spanish_and_arabic_cases_preserve_expected_text_contract(
monkeypatch,
lang,
text,
expected,
):
captured = {}
def fake_encode(value, *, model_dir, tokenizer_file):
captured["text"] = value
return [1]
monkeypatch.setattr(
text_processing_v2_5,
"encode_indextts25_text",
fake_encode,
)
_, lang_id = prepare_indextts25_text(
text,
lang=lang,
model_dir="/model",
text_normalization=False,
)
assert captured["text"] == expected
assert lang_id == LANGUAGE_DICT[lang]
@@ -0,0 +1,196 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.models.indextts2.indextts2_s2mel_decoder import (
IndexTTS2S2MelDecoder,
)
from vllm_omni.model_executor.models.indextts2.s2mel.modules.wavenet import WN
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_wavenet() -> WN:
torch.manual_seed(0)
return WN(
hidden_channels=2,
kernel_size=5,
dilation_rate=1,
n_layers=8,
gin_channels=2,
p_dropout=0.0,
causal=False,
).eval()
def _sequence_mask(lengths: torch.Tensor, max_length: int) -> torch.Tensor:
positions = torch.arange(max_length, device=lengths.device)
return (positions.unsqueeze(0) < lengths.unsqueeze(1)).unsqueeze(1)
def _singleton_outputs(
wavenet: WN,
inputs: torch.Tensor,
lengths: list[int],
conditioning: torch.Tensor,
) -> list[torch.Tensor]:
# This is the independent oracle: every request ends at the physical tensor
# boundary and therefore uses SConv1d's original reflect-padding behavior.
wavenet._assume_full_mask = True
return [
wavenet(
inputs[row : row + 1, :, :length],
torch.ones(1, 1, length, dtype=torch.bool),
g=conditioning[row : row + 1],
)
for row, length in enumerate(lengths)
]
def test_ragged_wavenet_matches_original_singleton_reflect_boundaries():
wavenet = _make_wavenet()
lengths = [6, 5, 4, 3]
inputs = torch.randn(len(lengths), 2, max(lengths))
conditioning = torch.randn(len(lengths), 2, 1)
expected = _singleton_outputs(wavenet, inputs, lengths, conditioning)
mask = _sequence_mask(torch.tensor(lengths), inputs.shape[-1])
wavenet._assume_full_mask = False
# Keep deliberately non-zero invalid tails. The first WaveNet layer sees
# transformer output before the recurrent mask has cleared those values.
actual = wavenet(inputs, mask, g=conditioning)
for row, (length, singleton) in enumerate(zip(lengths, expected)):
torch.testing.assert_close(
actual[row : row + 1, :, :length],
singleton,
rtol=1e-5,
atol=1e-6,
)
def test_wavenet_exposes_minimum_safe_ragged_length():
# kernel=5, dilation=1 has two right-reflect ghost positions, so a
# logical row must contain at least three frames before it can use the
# vectorized ragged path. Shorter rows stay on original singleton SConv1d.
assert _make_wavenet().ragged_min_length == 3
def test_cfm_grouping_isolates_rows_too_short_for_logical_reflect():
groups = IndexTTS2S2MelDecoder._cfm_group_indices(
sorted_indices=[0, 1, 2, 3, 4],
total_lengths=[0, 1, 2, 3, 6],
max_group_size=2,
min_ragged_length=3,
)
assert groups == [[0], [1], [2], [3, 4]]
def test_ragged_wavenet_preserves_cfg_cond_uncond_row_order():
wavenet = _make_wavenet()
base_lengths = [3, 6]
conditional = torch.randn(2, 2, 6)
unconditional = torch.randn(2, 2, 6)
cfg_inputs = torch.cat([conditional, unconditional], dim=0)
cfg_lengths = base_lengths + base_lengths
cfg_conditioning = torch.randn(4, 2, 1)
expected = _singleton_outputs(
wavenet,
cfg_inputs,
cfg_lengths,
cfg_conditioning,
)
mask = _sequence_mask(torch.tensor(cfg_lengths), cfg_inputs.shape[-1])
wavenet._assume_full_mask = False
actual = wavenet(cfg_inputs, mask, g=cfg_conditioning)
for row, (length, singleton) in enumerate(zip(cfg_lengths, expected)):
torch.testing.assert_close(
actual[row : row + 1, :, :length],
singleton,
rtol=1e-5,
atol=1e-6,
)
def test_full_mask_wavenet_path_is_bit_exact_with_original_path():
wavenet = _make_wavenet()
inputs = torch.randn(3, 2, 6)
mask = torch.ones(3, 1, 6, dtype=torch.bool)
conditioning = torch.randn(3, 2, 1)
if hasattr(wavenet, "_assume_full_mask"):
del wavenet._assume_full_mask
expected = wavenet(inputs, mask, g=conditioning)
wavenet._assume_full_mask = True
actual = wavenet(inputs, mask, g=conditioning)
assert torch.equal(actual, expected)
@pytest.mark.skipif(not hasattr(torch, "compile"), reason="torch.compile unavailable")
def test_ragged_wavenet_is_one_dynamic_fullgraph():
wavenet = _make_wavenet()
lengths = [3, 6]
inputs = torch.randn(2, 2, 6)
conditioning = torch.randn(2, 2, 1)
mask = _sequence_mask(torch.tensor(lengths), inputs.shape[-1])
expected = _singleton_outputs(wavenet, inputs, lengths, conditioning)
wavenet._assume_full_mask = False
compiled = torch.compile(
wavenet,
backend="eager",
fullgraph=True,
dynamic=True,
)
actual = compiled(inputs, mask, g=conditioning)
for row, (length, singleton) in enumerate(zip(lengths, expected)):
torch.testing.assert_close(
actual[row : row + 1, :, :length],
singleton,
rtol=1e-5,
atol=1e-6,
)
def test_decoder_full_mask_switch_also_controls_wavenet_boundary_path():
decoder = object.__new__(IndexTTS2S2MelDecoder)
attention_layers = [SimpleNamespace(attention=SimpleNamespace()) for _ in range(2)]
estimator = SimpleNamespace(
transformer=SimpleNamespace(layers=attention_layers),
wavenet=SimpleNamespace(),
)
decoder._set_dit_full_mask_fast_path(estimator, enabled=False)
assert all(layer.attention._assume_full_mask is False for layer in attention_layers)
assert estimator.wavenet._assume_full_mask is False
decoder._set_dit_full_mask_fast_path(estimator, enabled=True)
assert all(layer.attention._assume_full_mask is True for layer in attention_layers)
assert estimator.wavenet._assume_full_mask is True
def test_decoder_full_mask_switch_unwraps_compiled_estimator():
decoder = object.__new__(IndexTTS2S2MelDecoder)
attention_layers = [SimpleNamespace(attention=SimpleNamespace()) for _ in range(2)]
eager_estimator = SimpleNamespace(
transformer=SimpleNamespace(layers=attention_layers),
wavenet=SimpleNamespace(),
)
compiled_estimator = SimpleNamespace(_orig_mod=eager_estimator)
decoder._set_dit_full_mask_fast_path(compiled_estimator, enabled=False)
assert all(layer.attention._assume_full_mask is False for layer in attention_layers)
assert eager_estimator.wavenet._assume_full_mask is False
@@ -57,6 +57,7 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]:
platforms_mock = mocker.MagicMock()
platforms_mock.current_omni_platform.supports_torch_inductor.return_value = False
platforms_mock.current_omni_platform.is_npu.return_value = False
platforms_mock.current_omni_platform.is_xpu.return_value = False
logger_mock = mocker.MagicMock()
logger_mock.init_logger = lambda name: mocker.MagicMock()
@@ -284,6 +285,28 @@ class TestCodePredictorDtypeAlignment:
predictor._setup_compile()
assert predictor._model_dtype == torch.float16
def test_setup_compile_uses_eager_on_xpu(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""XPU should avoid the torch.compile path used by CUDA."""
_, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker, max_num_seqs=2)
predictor = code_predictor_wrapper(
vllm_config=vllm_config,
config=cp_config,
talker_config=talker_config,
)
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(common_mod.current_omni_platform, "supports_torch_inductor", return_value=True)
mocker.patch.object(common_mod.current_omni_platform, "is_xpu", return_value=True)
compile_mock = mocker.patch.object(common_mod.torch, "compile")
predictor._setup_compile()
compile_mock.assert_not_called()
assert predictor._compiled_model_fwd == predictor.model.forward
def test_forward_with_mismatched_input_dtype(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""forward() should not crash when inputs are float32 but model is float16."""
_, _, code_predictor_wrapper, _, _ = loaded_target_classes
@@ -116,6 +116,7 @@ def test_strip_stop_token_all_stop():
def _conditioning_tensors():
return {
"use_gpt_latent": True,
"S_ref": torch.randn(1, 3, LATENT_DIM),
"ref_mel": torch.randn(1, 80, 5),
"style": torch.randn(1, 4),
@@ -143,6 +144,8 @@ def test_talker2s2mel_full_payload_flat_keys_builds_s2mel_contract():
"meta.S_ref": cond["S_ref"],
"meta.ref_mel": cond["ref_mel"],
"meta.style": cond["style"],
"meta.use_gpt_latent": True,
"meta.duration_factor": 0.5,
}
result = talker2s2mel_full_payload(None, payload, SimpleNamespace(request_id="r-flat"))
@@ -156,6 +159,45 @@ def test_talker2s2mel_full_payload_flat_keys_builds_s2mel_contract():
assert result["ref_mel"].device.type == "cpu"
assert result["style"].device.type == "cpu"
assert result["S_ref"].data_ptr() != cond["S_ref"].data_ptr()
assert result["duration_factor"] == 0.5
def test_talker2s2mel_full_payload_defaults_missing_duration_factor_to_one():
cond = _conditioning_tensors()
payload = {
"codes.mel": torch.tensor([[10], [20]]),
"hidden_states.latent": torch.randn(2, LATENT_DIM),
"meta.S_ref": cond["S_ref"],
"meta.ref_mel": cond["ref_mel"],
"meta.style": cond["style"],
"meta.use_gpt_latent": True,
}
result = talker2s2mel_full_payload(None, payload, SimpleNamespace(request_id="r-default-duration"))
assert result is not None
assert result["duration_factor"] == 1.0
def test_talker2s2mel_full_payload_preserves_explicit_request_seed():
cond = _conditioning_tensors()
payload = {
"codes.mel": torch.tensor([[10], [20]]),
"hidden_states.latent": torch.randn(2, LATENT_DIM),
"meta.S_ref": cond["S_ref"],
"meta.ref_mel": cond["ref_mel"],
"meta.style": cond["style"],
"meta.use_gpt_latent": True,
}
request = SimpleNamespace(
request_id="r-seeded",
sampling_params=SimpleNamespace(seed=0),
)
result = talker2s2mel_full_payload(None, payload, request)
assert result is not None
assert result["seed"] == 0
def test_talker2s2mel_full_payload_nested_fallback_input():
@@ -206,7 +248,7 @@ def test_talker2s2mel_full_payload_trims_stop_token_and_matching_latent():
assert result["code_lens"].tolist() == [2]
def test_talker2s2mel_full_payload_crops_mel_latent_to_common_length():
def test_talker2s2mel_full_payload_rejects_mel_latent_length_mismatch():
cond = _conditioning_tensors()
payload = {
"codes.mel": torch.tensor([[1], [2], [3], [4]]),
@@ -214,14 +256,44 @@ def test_talker2s2mel_full_payload_crops_mel_latent_to_common_length():
"meta": cond,
}
result = talker2s2mel_full_payload(None, payload, SimpleNamespace(request_id="r-crop"))
with pytest.raises(ValueError, match="mel/latent length mismatch"):
talker2s2mel_full_payload(None, payload, SimpleNamespace(request_id="r-crop"))
def test_talker2s2mel_full_payload_v25_code_only_does_not_invent_latent():
cond = _conditioning_tensors()
payload = {
"meta.use_gpt_latent": False,
"meta.S_ref": cond["S_ref"],
"meta.ref_mel": cond["ref_mel"],
"meta.style": cond["style"],
}
request = SimpleNamespace(
request_id="r-v25",
output_token_ids=[3, 4, STOP_MEL_TOKEN],
)
result = talker2s2mel_full_payload(None, payload, request)
assert result is not None
assert result["mel_codes"].tolist() == [[1, 2]]
assert result["latent"].shape == (1, 2, LATENT_DIM)
assert result["mel_codes"].tolist() == [[3, 4]]
assert result["code_lens"].tolist() == [2]
assert result["use_gpt_latent"] is False
assert "latent" not in result
def test_talker2s2mel_full_payload_missing_required_fields_returns_none():
assert talker2s2mel_full_payload(None, {"hidden_states.latent": torch.randn(1, LATENT_DIM)}, None) is None
assert talker2s2mel_full_payload(None, {"codes.mel": torch.tensor([[1]])}, None) is None
def test_talker2s2mel_full_payload_latent_mode_rejects_missing_latent():
payload = {
"codes.mel": torch.tensor([[3], [4], [STOP_MEL_TOKEN]]),
"meta.use_gpt_latent": True,
}
with pytest.raises(ValueError, match="missing hidden_states.latent"):
talker2s2mel_full_payload(None, payload, SimpleNamespace(request_id="r-latent"))
def test_talker2s2mel_full_payload_missing_required_fields_raises():
with pytest.raises(ValueError, match="missing meta.use_gpt_latent"):
talker2s2mel_full_payload(None, {"hidden_states.latent": torch.randn(1, LATENT_DIM)}, None)
with pytest.raises(ValueError, match="missing meta.use_gpt_latent"):
talker2s2mel_full_payload(None, {"codes.mel": torch.tensor([[1]])}, None)
+41 -107
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
from typing import Any
import pytest
from PIL import Image
@@ -15,6 +17,7 @@ from vllm_omni.model_extras import (
build_x_to_text_prompt,
get_extra_body_params,
get_extra_output_params,
get_output_tensor_range,
get_x_to_text_model_family,
should_init_extra_args_for_non_diffusion_stages,
)
@@ -132,70 +135,11 @@ def test_lingbot_extra_registry_declares_request_params() -> None:
}
)
assert get_extra_output_params("LingBotVideoPipeline") == frozenset()
assert get_output_tensor_range("LingBotVideoPipeline") == "zero_to_one"
assert get_output_tensor_range("WanPipeline") == "negative_one_to_one"
assert should_init_extra_args_for_non_diffusion_stages("LingBotVideoPipeline") is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_lingbot_text_to_image_prompt_builder_preserves_empty_negative_prompt() -> None:
assert build_text_to_image_prompt(
"LingBotVideoPipeline",
prompt="a red fox in fresh snow",
negative_prompt="",
height=192,
width=320,
) == {
"prompt": "a red fox in fresh snow",
"modalities": ["image"],
"negative_prompt": "",
}
@pytest.mark.core_model
@pytest.mark.cpu
def test_lingbot_image_to_video_prompt_builder() -> None:
image = Image.new("RGB", (320, 192), "red")
result = build_image_to_video_prompt(
"LingBotVideoPipeline",
prompt="the fox looks toward the camera",
negative_prompt=None,
media_inputs={"image": image},
height=192,
width=320,
num_frames=9,
)
assert result == {
"prompt": "the fox looks toward the camera",
"modalities": ["video"],
"multi_modal_data": {"image": image},
"num_frames": 9,
}
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize(
"media_inputs",
[
{},
{"image": "input.png"},
{"image": [Image.new("RGB", (16, 16))]},
{"image": Image.new("RGB", (16, 16)), "video": b"video"},
],
ids=["missing", "path", "multiple", "extra-modality"],
)
def test_lingbot_image_to_video_prompt_builder_rejects_invalid_media(
media_inputs: dict[str, object],
) -> None:
with pytest.raises(ValueError, match="exactly one PIL image"):
build_image_to_video_prompt(
"LingBotVideoPipeline",
prompt="move",
negative_prompt=None,
media_inputs=media_inputs,
)
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("pipeline_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"])
@@ -342,8 +286,11 @@ def test_ming_flash_omni_declared_extra_args_route_into_sampling_params() -> Non
def test_ming_flash_omni_text_to_image_prompt_builder() -> None:
assert build_text_to_image_prompt(
"MingImagePipeline",
prompt="Please draw a cute cat.",
negative_prompt="ugly, blurry",
{
"prompt": "Please draw a cute cat.",
"modalities": ["image"],
"negative_prompt": "ugly, blurry",
},
height=512,
width=768,
) == {
@@ -360,8 +307,7 @@ def test_ming_flash_omni_text_to_image_prompt_builder() -> None:
# pipeline's 1024x1024 default still applies.
assert build_text_to_image_prompt(
"MingImagePipeline",
prompt="Draw a poster.",
negative_prompt=None,
{"prompt": "Draw a poster.", "modalities": ["image"]},
) == {
"prompt": "Draw a poster.",
"modalities": ["image"],
@@ -391,28 +337,6 @@ def test_ming_flash_omni_image_to_image_prompt_builder() -> None:
assert "negative_prompt" not in result
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("pipeline_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"])
def test_cosmos3_text_to_image_prompt_builder_selects_image_modality(pipeline_name: str) -> None:
assert build_text_to_image_prompt(
pipeline_name,
prompt="a red sports car at golden hour",
negative_prompt="blurry, distorted",
height=1024,
width=1024,
) == {
"prompt": "a red sports car at golden hour",
"modalities": ["image"],
"negative_prompt": "blurry, distorted",
}
assert build_text_to_image_prompt(
pipeline_name,
prompt="a red sports car",
negative_prompt=None,
) == {"prompt": "a red sports car", "modalities": ["image"]}
@pytest.mark.core_model
@pytest.mark.cpu
def test_audiox_extra_registry_declares_request_and_response_params() -> None:
@@ -505,8 +429,11 @@ def test_unknown_pipeline_has_empty_extra_registry() -> None:
def test_bagel_text_to_image_prompt_builder() -> None:
assert build_text_to_image_prompt(
"BagelPipeline",
prompt="a cat",
negative_prompt="blurry",
{
"prompt": "a cat",
"modalities": ["image"],
"negative_prompt": "blurry",
},
height=512,
width=768,
) == {
@@ -543,14 +470,17 @@ def test_bagel_image_to_image_prompt_builder() -> None:
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_pipeline_uses_default_text_to_image_prompt() -> None:
assert build_text_to_image_prompt(
"UnknownPipeline",
prompt="a cat",
negative_prompt=None,
height=512,
width=512,
) == {"prompt": "a cat"}
def test_unknown_pipeline_preserves_canonical_text_to_image_prompt() -> None:
canonical_prompt = {"prompt": "a cat", "modalities": ["image"]}
assert (
build_text_to_image_prompt(
"UnknownPipeline",
canonical_prompt,
height=512,
width=512,
)
is canonical_prompt
)
@pytest.mark.core_model
@@ -569,12 +499,14 @@ def test_unknown_pipeline_uses_default_image_to_image_prompt() -> None:
}
def _build_vace_prompt(media_inputs: dict[str, object], *, num_frames: int = 5) -> dict:
def _build_vace_prompt(media_inputs: dict[str, Any], *, num_frames: int = 5) -> dict:
return build_image_to_video_prompt(
"WanVACEPipeline",
prompt="a bird flying",
negative_prompt=None,
media_inputs=media_inputs,
{
"prompt": "a bird flying",
"modalities": ["video"],
"multi_modal_data": media_inputs,
},
height=16,
width=320,
num_frames=num_frames,
@@ -597,7 +529,7 @@ def _build_vace_prompt(media_inputs: dict[str, object], *, num_frames: int = 5)
],
ids=["i2v", "v2lf", "flf2v", "inpaint", "r2v"],
)
def test_vace_image_to_video_prompt_builder(media_inputs: dict[str, object]) -> None:
def test_vace_image_to_video_prompt_builder(media_inputs: dict[str, Any]) -> None:
result = _build_vace_prompt(media_inputs)
mmd = result["multi_modal_data"]
if "reference_images" in mmd:
@@ -616,7 +548,7 @@ def test_vace_image_to_video_prompt_builder(media_inputs: dict[str, object]) ->
({"control_image": Image.new("RGB", (320, 16))}, "Unsupported VACE media input"),
],
)
def test_vace_rejects_invalid_media_combinations(media_inputs: dict[str, object], message: str) -> None:
def test_vace_rejects_invalid_media_combinations(media_inputs: dict[str, Any], message: str) -> None:
with pytest.raises(ValueError, match=message):
_build_vace_prompt(media_inputs)
@@ -664,8 +596,7 @@ def test_mammothmoda2_extra_registry_declares_request_and_response_params() -> N
wrapper_prompt = build_text_to_image_prompt(
"MammothModa2ForConditionalGeneration",
prompt="a cat",
negative_prompt=None,
{"prompt": "a cat", "modalities": ["image"]},
height=256,
width=256,
)
@@ -679,8 +610,11 @@ def test_mammothmoda2_text_to_image_prompt_builder() -> None:
# prompt is ignored (MammothModa2 t2i uses CFG, not an explicit negative path).
assert build_text_to_image_prompt(
"MammothModa2DiTPipeline",
prompt="a cat",
negative_prompt="blurry",
{
"prompt": "a cat",
"modalities": ["image"],
"negative_prompt": "blurry",
},
height=512,
width=768,
) == {
@@ -0,0 +1,155 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import builtins
import importlib.util
import platform
import sys
import types
from pathlib import Path
import pytest
import tomllib
from packaging.requirements import Requirement
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
ROOT = Path(__file__).parents[1]
TEXT_PROCESSING_PATH = ROOT / "vllm_omni" / "model_executor" / "models" / "indextts2" / "text_processing_v2_5.py"
INDEXTTS2_FRONTEND_PACKAGES = {
"fugashi",
"unidic-lite",
"wetext",
"wetextprocessing",
}
INSTALL_COMMAND = "pip install 'vllm-omni[indextts2]'"
def _load_text_processing_module(monkeypatch: pytest.MonkeyPatch):
package_name = "_indextts2_optional_dependencies_test"
package = types.ModuleType(package_name)
package.__path__ = [str(TEXT_PROCESSING_PATH.parent)]
tokenizer = types.ModuleType(f"{package_name}.tokenizer_v2_5")
tokenizer.INDEXTTS25_TOKENIZER_FILE = "tokenizer.tiktoken"
tokenizer.encode_indextts25_text = lambda *args, **kwargs: []
tokenizer.lang_to_token = lambda lang: 0
tokenizer.normalize_language_code = lambda lang: lang
monkeypatch.setitem(sys.modules, package_name, package)
monkeypatch.setitem(sys.modules, tokenizer.__name__, tokenizer)
module_name = f"{package_name}.text_processing_v2_5"
spec = importlib.util.spec_from_file_location(module_name, TEXT_PROCESSING_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def _requirements_from_file(path: Path) -> list[Requirement]:
requirements = []
for raw_line in path.read_text().splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
if line.startswith(("-r ", "--requirement ")):
included = line.split(maxsplit=1)[1]
requirements.extend(_requirements_from_file(path.parent / included))
continue
requirements.append(Requirement(line))
return requirements
def _block_import(monkeypatch: pytest.MonkeyPatch, package: str) -> None:
original_import = builtins.__import__
def controlled_import(name, *args, **kwargs):
if name == package or name.startswith(f"{package}."):
raise ModuleNotFoundError(f"No module named {package!r}", name=package)
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", controlled_import)
def test_indextts2_extra_declares_platform_specific_frontend_dependencies():
project = tomllib.loads((ROOT / "pyproject.toml").read_text())
requirements = [Requirement(value) for value in project["project"]["optional-dependencies"]["indextts2"]]
assert {
(requirement.name.lower(), str(requirement.specifier), str(requirement.marker) if requirement.marker else None)
for requirement in requirements
} == {
("fugashi", ">=1.2.0", None),
("unidic-lite", ">=1.0.0", None),
("wetext", ">=0.0.9", 'sys_platform != "linux"'),
("wetextprocessing", "", 'sys_platform == "linux"'),
}
def test_common_requirements_exclude_indextts2_frontend_dependencies():
requirements = _requirements_from_file(ROOT / "requirements" / "common.txt")
assert not ({requirement.name.lower() for requirement in requirements} & INDEXTTS2_FRONTEND_PACKAGES)
def test_wetext_normalizer_constructor_error_propagates(monkeypatch):
text_processing_v2_5 = _load_text_processing_module(monkeypatch)
sentinel = RuntimeError("wetext constructor sentinel")
class FailingNormalizer:
def __init__(self, *args, **kwargs):
raise sentinel
wetext = types.ModuleType("wetext")
wetext.Normalizer = FailingNormalizer
monkeypatch.setitem(sys.modules, "wetext", wetext)
monkeypatch.setattr(platform, "system", lambda: "Darwin")
with pytest.raises(RuntimeError) as exc_info:
text_processing_v2_5._load_wetext_normalizer("zh")
assert exc_info.value is sentinel
def test_japanese_tagger_constructor_error_propagates(monkeypatch):
text_processing_v2_5 = _load_text_processing_module(monkeypatch)
sentinel = RuntimeError("fugashi constructor sentinel")
fugashi = types.ModuleType("fugashi")
def failing_tagger():
raise sentinel
fugashi.Tagger = failing_tagger
monkeypatch.setitem(sys.modules, "fugashi", fugashi)
monkeypatch.setitem(sys.modules, "unidic_lite", types.ModuleType("unidic_lite"))
with pytest.raises(RuntimeError) as exc_info:
text_processing_v2_5._load_japanese_tagger()
assert exc_info.value is sentinel
def test_missing_wetext_backend_reports_indextts2_extra(monkeypatch):
text_processing_v2_5 = _load_text_processing_module(monkeypatch)
text_processing_v2_5._load_wetext_normalizer.cache_clear()
monkeypatch.setattr(platform, "system", lambda: "Darwin")
_block_import(monkeypatch, "wetext")
with pytest.raises(RuntimeError, match="IndexTTS 2.5 text normalization") as exc_info:
text_processing_v2_5._load_wetext_normalizer("zh")
assert INSTALL_COMMAND in str(exc_info.value)
text_processing_v2_5._load_wetext_normalizer.cache_clear()
def test_missing_japanese_tagger_reports_indextts2_extra(monkeypatch):
text_processing_v2_5 = _load_text_processing_module(monkeypatch)
text_processing_v2_5._load_japanese_tagger.cache_clear()
_block_import(monkeypatch, "fugashi")
with pytest.raises(RuntimeError, match="IndexTTS 2.5 Japanese preprocessing") as exc_info:
text_processing_v2_5._load_japanese_tagger()
assert INSTALL_COMMAND in str(exc_info.value)
text_processing_v2_5._load_japanese_tagger.cache_clear()
+31
View File
@@ -1,6 +1,7 @@
import pytest
import torch
import vllm_omni.utils.mm_outputs as mm_outputs
from vllm_omni.utils.mm_outputs import partition_flat_payload, partition_payload_list
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -69,3 +70,33 @@ def test_partition_duplex_audio_transcript_metadata_to_client_mm():
assert "meta.turn_end" in client
assert "meta.native_duplex_segment_text" not in client
assert "meta.native_duplex_segment_text" in inter
def test_snapshot_mm_payload_coalesces_compatible_tensor_list(monkeypatch):
chunks = [
torch.tensor([[7]], dtype=torch.long),
torch.empty(0, 1, dtype=torch.long),
torch.tensor([[9], [10]], dtype=torch.long),
]
real_cat = torch.cat
cat_shapes = []
def recording_cat(tensors, *args, **kwargs):
cat_shapes.append([tuple(tensor.shape) for tensor in tensors])
return real_cat(tensors, *args, **kwargs)
monkeypatch.setattr(torch, "cat", recording_cat)
result = mm_outputs.snapshot_mm_payload({"codes.mel": chunks})
assert cat_shapes == [[(1, 1), (0, 1), (2, 1)]]
output_chunks = result["codes.mel"]
assert [tuple(chunk.shape) for chunk in output_chunks] == [
(1, 1),
(0, 1),
(2, 1),
]
torch.testing.assert_close(output_chunks[0], chunks[0])
torch.testing.assert_close(output_chunks[1], chunks[1])
torch.testing.assert_close(output_chunks[2], chunks[2])
assert output_chunks[0].untyped_storage().data_ptr() == output_chunks[2].untyped_storage().data_ptr()
@@ -5,6 +5,7 @@ import pytest
import torch
from vllm.v1.cudagraph_dispatcher import CUDAGraphMode
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
from vllm_omni.worker.gpu_model_runner import (
OmniGPUModelRunner,
_filter_mrope_kwargs_for_model,
@@ -691,6 +692,49 @@ def test_full_payload_output_accumulation_hook_matrix():
assert not runner._should_accumulate_full_payload_output()
def _make_request_end_payload_runner(*, enabled=True, prefix_cache=None):
runner = object.__new__(GPUARModelRunner)
runner.model = SimpleNamespace(omni_payload_at_request_end=enabled)
runner.omni_prefix_cache = prefix_cache
runner.model_config = SimpleNamespace(
model_arch="IndexTTS25TalkerForConditionalGeneration",
model_stage="indextts2_5_talker",
async_chunk=False,
final_output=False,
custom_process_next_stage_input_func="module.full_payload",
)
runner._custom_process_func = object()
runner._pending_full_payload_send = {}
runner._stage_id = 0
runner._omni_connector = object()
return runner
def test_request_end_payload_d2h_gate_requires_opt_in_and_no_prefix_cache():
assert _make_request_end_payload_runner()._should_defer_full_payload_d2h()
assert not _make_request_end_payload_runner(enabled=False)._should_defer_full_payload_d2h()
assert not _make_request_end_payload_runner(prefix_cache=object())._should_defer_full_payload_d2h()
def test_request_end_payload_suppresses_per_step_multimodal_outputs():
runner = _make_request_end_payload_runner()
def unexpected_build(_payload):
raise AssertionError("request-end payloads must stay inside the GPU accumulator")
runner._build_multimodal_outputs = unexpected_build
pooler_inter = [{"codes.mel": torch.tensor([[7]])}]
inter_stage, client = runner._build_omni_step_outputs(
pooler_inter,
pooler_inter,
defer_full_payload_d2h=True,
)
assert inter_stage is None
assert client is None
def test_sync_local_stage_payloads_retains_payload_until_request_is_active():
runner = object.__new__(OmniGPUModelRunner)
payload = {"codes": {"audio": [1, 2, 3]}}
+5 -1
View File
@@ -61,7 +61,10 @@ from vllm_omni.model_executor.models.hunyuan_image3.pipeline import (
HUNYUAN_IMAGE3_PIPELINE,
)
from vllm_omni.model_executor.models.hunyuan_video.pipeline import HUNYUAN_VIDEO_15_PIPELINE
from vllm_omni.model_executor.models.indextts2.pipeline import INDEXTTS2_PIPELINE
from vllm_omni.model_executor.models.indextts2.pipeline import (
INDEXTTS2_PIPELINE,
INDEXTTS25_PIPELINE,
)
from vllm_omni.model_executor.models.lance.pipeline import LANCE_PIPELINE
from vllm_omni.model_executor.models.mammoth_moda2.pipeline import (
MAMMOTH_MODA2_AR_PIPELINE,
@@ -164,6 +167,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"higgs_multimodal_qwen3": HIGGS_AUDIO_V3_PIPELINE,
"dynin_omni": DYNIN_OMNI_PIPELINE,
"indextts2": INDEXTTS2_PIPELINE,
"indextts2_5": INDEXTTS25_PIPELINE,
"soulxsinger_svc": SOULXSINGER_SVC_PIPELINE,
"soulxsinger_svs": SOULXSINGER_SVS_PIPELINE,
}
@@ -57,9 +57,10 @@ _FULL_PAYLOAD_INPUT_STAGES: frozenset[tuple[str, str]] = frozenset(
# audex TTA: tta thinker (Stage 0) -> XCodec1 (Stage 1, always sync
# full-payload — CNN codec decoded over the full sequence).
("AudexXCodec1", "audex_xcodec"),
# indextts2: indextts2_talker (Stage 0) -> indextts2_s2mel_decoder
# (Stage 1). Stage 1 consumes the complete mel/latent payload.
# indextts2 / indextts2_5: talker (Stage 0) -> s2mel decoder
# (Stage 1). Stage 1 consumes the complete mel/optional-latent payload.
("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"),
("IndexTTS25S2MelDecoder", "indextts2_5_s2mel_decoder"),
# dynin: token2text (Stage 0) -> token2image (Stage 1) ->
# token2audio (Stage 2). Producer wires via
# custom_process_next_stage_input_func: *_full_payload in deploy yaml.
+64
View File
@@ -0,0 +1,64 @@
async_chunk: false
trust_remote_code: true
pipeline: indextts2_5
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
codec_streaming: false
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 5000
connector_get_max_wait: 500
stages:
- stage_id: 0
max_num_seqs: 4
# Triton matches the native GPT-2 logits for this non-RoPE model.
# FlashInfer produces materially different semantic-token logits.
attention_backend: TRITON_ATTN
gpu_memory_utilization: 0.4
enable_prefix_caching: false
enable_chunked_prefill: false
skip_tokenizer_init: true
async_scheduling: true
max_num_batched_tokens: 2560
max_model_len: 2560
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
hf_overrides:
use_gpt_latent: false
# Stage 0 uses plain vLLM sampling, not the upstream default
# num_beams=3 beam search. Use upstream num_beams=1 for parity comparisons.
default_sampling_params:
temperature: 0.8
top_p: 0.8
top_k: 30
max_tokens: 1500
repetition_penalty: 10.0
stop_token_ids: [8193]
detokenize: false
- stage_id: 1
max_num_seqs: 4
gpu_memory_utilization: 0.4
enable_prefix_caching: false
skip_tokenizer_init: true
enforce_eager: true
async_scheduling: true
max_num_batched_tokens: 8192
max_model_len: 32768
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
hf_overrides:
use_gpt_latent: false
diffusion_steps: 25
s2mel_dit_bf16: true
s2mel_cfm_batch_size: 4
inference_cfg_rate: 0.7
s2mel_dit_cuda_graph: false
s2mel_dit_torch_compile: true
s2mel_dit_torch_compile_mode: default
s2mel_vocoder_torch_compile: false
default_sampling_params:
temperature: 0.0
max_tokens: 32767
+7 -5
View File
@@ -13,8 +13,8 @@
# the custom generation loop — set explicitly everywhere).
# * AR stages (thinker, talker): model-dependent. Qwen2.5-Omni runs eager
# on CUDA (thinker uses custom ops that don't trace cleanly); NPU / XPU
# platform overrides flip back to false where cudagraph is verified.
# Qwen3-Omni / Qwen3-TTS AR stages use the default (false = cudagraph on).
# overrides are documented below. Qwen3-Omni / Qwen3-TTS AR stages use the
# default (false = cudagraph on).
async_chunk: false
stages:
@@ -82,13 +82,15 @@ platforms:
xpu:
stages:
# Verified on 2x Intel Arc Pro B60. Both AR stages use cudagraphs.
# PyTorch 2.13 XPU Dynamo raises duplicate-handler assertions while
# compiling both Qwen2.5 AR stages. Keep them eager until that toolchain
# issue is fixed; stage 2 already inherits enforce_eager: true above.
- stage_id: 0
gpu_memory_utilization: 0.9
enforce_eager: false
enforce_eager: true
- stage_id: 1
gpu_memory_utilization: 0.5
enforce_eager: false
enforce_eager: true
- stage_id: 2
gpu_memory_utilization: 0.3
# Stage 2 colocates with stage 1's device on XPU.
+13 -1
View File
@@ -22,6 +22,8 @@ _ARCH_TO_MODEL_TYPE: dict[str, str] = {
"GLMTTSForConditionalGeneration": "glm_tts",
"IndexTTS2S2MelDecoder": "indextts2",
"IndexTTS2TalkerForConditionalGeneration": "indextts2",
"IndexTTS25S2MelDecoder": "indextts2_5",
"IndexTTS25TalkerForConditionalGeneration": "indextts2_5",
"OmniVoiceModel": "omnivoice",
# PersonaPlex ships an empty config.json, so create_model_config() must patch
# model_type=personaplex from the arch name or the staged pipeline can't load
@@ -44,6 +46,7 @@ def _register_omni_hf_configs() -> None:
from vllm_omni.model_executor.models.indextts2.configuration_indextts2 import (
IndexTTS2Config,
IndexTTS25Config,
)
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import (
MingDenseConfig,
@@ -79,6 +82,7 @@ def _register_omni_hf_configs() -> None:
("dense", MingDenseConfig),
("bailingmm", MingMoeConfig),
("indextts2", IndexTTS2Config),
("indextts2_5", IndexTTS25Config),
("moss_tts_local", MossTTSLocalConfig),
("moss_tts_realtime", MossTTSRealtimeConfig),
("qwen3_tts", Qwen3TTSConfig),
@@ -250,7 +254,15 @@ class OmniEngineArgs(EngineArgs):
if config_dict.get("model_type"):
return # config.json already has model_type, no patching needed
except Exception:
return # can't load config, let vLLM handle the error
# The official IndexTTS 2.5 bundle has no HuggingFace config.json.
# Keep this exception model-scoped so other loader failures retain
# vLLM's normal error path.
if model_type != "indextts2_5" or not os.path.isdir(self.model):
return
config_path = os.path.join(self.model, "config.json")
if os.path.lexists(config_path):
return
config_dict = {}
# Create a temp dir with a patched config.json
temp_dir = tempfile.mkdtemp(prefix="omni_hf_config_")
+21 -1
View File
@@ -20,6 +20,7 @@ from dataclasses import dataclass, fields, replace
from typing import Any, Literal, cast
from vllm.logger import init_logger
from vllm.renderers import BaseRenderer
from vllm.sampling_params import SamplingParams
from vllm.tokenizers import cached_tokenizer_from_config
from vllm.usage.usage_lib import UsageContext
@@ -1119,11 +1120,30 @@ def build_llm_stage_output_processor(
)
class _TokenOnlyRenderer(BaseRenderer):
"""Renderer for stages that explicitly skip tokenizer initialization."""
def render_messages(self, messages, params):
raise ValueError(
"Chat messages are unavailable when skip_tokenizer_init=True; submit prompt_token_ids or prompt_embeds"
)
def _build_token_only_renderer(stage_vllm_config: Any) -> BaseRenderer:
return _TokenOnlyRenderer(stage_vllm_config, tokenizer=None)
def build_stage0_input_processor(stage_vllm_config: Any) -> InputProcessor:
"""Build the shared stage-0 input processor."""
patch_generation_config_if_needed(stage_vllm_config.model_config)
input_processor = InputProcessor(vllm_config=stage_vllm_config)
if bool(getattr(stage_vllm_config.model_config, "skip_tokenizer_init", False)):
input_processor = InputProcessor(
vllm_config=stage_vllm_config,
renderer=_build_token_only_renderer(stage_vllm_config),
)
else:
input_processor = InputProcessor(vllm_config=stage_vllm_config)
input_processor.input_preprocessor = OmniInputPreprocessor(
vllm_config=stage_vllm_config,
renderer=input_processor.renderer,
+5 -3
View File
@@ -996,9 +996,11 @@ async def omni_init_app_state(
)
# Warm up chat template processing to avoid first-request latency
# Upstream f5ffc59b6a removed OpenAIServingChat.warmup() and moved the
# warmup onto the renderer (OnlineRenderer.warmup()); mirror upstream.
state.online_renderer.warmup()
# Upstream f5ffc59b6a moved warmup onto OnlineRenderer. Accelerator
# images can temporarily lag that renderer API, where warmup is optional.
renderer_warmup = getattr(state.online_renderer, "warmup", None)
if renderer_warmup is not None:
renderer_warmup()
state.openai_serving_completion = (
OpenAIServingCompletion(
@@ -82,7 +82,8 @@ class OpenAICreateSpeechRequest(BaseModel):
description=(
"Streaming switch; defaults to OpenAI speech.audio.* SSE events. "
"Set stream_format='audio' to opt into raw pcm/wav byte streaming. "
"Requires response_format='pcm' or 'wav'. Speed adjustment is not supported when streaming."
"HTTP streaming requires response_format='pcm' or 'wav'. "
"Models with native speed control may accept speed adjustment over HTTP."
),
)
@@ -317,8 +318,6 @@ class OpenAICreateSpeechRequest(BaseModel):
)
if self.speed is None:
self.speed = 1.0
elif self.speed != 1.0:
raise ValueError("Speed adjustment is not supported when streaming. Set speed=1.0 or omit it.")
return self
@@ -532,7 +531,8 @@ class StreamingSpeechSessionConfig(BaseModel):
default=False,
description=(
"If true, send raw PCM audio chunks progressively over WebSocket. "
"Requires response_format='pcm'. Speed adjustment is not supported when streaming."
"Requires response_format='pcm'. WebSocket streaming currently requires "
"speed=1.0, including for models with native speed control."
),
)
word_timestamps: bool = Field(
@@ -556,5 +556,8 @@ class StreamingSpeechSessionConfig(BaseModel):
if self.speed is None:
self.speed = 1.0
elif self.speed != 1.0:
raise ValueError("Speed adjustment is not supported when stream_audio=true. Set speed=1.0 or omit it.")
raise ValueError(
"WebSocket stream_audio=true currently requires speed=1.0; "
"native speed control is only available through HTTP streaming."
)
return self
+8 -1
View File
@@ -83,7 +83,14 @@ from vllm.inputs import PromptType
from vllm.logger import init_logger
from vllm.multimodal.media.connector import MediaConnector
from vllm.outputs import RequestOutput
from vllm.parser.mistral import MistralToolCall
try:
from vllm.parser.mistral import MistralToolCall
except ImportError:
# Accelerator images can lag the core vLLM release by one version.
from vllm.tool_parsers.mistral_tool_parser import MistralToolCall
from vllm.reasoning import ReasoningParser
from vllm.renderers import BaseRenderer, merge_kwargs
from vllm.renderers.inputs import TokPrompt
+13 -3
View File
@@ -101,6 +101,7 @@ _SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = {
"higgs_audio_v2",
"higgs_audio_v3",
"indextts2",
"indextts2_5",
"audex",
"audex_tta",
}
@@ -513,6 +514,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._adapter = adapter_cls(ctx)
return self._adapter
def _uses_native_speed_control(self) -> bool:
adapter = self._get_tts_adapter()
return bool(adapter is not None and adapter.native_speed_control)
def _audio_encode_speed(self, request: OpenAICreateSpeechRequest) -> float:
if self._uses_native_speed_control():
return 1.0
return float(request.speed or 1.0)
async def warmup(self) -> None:
"""Run a synthetic speech request to trigger all first-request warmup.
@@ -3473,7 +3483,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
audio_tensor=audio_tensor,
sample_rate=sample_rate,
response_format=request.response_format or "wav",
speed=request.speed or 1.0,
speed=self._audio_encode_speed(request),
base64_encode=base64_encode,
)
audio_response: AudioResponse = self.create_audio(audio_obj)
@@ -3587,7 +3597,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
audio_tensor=audio_tensor,
sample_rate=sample_rate,
response_format=request.response_format or "wav",
speed=request.speed or 1.0,
speed=self._audio_encode_speed(request),
base64_encode=False,
)
audio_response: AudioResponse = self.create_audio(audio_obj)
@@ -3629,7 +3639,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return response_format, self.create_error_response(
f"{mode_label} is only supported for 'pcm' and 'wav' formats. Got '{response_format}'."
)
if request.speed is not None and request.speed != 1.0:
if request.speed is not None and request.speed != 1.0 and not self._uses_native_speed_control():
return response_format, self.create_error_response(
f"{mode_label} is not supported with speed adjustment. "
"Use a non-streaming request or remove the speed parameter."
@@ -115,6 +115,8 @@ class TTSModelAdapter(ABC):
detect_priority: ClassVar[int] = 100
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
backend: ClassVar[str] = "ar"
#: Whether the model consumes ``request.speed`` in its native parameters.
native_speed_control: ClassVar[bool] = False
max_new_tokens_min = 1
@@ -6,7 +6,7 @@ from __future__ import annotations
import hashlib
import math
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, ClassVar
import numpy as np
import torch
@@ -15,6 +15,10 @@ from vllm.utils import random_uuid
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
from vllm_omni.model_executor.models.indextts2.configuration_indextts2 import (
INDEXTTS25_MAX_DURATION_FACTOR,
INDEXTTS25_MIN_DURATION_FACTOR,
)
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@@ -102,6 +106,8 @@ def indextts2_conditioning_cache_salt(
"emo_text",
"use_emo_text",
"use_random",
"lang",
"text_normalization",
):
h.update(b"\x00")
h.update(key.encode("utf-8"))
@@ -206,14 +212,34 @@ class IndexTTS2Adapter(ARTTSAdapter):
estimate_indextts2_prefill_prompt_len,
)
prompt_kwargs: dict[str, Any] = {}
if self.name == "indextts2_5":
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
)
hf_config = getattr(server.engine_client.model_config, "hf_config", None)
prompt_kwargs["tokenizer_file"] = getattr(
hf_config,
"tokenizer_file",
INDEXTTS25_TOKENIZER_FILE,
)
ph_len = estimate_indextts2_prefill_prompt_len(
server.engine_client.model_config.model,
request.input,
model_type=self.name,
lang=str(_first_conditioning_value(tts_params.get("lang")) or "zh"),
text_normalization=bool(
_first_conditioning_value(tts_params.get("text_normalization"))
if "text_normalization" in tts_params
else True
),
**prompt_kwargs,
)
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
prompt["additional_information"] = tts_params
prompt["cache_salt"] = indextts2_conditioning_cache_salt(request, tts_params)
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="indextts2")
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type=self.name)
async def _build_params(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]:
server = self.ctx.server
@@ -241,3 +267,59 @@ class IndexTTS2Adapter(ARTTSAdapter):
else:
params[key] = [extras[key]]
return params
@register_tts_adapter
class IndexTTS25Adapter(IndexTTS2Adapter):
stage_keys = frozenset({"indextts2_5_talker"})
name = "indextts2_5"
native_speed_control: ClassVar[bool] = True
def validate(self, request: OpenAICreateSpeechRequest) -> str | None:
error = super().validate(request)
if error is not None:
return error
speed = request.speed if request.speed is not None else 1.0
if speed < INDEXTTS25_MIN_DURATION_FACTOR or speed > INDEXTTS25_MAX_DURATION_FACTOR:
return "IndexTTS 2.5 speed must be between 0.5 and 2.0"
return None
def _validate_extra_params(self, extras: Mapping[str, Any]) -> str | None:
error = super()._validate_extra_params(extras)
if error is not None:
return error
if "lang" in extras:
value = extras["lang"]
if not isinstance(value, str):
return "extra_params.lang must be a string"
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
normalize_language_code,
)
try:
normalize_language_code(value)
except ValueError as exc:
return str(exc)
if "text_normalization" in extras and not isinstance(
extras["text_normalization"],
bool,
):
return "extra_params.text_normalization must be a boolean"
return None
async def _build_params(
self,
request: OpenAICreateSpeechRequest,
) -> dict[str, Any]:
params = await super()._build_params(request)
extras = request.extra_params if isinstance(request.extra_params, dict) else {}
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
normalize_language_code,
)
params["lang"] = [normalize_language_code(str(extras.get("lang", "zh")))]
params["text_normalization"] = [bool(extras.get("text_normalization", True))]
speed = request.speed if request.speed is not None else 1.0
params["duration_factor"] = [1.0 / speed]
return params
@@ -571,7 +571,10 @@ class CodePredictorWrapper(nn.Module):
self._lm_heads_list = list(self.lm_head)
self._codec_embeds_list = list(self.model.codec_embedding)
if not current_omni_platform.supports_torch_inductor():
# Torch 2.13 XPU Dynamo can double-register built-in handlers when
# spawned workers compile this predictor. Keep this narrow path eager
# until the upstream XPU compiler issue is resolved.
if current_omni_platform.is_xpu() or not current_omni_platform.supports_torch_inductor():
# NPU or other platforms without Inductor support
self._compiled_model_fwd = self.model.forward
@@ -0,0 +1,7 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright contributors to the Amphion project
"""Inference-only EnhancedCodec runtime for IndexTTS 2.5."""
from .models import EnhancedCodec
__all__ = ["EnhancedCodec"]
@@ -0,0 +1,138 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright contributors to the Amphion project
"""Inference-only EnhancedCodec decoder for IndexTTS 2.5.
The checkpoint originates from the Amphion EnhancedCodec implementation
vendored by IndexTTS 2.5. The encoder and training paths are deliberately not
constructed because vLLM-Omni only performs semantic-code decoding here.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from .quantizer import ResidualVQDecoder
from .vocos import VocosBackbone
logger = logging.getLogger(__name__)
def _config_value(
cfg: Mapping[str, Any] | Any | None,
name: str,
default: Any,
) -> Any:
if cfg is None:
return default
if isinstance(cfg, Mapping):
return cfg.get(name, default)
return getattr(cfg, name, default)
class EnhancedCodec(nn.Module):
"""Subset of EnhancedCodec required for ``decode(codes)``."""
def __init__(
self,
codebook_size: int = 8192,
hidden_size: int = 1024,
codebook_dim: int = 8,
vocos_dim: int = 384,
vocos_intermediate_dim: int = 2048,
vocos_num_layers: int = 12,
num_quantizers: int = 1,
downsample_scale: int | None = 2,
cfg: Mapping[str, Any] | Any | None = None,
):
super().__init__()
codebook_size = int(_config_value(cfg, "codebook_size", codebook_size))
hidden_size = int(_config_value(cfg, "hidden_size", hidden_size))
codebook_dim = int(_config_value(cfg, "codebook_dim", codebook_dim))
vocos_dim = int(_config_value(cfg, "vocos_dim", vocos_dim))
vocos_intermediate_dim = int(_config_value(cfg, "vocos_intermediate_dim", vocos_intermediate_dim))
vocos_num_layers = int(_config_value(cfg, "vocos_num_layers", vocos_num_layers))
num_quantizers = int(_config_value(cfg, "num_quantizers", num_quantizers))
downsample_scale = _config_value(
cfg,
"downsample_scale",
downsample_scale,
)
self.hidden_size = hidden_size
self.num_quantizers = num_quantizers
self.downsample_scale = downsample_scale
self.decoder = nn.Sequential(
VocosBackbone(
input_channels=hidden_size,
dim=vocos_dim,
intermediate_dim=vocos_intermediate_dim,
num_layers=vocos_num_layers,
),
nn.Linear(vocos_dim, hidden_size),
)
self.quantizer = ResidualVQDecoder(
input_dim=hidden_size,
num_quantizers=num_quantizers,
codebook_size=codebook_size,
codebook_dim=codebook_dim,
)
if downsample_scale is not None and downsample_scale > 1:
self.up = nn.Conv1d(
hidden_size,
hidden_size,
kernel_size=3,
stride=1,
padding=1,
)
def decode(self, codes: torch.Tensor) -> torch.Tensor:
"""Decode `[B,T]` or `[N,B,T]` codes to `[B,T*scale,hidden]`."""
if codes.ndim == 2:
codes = codes.unsqueeze(0)
if codes.ndim != 3:
raise ValueError(f"EnhancedCodec codes must have shape [B,T] or [N,B,T], got {tuple(codes.shape)}")
if codes.shape[0] != self.num_quantizers:
raise ValueError(f"EnhancedCodec expected {self.num_quantizers} codebooks, got {codes.shape[0]}")
hidden = self.quantizer.vq2emb(codes)
hidden = self.decoder(hidden)
if self.downsample_scale is not None and self.downsample_scale > 1:
hidden = hidden.transpose(1, 2)
hidden = F.interpolate(
hidden,
scale_factor=self.downsample_scale,
mode="nearest",
)
hidden = self.up(hidden).transpose(1, 2)
return hidden
def load_checkpoint(self, checkpoint_path: str) -> None:
path = Path(checkpoint_path)
if not path.is_file():
raise FileNotFoundError(f"EnhancedCodec checkpoint not found: {path}")
checkpoint = torch.load(path, map_location="cpu", weights_only=True)
saved_state = checkpoint.get("model", checkpoint)
own_state = self.state_dict()
missing = [
name
for name, value in own_state.items()
if name not in saved_state or saved_state[name].shape != value.shape
]
if missing:
raise RuntimeError("EnhancedCodec checkpoint is missing required decoder weights: " + ", ".join(missing))
self.load_state_dict(
{name: saved_state[name] for name in own_state},
strict=True,
)
logger.info(
"Loaded %d inference-only EnhancedCodec tensors from %s",
len(own_state),
path,
)
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright contributors to the Amphion project
"""Decode-only factorized residual vector quantizer.
Adapted from the Amphion codec implementation vendored by IndexTTS 2.5.
Training-only paths are intentionally omitted.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.utils import weight_norm
def _weight_norm_conv1d(*args, **kwargs) -> nn.Conv1d:
return weight_norm(nn.Conv1d(*args, **kwargs))
class FactorizedVectorQuantizeDecoder(nn.Module):
def __init__(
self,
*,
input_dim: int,
codebook_size: int,
codebook_dim: int,
):
super().__init__()
self.input_dim = input_dim
self.codebook_dim = codebook_dim
if input_dim != codebook_dim:
# Keep upstream module names and weight-normalized parameter layout
# so codec.pth keys load without conversion.
self.in_project = _weight_norm_conv1d(
input_dim,
codebook_dim,
kernel_size=1,
)
self.out_project = _weight_norm_conv1d(
codebook_dim,
input_dim,
kernel_size=1,
)
else:
self.in_project = nn.Identity()
self.out_project = nn.Identity()
self.codebook = nn.Embedding(codebook_size, codebook_dim)
def vq2emb(self, codes: torch.Tensor) -> torch.Tensor:
embedding = F.embedding(codes, self.codebook.weight).transpose(1, 2)
return self.out_project(embedding)
class ResidualVQDecoder(nn.Module):
def __init__(
self,
*,
input_dim: int,
num_quantizers: int,
codebook_size: int,
codebook_dim: int,
):
super().__init__()
self.num_quantizers = num_quantizers
self.quantizers = nn.ModuleList(
[
FactorizedVectorQuantizeDecoder(
input_dim=input_dim,
codebook_size=codebook_size,
codebook_dim=codebook_dim,
)
for _ in range(num_quantizers)
]
)
def vq2emb(
self,
codes: torch.Tensor,
n_quantizers: int | None = None,
) -> torch.Tensor:
count = self.num_quantizers if n_quantizers is None else n_quantizers
quantized: torch.Tensor | None = None
for index, quantizer in enumerate(self.quantizers):
if index >= count:
break
value = quantizer.vq2emb(codes[index])
quantized = value if quantized is None else quantized + value
if quantized is None:
raise ValueError("EnhancedCodec requires at least one quantizer")
return quantized
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright contributors to the Amphion project
"""ConvNeXt Vocos backbone used by the IndexTTS 2.5 codec decoder."""
from __future__ import annotations
import torch
import torch.nn as nn
class ConvNeXtBlock(nn.Module):
def __init__(
self,
*,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float,
):
super().__init__()
self.dwconv = nn.Conv1d(
dim,
dim,
kernel_size=7,
padding=3,
groups=dim,
)
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.pwconv1 = nn.Linear(dim, intermediate_dim)
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
self.gamma = nn.Parameter(
layer_scale_init_value * torch.ones(dim),
requires_grad=True,
)
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
residual = inputs
hidden = self.dwconv(inputs).transpose(1, 2)
hidden = self.norm(hidden)
hidden = self.pwconv1(hidden)
hidden = self.act(hidden)
hidden = self.pwconv2(hidden)
hidden = self.gamma * hidden
return residual + hidden.transpose(1, 2)
class VocosBackbone(nn.Module):
def __init__(
self,
*,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
):
super().__init__()
self.embed = nn.Conv1d(
input_channels,
dim,
kernel_size=7,
padding=3,
)
self.norm = nn.LayerNorm(dim, eps=1e-6)
layer_scale = 1 / num_layers
self.convnext = nn.ModuleList(
[
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
layer_scale_init_value=layer_scale,
)
for _ in range(num_layers)
]
)
self.final_layer_norm = nn.LayerNorm(dim, eps=1e-6)
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
hidden = self.embed(inputs)
hidden = self.norm(hidden.transpose(1, 2)).transpose(1, 2)
for block in self.convnext:
hidden = block(hidden)
return self.final_layer_norm(hidden.transpose(1, 2))
@@ -5,6 +5,9 @@ import copy
from transformers import PretrainedConfig
INDEXTTS25_MIN_DURATION_FACTOR = 0.5
INDEXTTS25_MAX_DURATION_FACTOR = 2.0
class IndexTTS2Config(PretrainedConfig):
model_type = "indextts2"
@@ -138,6 +141,10 @@ class IndexTTS2Config(PretrainedConfig):
"emo_num": [3, 17, 2, 8, 4, 5, 10, 24],
"qwen_emo_path": "qwen0.6bemo4-merge/",
"vocoder": {"type": "bigvgan", "name": "nvidia/bigvgan_v2_22khz_80band_256x"},
"tokenizer_file": "bpe.model",
"semantic_codec_checkpoint": "semantic_codec.pth",
"semantic_codec_type": "repcodec",
"use_gpt_latent": True,
"version": 2.0,
# Default-off performance tracing for IndexTTS2 overhead analysis.
}
@@ -148,9 +155,49 @@ class IndexTTS2Config(PretrainedConfig):
for attr, default in defaults.items():
if not hasattr(self, attr):
setattr(self, attr, default)
# vLLM kernel warmup reads the top-level vocabulary size even when
# tokenizer initialization is skipped for the audio-code model.
self.vocab_size = int(getattr(self, "vocab_size", 0) or self.gpt["number_mel_codes"])
# Keep gpt.model_dim in sync with top-level hidden_size.
if isinstance(self.gpt, dict) and self.gpt.get("model_dim") != self.hidden_size:
self.gpt["model_dim"] = self.hidden_size
self.output_sample_rate = int(
self.s2mel["preprocess_params"].get("sr", 22050) if isinstance(self.s2mel, dict) else 22050
)
class IndexTTS25Config(IndexTTS2Config):
"""Configuration for the official IndexTTS 2.5 checkpoint.
IndexTTS 2.5 keeps the GPT/S2Mel backbone shapes used by IndexTTS 2,
but switches text tokenization and speaker conditioning, and decodes
semantic codes with EnhancedCodec. GPT latent conditioning is disabled by
default upstream; the opt-in path is an experimental vLLM-Omni-specific
latent variant with no official runnable reference output.
"""
model_type = "indextts2_5"
_DEFAULTS = copy.deepcopy(IndexTTS2Config._DEFAULTS)
_DEFAULTS["gpt"] = {
**_DEFAULTS["gpt"],
"number_text_tokens": 60509,
}
_DEFAULTS.update(
{
"tokenizer_file": "multilingual_zh_ja_yue_char_del.tiktoken",
"semantic_codec_checkpoint": "codec.pth",
"semantic_codec_type": "enhanced",
"use_gpt_latent": False,
"version": 2.5,
}
)
def __init__(self, **kwargs):
# HF overrides are sometimes intentionally partial. Merge nested GPT
# values with the 2.5 defaults so dimensions needed by the shared
# implementation remain present while explicit values still win.
explicit_gpt = kwargs.pop("gpt", None)
merged_gpt = copy.deepcopy(self._DEFAULTS["gpt"])
if explicit_gpt is not None:
merged_gpt.update(explicit_gpt)
super().__init__(gpt=merged_gpt, **kwargs)
@@ -51,6 +51,41 @@ class CUDAGraphDiTRunner:
self.enabled = bool(enabled)
self._cache: OrderedDict[tuple, _GraphEntry] = OrderedDict()
self.last_call_info: dict[str, object] = {}
self._stats = {
"calls": 0,
"hits": 0,
"captures": 0,
"capture_failures": 0,
"evictions": 0,
"eager": 0,
}
def stats_snapshot(self) -> dict[str, object]:
"""Return bounded cumulative graph-cache telemetry."""
return {
**self._stats,
"cache_size": len(self._cache),
}
def _replay(
self,
entry: _GraphEntry,
*,
x_in: torch.Tensor,
c: torch.Tensor,
) -> torch.Tensor:
"""Replay with only the inputs that vary between Euler steps.
``input_pos`` is the same arange slice for a given cache key and the
runner is enabled only for all-True masks, so their capture-time
buffers remain valid. The returned output is also the graph's static
buffer: all downstream consumers are enqueued on the same CUDA stream
before the next replay can overwrite it.
"""
entry.static_x.copy_(x_in)
entry.static_c.copy_(c)
entry.graph.replay()
return entry.static_out
def __call__(
self,
@@ -59,7 +94,9 @@ class CUDAGraphDiTRunner:
input_pos: torch.Tensor,
mask: torch.Tensor | None,
) -> torch.Tensor:
self._stats["calls"] += 1
if not self.enabled or x_in.device.type != "cuda" or torch.cuda.is_current_stream_capturing():
self._stats["eager"] += 1
self.last_call_info = {
"mode": "eager",
"reason": "ineligible",
@@ -84,6 +121,8 @@ class CUDAGraphDiTRunner:
if entry is None:
entry = self._capture(x_in, c, input_pos, mask)
if entry is None:
self._stats["capture_failures"] += 1
self._stats["eager"] += 1
self.last_call_info = {
"mode": "eager",
"reason": "capture_failed",
@@ -91,10 +130,12 @@ class CUDAGraphDiTRunner:
"cache_size": len(self._cache),
}
return self.transformer(x_in, c, input_pos, mask)
self._stats["captures"] += 1
self._cache[key] = entry
while len(self._cache) > self.max_graphs:
_, evicted = self._cache.popitem(last=False)
del evicted
self._stats["evictions"] += 1
# Kernels are only recorded (not executed) during capture, so
# replay once to produce the first real output.
entry.graph.replay()
@@ -104,22 +145,20 @@ class CUDAGraphDiTRunner:
"shape": tuple(x_in.shape),
"cache_size": len(self._cache),
}
return entry.static_out.clone()
return entry.static_out
self._cache.move_to_end(key)
entry.static_x.copy_(x_in)
entry.static_c.copy_(c)
entry.static_pos.copy_(input_pos)
if entry.static_mask is not None and mask is not None:
entry.static_mask.copy_(mask)
entry.graph.replay()
self._stats["hits"] += 1
output = self._replay(entry, x_in=x_in, c=c)
self.last_call_info = {
"mode": "graph",
"reason": "hit",
"shape": tuple(x_in.shape),
"cache_size": len(self._cache),
}
return entry.static_out.clone()
if self._stats["calls"] % 1000 == 0:
logger.info("S2Mel DiT CUDA graph stats: %s", self.stats_snapshot())
return output
def _capture(
self,
File diff suppressed because it is too large. Load diff
@@ -12,6 +12,7 @@ import json
import math
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import numpy as np
@@ -38,7 +39,11 @@ from vllm_omni.data_entry_keys import OmniPayload
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.utils.speaker_cache import get_speaker_cache
from .configuration_indextts2 import IndexTTS2Config
from .configuration_indextts2 import (
INDEXTTS25_MAX_DURATION_FACTOR,
INDEXTTS25_MIN_DURATION_FACTOR,
IndexTTS2Config,
)
from .gpt.conformer_encoder import ConformerEncoder
from .gpt.embeddings import LearnedPositionEmbeddings
from .gpt.perceiver import PerceiverResampler
@@ -56,6 +61,57 @@ from .preprocess_utils import (
logger = init_logger(__name__)
@dataclass(frozen=True)
class IndexTTSConditioningPolicy:
prefix_tokens: int
uses_conformer_perceiver: bool
uses_campplus_projection: bool
uses_language_embedding: bool
def resolve_indextts_conditioning_policy(model_type: str) -> IndexTTSConditioningPolicy:
if model_type == "indextts2_5":
return IndexTTSConditioningPolicy(
prefix_tokens=3,
uses_conformer_perceiver=False,
uses_campplus_projection=True,
uses_language_embedding=True,
)
return IndexTTSConditioningPolicy(
prefix_tokens=34,
uses_conformer_perceiver=True,
uses_campplus_projection=False,
uses_language_embedding=False,
)
def _normalize_indextts_checkpoint_key(name: str) -> str:
"""Remove FSDP wrapper path components from native checkpoint keys."""
return name.replace("_fsdp_wrapped_module.", "")
def _require_exact_indextts_prefill_span(
*,
actual_span_len: int,
scheduled_span_len: int,
model_type: str,
lang: str,
text_token_count: int,
) -> None:
"""Reject prompt-sizing drift before it can move/remove ``start_mel``."""
if actual_span_len == scheduled_span_len:
return
raise ValueError(
"IndexTTS prefill embedding span mismatch: "
f"scheduled_span_len={scheduled_span_len}, "
f"actual_span_len={actual_span_len}, "
f"model_type={model_type}, lang={lang}, "
f"text_token_count={text_token_count}. "
"IndexTTS requires exact placeholder sizing and does not support "
"chunked prefill; keep enable_chunked_prefill=false."
)
def _find_most_similar_cosine(query: torch.Tensor, matrix: torch.Tensor) -> int:
sims = F.cosine_similarity(query.float(), matrix.float(), dim=1)
return int(torch.argmax(sims).item())
@@ -82,6 +138,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self.model_path = vllm_config.model_config.model
self.config: IndexTTS2Config = vllm_config.model_config.hf_config # type: ignore[assignment]
gpt_cfg = self.config.gpt
self.conditioning_policy = resolve_indextts_conditioning_policy(self.config.model_type)
self.use_gpt_latent = bool(getattr(self.config, "use_gpt_latent", True))
self.model_dim = gpt_cfg["model_dim"]
self.num_layers = gpt_cfg["layers"]
@@ -100,26 +158,37 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self.has_postprocess = True
self.requires_raw_input_tokens = True
self.enable_update_additional_information = True
# S2Mel consumes the complete code sequence only at request end, but
# the connector accumulator needs to observe each appendable AR delta.
# Keep runner request-end gating disabled and emit per-step rows from
# make_omni_output; the full-payload builder reconstructs the sequence.
self.omni_payload_at_request_end = False
# Stage 1 consumes the hidden-state stream only in the legacy
# ``use_gpt_latent`` mode. IndexTTS 2.5 transports mel codes plus
# reference conditioning, so asking the generic runner to include
# hidden states would add a synchronous [num_tokens, model_dim] D2H
# copy on every decode step without adding anything to the payload.
self.omni_pooler_payload_include_hidden = self.use_gpt_latent
# S2Mel consumes the complete sequence only at request end. In latent
# mode the runner retains aligned code/latent deltas on GPU; no-latent
# mode reuses the CPU output-token history and retains only conditioning
# metadata here.
self.omni_payload_at_request_end = self.config.model_type == "indextts2_5"
self.omni_request_end_token_ids = ()
self._speaker_cache = get_speaker_cache()
self.gpu_resident_buffer_keys: set[tuple[str, str]] = {
("codes", "mel"),
("hidden_states", "latent"),
("meta", "mel_start_offset"),
("meta", "latent_acc"),
("meta", "mel_code_count"),
}
if self.use_gpt_latent:
self.gpu_resident_buffer_keys.update(
{
("hidden_states", "latent"),
("meta", "latent_acc"),
}
)
# --- GPT-2 transformer (vLLM-native with PagedAttention) ---
# Build a GPT2Config-like object for GPT2Block
from transformers import GPT2Config as HFGpt2Config
max_seq_len = self.max_mel_tokens + self.max_text_tokens + self.condition_num_latent + 4
max_seq_len = self.max_mel_tokens + self.max_text_tokens + self.conditioning_policy.prefix_tokens + 3
gpt2_config = HFGpt2Config(
vocab_size=self.number_mel_codes,
n_positions=max_seq_len,
@@ -149,7 +218,13 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self.mel_embedding = nn.Embedding(self.number_mel_codes, self.model_dim)
self.text_pos_embedding = LearnedPositionEmbeddings(self.max_text_tokens + 2, self.model_dim)
self.mel_pos_embedding = LearnedPositionEmbeddings(self.max_mel_tokens + 2 + 1, self.model_dim)
self.speed_emb = nn.Embedding(2, self.model_dim)
if self.conditioning_policy.uses_campplus_projection:
from .tokenizer_v2_5 import LANGUAGE_DICT
self.spk_emb_proj = nn.Linear(192, self.model_dim)
self.lang_embedding = nn.Embedding(len(LANGUAGE_DICT) + 1, self.model_dim)
else:
self.speed_emb = nn.Embedding(2, self.model_dim)
self.emo_layer = nn.Linear(self.model_dim, self.model_dim)
self.emovec_layer = nn.Linear(1024, self.model_dim)
@@ -158,6 +233,7 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self.mel_head = ParallelLMHead(
self.number_mel_codes,
self.model_dim,
bias=True,
quant_config=quant_config,
prefix=maybe_prefix(prefix, "mel_head"),
)
@@ -169,21 +245,22 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
cond_cfg = gpt_cfg.get("condition_module", {})
emo_cond_cfg = gpt_cfg.get("emo_condition_module", {})
self.conditioning_encoder = ConformerEncoder(
input_size=1024,
output_size=cond_cfg.get("output_size", 512),
linear_units=cond_cfg.get("linear_units", 2048),
attention_heads=cond_cfg.get("attention_heads", 8),
num_blocks=cond_cfg.get("num_blocks", 6),
input_layer=cond_cfg.get("input_layer", "conv2d2"),
)
self.perceiver_encoder = PerceiverResampler(
self.model_dim,
dim_context=cond_cfg.get("output_size", 512),
ff_mult=cond_cfg.get("perceiver_mult", 2),
heads=cond_cfg.get("attention_heads", 8),
num_latents=self.condition_num_latent,
)
if self.conditioning_policy.uses_conformer_perceiver:
self.conditioning_encoder = ConformerEncoder(
input_size=1024,
output_size=cond_cfg.get("output_size", 512),
linear_units=cond_cfg.get("linear_units", 2048),
attention_heads=cond_cfg.get("attention_heads", 8),
num_blocks=cond_cfg.get("num_blocks", 6),
input_layer=cond_cfg.get("input_layer", "conv2d2"),
)
self.perceiver_encoder = PerceiverResampler(
self.model_dim,
dim_context=cond_cfg.get("output_size", 512),
ff_mult=cond_cfg.get("perceiver_mult", 2),
heads=cond_cfg.get("attention_heads", 8),
num_latents=self.condition_num_latent,
)
self.emo_conditioning_encoder = ConformerEncoder(
input_size=1024,
@@ -202,7 +279,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
)
# Padding masks for perceiver cross-attention
self.cond_mask_pad = nn.ConstantPad1d((self.condition_num_latent, 0), True)
if self.conditioning_policy.uses_conformer_perceiver:
self.cond_mask_pad = nn.ConstantPad1d((self.condition_num_latent, 0), True)
self.emo_cond_mask_pad = nn.ConstantPad1d((1, 0), True)
# --- Lazy-loaded external models ---
@@ -214,7 +292,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
# Initialize embeddings per GPT-2 convention
for emb in [self.text_embedding, self.mel_embedding]:
emb.weight.data.normal_(mean=0.0, std=0.02)
self.speed_emb.weight.data.normal_(mean=0.0, std=0.0)
if hasattr(self, "speed_emb"):
self.speed_emb.weight.data.normal_(mean=0.0, std=0.0)
# ------------------------------------------------------------------
# vLLM required hooks
@@ -283,14 +362,23 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
hidden_states = hidden_states.text_hidden_states
if hidden_states is None or self.mel_head is None:
return None
return self.logits_processor(self.mel_head, hidden_states)
# `LogitsProcessor` never reads `lm_head.bias`: the unquantized apply()
# path is `F.linear(x, layer.weight, bias)` where `bias` comes from the
# `embedding_bias` argument. IndexTTS's mel head is `Linear(..., bias=True)`
# upstream, so the bias has to be passed explicitly or logits silently
# drop it.
return self.logits_processor(
self.mel_head,
hidden_states,
embedding_bias=getattr(self.mel_head, "bias", None),
)
# ------------------------------------------------------------------
# Omni multimodal output plumbing
# ------------------------------------------------------------------
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
"""Collect mel_codes and hidden_states from intermediate buffer."""
"""Build the per-step Stage 1 payload from the intermediate buffer."""
if isinstance(model_outputs, OmniOutput):
return model_outputs
@@ -303,58 +391,71 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
device = hidden.device
latent_dim = hidden.shape[-1]
_zero_codes = torch.zeros(0, 1, dtype=torch.long, device=device)
_zero_latent = torch.zeros(0, latent_dim, dtype=hidden.dtype, device=device)
_zero_codes = torch.zeros(0, 1, dtype=torch.long, device=device) if self.use_gpt_latent else None
_zero_latent = torch.zeros(0, latent_dim, dtype=hidden.dtype, device=device) if self.use_gpt_latent else None
# Per-request lists: to_payload_element dispatches lists via
# element[idx], giving correct per-request mapping even during
# mixed prefill+decode steps. Zero-length placeholders for prefill
# requests are harmless — torch.cat ignores them in accumulation.
#
# Export semantics are appendable deltas for the existing full-payload
# accumulator: codes.mel is a 2-D row [1, 1], hidden_states.latent is
# the aligned one-row [1, D] slice from meta.latent_acc.
# Latent-mode exports are appendable deltas for the existing
# full-payload accumulator: codes.mel is a 2-D row [1, 1], while
# hidden_states.latent is the aligned one-row [1, D] slice from
# meta.latent_acc. No-latent mode only uses meta_list.
mel_codes_list: list[torch.Tensor] = []
latent_list: list[torch.Tensor] = []
meta_list: list[dict[str, Any]] = []
for info in info_dicts:
if not isinstance(info, dict):
mel_codes_list.append(_zero_codes)
latent_list.append(_zero_latent)
if self.use_gpt_latent:
assert _zero_codes is not None
mel_codes_list.append(_zero_codes)
assert _zero_latent is not None
latent_list.append(_zero_latent)
meta_list.append({})
continue
codes = info.get("codes", {})
mc = codes.get("mel")
info_meta = info.get("meta", {})
# mel_len now comes from the int counter in meta, not from
# code_seq.shape[0] (codes.mel is a single-token delta).
mel_len = int(info_meta.get("mel_code_count", 0))
if isinstance(mc, torch.Tensor) and mc.numel() > 0 and mel_len > 0:
code_seq = mc.reshape(-1)
mel_codes_list.append(code_seq[-1:].reshape(1, 1).contiguous())
else:
mel_codes_list.append(_zero_codes)
lat_acc = info_meta.get("latent_acc")
if isinstance(lat_acc, torch.Tensor) and lat_acc.numel() > 0 and mel_len > 0:
lat_seq = lat_acc.reshape(-1, lat_acc.shape[-1]) if lat_acc.ndim != 2 else lat_acc
idx = min(max(mel_len - 1, 0), int(lat_seq.shape[0]) - 1)
latent_list.append(lat_seq[idx : idx + 1].contiguous())
else:
hs = info.get("hidden_states", {})
lat = hs.get("latent")
if isinstance(lat, torch.Tensor) and lat.numel() > 0:
lat_seq = lat.reshape(-1, lat.shape[-1]) if lat.ndim != 2 else lat
latent_list.append(lat_seq[-1:].contiguous())
if self.use_gpt_latent:
codes = info.get("codes", {})
mc = codes.get("mel")
if isinstance(mc, torch.Tensor) and mc.numel() > 0 and mel_len > 0:
code_seq = mc.reshape(-1)
mel_codes_list.append(code_seq[-1:].reshape(1, 1).contiguous())
else:
latent_list.append(_zero_latent)
assert _zero_codes is not None
mel_codes_list.append(_zero_codes)
req_meta: dict[str, Any] = {}
lat_acc = info_meta.get("latent_acc")
if isinstance(lat_acc, torch.Tensor) and lat_acc.numel() > 0 and mel_len > 0:
lat_seq = lat_acc.reshape(-1, lat_acc.shape[-1]) if lat_acc.ndim != 2 else lat_acc
idx = min(max(mel_len - 1, 0), int(lat_seq.shape[0]) - 1)
latent_list.append(lat_seq[idx : idx + 1].contiguous())
else:
hs = info.get("hidden_states", {})
lat = hs.get("latent")
if isinstance(lat, torch.Tensor) and lat.numel() > 0:
lat_seq = lat.reshape(-1, lat.shape[-1]) if lat.ndim != 2 else lat
latent_list.append(lat_seq[-1:].contiguous())
else:
assert _zero_latent is not None
latent_list.append(_zero_latent)
# Scalar metadata is kept with REPLACE semantics by the generic
# full-payload accumulator. Emit the policy on every step so the
# final payload contract never depends on which decode row carried
# the one-time conditioning tensors.
req_meta: dict[str, Any] = {"use_gpt_latent": self.use_gpt_latent}
if mel_len == 1:
s_ref = info_meta.get("S_ref")
ref_mel = info_meta.get("ref_mel")
style = info_meta.get("style")
req_meta["duration_factor"] = info_meta.get("duration_factor", 1.0)
if isinstance(s_ref, torch.Tensor):
req_meta["S_ref"] = s_ref
if isinstance(ref_mel, torch.Tensor):
@@ -363,13 +464,22 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
req_meta["style"] = style
meta_list.append(req_meta)
if not any(t.numel() > 0 for t in mel_codes_list):
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
mm: OmniPayload = {
"codes": {"mel": mel_codes_list},
"hidden_states": {"latent": latent_list},
}
if self.use_gpt_latent:
if not any(t.numel() > 0 for t in mel_codes_list):
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
mm: OmniPayload = {
"codes": {"mel": mel_codes_list},
}
mm["hidden_states"] = {"latent": latent_list}
else:
# vLLM already materializes sampled token IDs on CPU for scheduler
# bookkeeping and stop-token handling. Stage 1 consumes the complete
# sequence only after request completion, so retain the conditioning
# metadata once and read the finished request.output_token_ids there
# instead of snapshotting a duplicate GPU code row every step.
if not any(meta_list):
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
mm = {}
if any(meta_list):
mm["meta"] = meta_list
@@ -387,9 +497,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
"""Build prompt embeddings for prefill; compute mel embeddings for decode.
Prefill layout::
[conds(32) + emo_vec(1) + duration(2)] [text_emb + text_pos] [start_mel + mel_pos(0)]
IndexTTS 2 prefill uses 32 speaker latents plus two duration tokens.
IndexTTS 2.5 uses one CAMPPlus speaker token plus two zero tokens.
Decode: ``mel_embedding(token) + mel_pos_embedding(step)``.
"""
@@ -453,6 +562,12 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
v = info_dict.get(key)
return v[0] if isinstance(v, list) and v else default
duration_factor = float(_first("duration_factor", 1.0))
# Direct Omni callers bypass the serving adapter, so retain the
# model-side guard as the final fail-fast validation layer.
if not (INDEXTTS25_MIN_DURATION_FACTOR <= duration_factor <= INDEXTTS25_MAX_DURATION_FACTOR):
raise ValueError("IndexTTS 2.5 duration_factor must be between 0.5 and 2.0")
text = _first("text")
if not text:
raise ValueError("Missing additional_information.text for IndexTTS2 talker.")
@@ -467,6 +582,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
emo_vector = _first("emo_vector")
emo_alpha = float(_first("emo_alpha", 1.0))
use_random = bool(_first("use_random", False))
lang = str(_first("lang", "zh"))
text_normalization = bool(_first("text_normalization", True))
_raw_emo_voice = _first("emo_voice_name")
emo_voice_name = str(_raw_emo_voice).strip().lower() if _raw_emo_voice else None
@@ -499,9 +616,19 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self._ensure_w2v_stat_loaded(device)
spk_cond_emb = wav2vec_extract(wav_16k, w2v_model, w2v_proc, device, self._w2v_stat)
semantic_codec = load_semantic_codec(self.model_path, self.config.semantic_codec, device)
with torch.no_grad():
_, s_ref = semantic_codec.quantize(spk_cond_emb) # [B, T, 1024] quantized embeddings
if self.conditioning_policy.uses_campplus_projection:
# Official 2.5 keeps W2V-BERT features as the reference
# semantic sequence; EnhancedCodec is only used for generated
# semantic codes in Stage 1.
s_ref = spk_cond_emb
else:
semantic_codec = load_semantic_codec(
self.model_path,
self.config.semantic_codec,
device,
)
with torch.no_grad():
_, s_ref = semantic_codec.quantize(spk_cond_emb)
campplus = load_campplus(self.model_path, device)
fbank = compute_fbank(wav_16k, device)
@@ -521,12 +648,16 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
},
)
model_dtype = next(self.conditioning_encoder.parameters()).dtype
if self.conditioning_policy.uses_campplus_projection:
model_dtype = self.spk_emb_proj.weight.dtype
else:
model_dtype = next(self.conditioning_encoder.parameters()).dtype
spk_cond_emb = spk_cond_emb.to(device=device, dtype=model_dtype)
spk_lens = torch.tensor([spk_cond_emb.shape[1]], device=device, dtype=torch.long)
speech_cond, mask = self.conditioning_encoder(spk_cond_emb, spk_lens)
conds_mask = self.cond_mask_pad(mask.squeeze(1))
conds = self.perceiver_encoder(speech_cond, conds_mask) # [1, 32, D]
if self.conditioning_policy.uses_conformer_perceiver:
spk_lens = torch.tensor([spk_cond_emb.shape[1]], device=device, dtype=torch.long)
speech_cond, mask = self.conditioning_encoder(spk_cond_emb, spk_lens)
conds_mask = self.cond_mask_pad(mask.squeeze(1))
conds = self.perceiver_encoder(speech_cond, conds_mask)
emo_vec = self._compute_emotion_vector(
wav_16k=wav_16k,
@@ -546,19 +677,39 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
device=device,
)
speed_zero = torch.zeros(1, device=device, dtype=torch.long)
speed_one = torch.ones(1, device=device, dtype=torch.long)
duration_emb = self.speed_emb(speed_zero)
duration_emb_half = self.speed_emb(speed_one)
if self.conditioning_policy.uses_campplus_projection:
speaker_token = self.spk_emb_proj(style.to(dtype=model_dtype)).unsqueeze(1)
zero_tokens = torch.zeros(
speaker_token.shape[0],
2,
self.model_dim,
device=device,
dtype=speaker_token.dtype,
)
conds_prefix = torch.cat(
[speaker_token + emo_vec.unsqueeze(1), zero_tokens],
dim=1,
)
else:
speed_zero = torch.zeros(1, device=device, dtype=torch.long)
speed_one = torch.ones(1, device=device, dtype=torch.long)
duration_emb = self.speed_emb(speed_zero)
duration_emb_half = self.speed_emb(speed_one)
conds_with_emo = conds + emo_vec.unsqueeze(1)
conds_prefix = torch.cat(
[conds_with_emo, duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)],
dim=1,
)
conds_with_emo = conds + emo_vec.unsqueeze(1) # [1, 32, D]
conds_prefix = torch.cat(
[conds_with_emo, duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)],
dim=1,
) # [1, 34, D]
text_tokens = self._tokenize_text(text, device) # [1, L]
text_tokens, lang_id = self._tokenize_text(
text,
device,
lang=lang,
text_normalization=text_normalization,
)
text_emb = self.text_embedding(text_tokens) + self.text_pos_embedding(text_tokens)
if lang_id is not None:
text_emb = text_emb + self.lang_embedding(lang_id).unsqueeze(1)
start_mel = self.mel_embedding(torch.tensor([[self.start_mel_token]], device=device, dtype=torch.long))
start_mel_pos = self.mel_pos_embedding.get_fixed_embedding(0, device)
@@ -567,13 +718,13 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
inputs_embeds = torch.cat([conds_prefix, text_emb, start_mel], dim=1).squeeze(0)
mel_start_offset = int(conds_prefix.shape[1]) + int(text_emb.shape[1])
prefill_mel_index = int(inputs_embeds.shape[0]) - 1
if int(inputs_embeds.shape[0]) < span_len:
pad_n = span_len - int(inputs_embeds.shape[0])
pad_emb = torch.zeros(pad_n, self.model_dim, device=device, dtype=inputs_embeds.dtype)
inputs_embeds = torch.cat([inputs_embeds, pad_emb], dim=0)
elif int(inputs_embeds.shape[0]) > span_len:
inputs_embeds = inputs_embeds[:span_len]
_require_exact_indextts_prefill_span(
actual_span_len=int(inputs_embeds.shape[0]),
scheduled_span_len=span_len,
model_type=str(getattr(self.config, "model_type", "indextts2")),
lang=lang,
text_token_count=int(text_tokens.shape[1]),
)
input_ids_out = torch.full_like(input_ids, self.start_mel_token)
@@ -585,11 +736,26 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
"S_ref": s_ref.cpu().contiguous(),
"ref_mel": ref_mel.cpu().contiguous(),
"style": style.cpu().contiguous(),
"latent_acc": torch.zeros(0, self.model_dim, dtype=inputs_embeds.dtype, device=device),
"use_gpt_latent": self.use_gpt_latent,
"duration_factor": duration_factor,
},
"codes": {"mel": torch.zeros(0, dtype=torch.long, device=device)},
"hidden_states": {"latent": torch.zeros(0, self.model_dim, dtype=inputs_embeds.dtype, device=device)},
}
if self.use_gpt_latent:
info_update["meta"]["latent_acc"] = torch.zeros(
0,
self.model_dim,
dtype=inputs_embeds.dtype,
device=device,
)
info_update["hidden_states"] = {
"latent": torch.zeros(
0,
self.model_dim,
dtype=inputs_embeds.dtype,
device=device,
)
}
return input_ids_out, inputs_embeds, info_update
@@ -605,6 +771,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
reconstructs the complete latent sequence from per-step deltas emitted
by make_omni_output.
"""
if not self.use_gpt_latent:
return {}
if hidden_states.shape[0] != 1:
meta = kwargs.get("meta", {})
prefill_mel_index = int(meta.get("prefill_mel_index", hidden_states.shape[0] - 1))
@@ -665,6 +833,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
if emo_text is None:
emo_text = main_text
emo_vector = self._predict_emotion_from_text(emo_text, device)
if emo_vector is None:
raise RuntimeError("IndexTTS use_emo_text requested but QwenEmotion could not be loaded")
if use_emo_text or emo_vector is not None:
emo_audio_path = None
@@ -840,7 +1010,7 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
self._ensure_matrix_loaded("_emo_matrix", "emo_matrix", "Emotion matrix")
self._ensure_matrix_loaded("_spk_matrix", "spk_matrix", "Speaker matrix")
if self._emo_matrix is None or self._spk_matrix is None:
return torch.zeros(1, self.model_dim, device=device)
raise RuntimeError("IndexTTS emotion control requires both emotion and speaker matrices")
weight_vector = emo_dist.to(device=device, dtype=torch.float32)
if weight_vector.ndim == 2:
@@ -886,23 +1056,47 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
# Text tokenizer
# ------------------------------------------------------------------
def _tokenize_text(self, text: str, device: torch.device) -> torch.Tensor:
"""Tokenize text using the BPE tokenizer, add start/stop tokens."""
if self._text_tokenizer is None:
from .tokenizer import IndexTTS2Tokenizer
bpe_path = resolve_model_file(self.model_path, "bpe.model")
if bpe_path is None:
raise FileNotFoundError(f"BPE model not found in {self.model_path}")
self._text_tokenizer = IndexTTS2Tokenizer(bpe_path, model_dir=self.model_path)
token_ids = self._text_tokenizer.encode(text, add_special_tokens=False)
# Wrap with start/stop text tokens
def _tokenize_text(
self,
text: str,
device: torch.device,
*,
lang: str = "zh",
text_normalization: bool = True,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Tokenize text and add the checkpoint start/stop text tokens."""
start_text = 0
stop_text = 1
lang_id: int | None = None
if self.conditioning_policy.uses_language_embedding:
from .text_processing_v2_5 import prepare_indextts25_text
token_ids, lang_id = prepare_indextts25_text(
text,
lang=lang,
model_dir=self.model_path,
tokenizer_file=self.config.tokenizer_file,
text_normalization=text_normalization,
)
token_ids = [token_id for token_id in token_ids if token_id not in {start_text, stop_text}]
else:
if self._text_tokenizer is None:
from .tokenizer import IndexTTS2Tokenizer
bpe_path = resolve_model_file(self.model_path, "bpe.model")
if bpe_path is None:
raise FileNotFoundError(f"BPE model not found in {self.model_path}")
self._text_tokenizer = IndexTTS2Tokenizer(bpe_path, model_dir=self.model_path)
token_ids = self._text_tokenizer.encode(text, add_special_tokens=False)
# The 2.5 tokenizer begins with the explicit ``<|lang|>`` token.
# Official prepare_gpt_inputs filters any existing wrapper tokens,
# then adds start_text=0 and stop_text=1 around that tokenizer output.
token_ids = [start_text] + token_ids + [stop_text]
return torch.tensor([token_ids], device=device, dtype=torch.long)
token_tensor = torch.tensor([token_ids], device=device, dtype=torch.long)
lang_tensor = torch.tensor([lang_id], device=device, dtype=torch.long) if lang_id is not None else None
return token_tensor, lang_tensor
# ------------------------------------------------------------------
# Lazy loading helpers
@@ -919,7 +1113,7 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
else:
self._w2v_stat = raw
else:
logger.warning("Wav2Vec2-BERT stat file not found in %s", self.model_path)
raise FileNotFoundError(f"Wav2Vec2-BERT stat file not found in {self.model_path!r}")
def _ensure_matrix_loaded(self, attr: str, cfg_key: str, label: str) -> None:
if getattr(self, attr) is not None:
@@ -929,7 +1123,7 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
raw = torch.load(path, map_location="cpu", weights_only=True)
setattr(self, attr, torch.split(raw, list(self.config.emo_num)))
else:
logger.warning("%s not found in %s", label, self.model_path)
raise FileNotFoundError(f"{label} not found in {self.model_path!r}")
# ------------------------------------------------------------------
# Weight loading
@@ -966,7 +1160,8 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
params_dict = dict(self.named_parameters(remove_duplicate=False))
loaded_params: set[str] = set()
for name, loaded_weight in state.items():
for checkpoint_name, loaded_weight in state.items():
name = _normalize_indextts_checkpoint_key(checkpoint_name)
# Skip attention masks
if ".attn.bias" in name or ".attn.masked_bias" in name:
continue
@@ -989,7 +1184,11 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
continue
if mapped_name not in params_dict:
logger.debug("Skipping unrecognized weight: %s → %s", name, mapped_name)
logger.debug(
"Skipping unrecognized weight: %s → %s",
checkpoint_name,
mapped_name,
)
continue
param = params_dict[mapped_name]
@@ -1015,17 +1214,29 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
"mel_embedding.",
"text_pos_embedding.",
"mel_pos_embedding.",
"speed_emb.",
"emo_layer.",
"emovec_layer.",
"conditioning_encoder.",
"perceiver_encoder.",
"emo_conditioning_encoder.",
"emo_perceiver_encoder.",
"h.",
"ln_f.",
"final_norm.",
]
if self.conditioning_policy.uses_campplus_projection:
required_prefixes.extend(
[
"spk_emb_proj.",
"lang_embedding.",
]
)
else:
required_prefixes.extend(
[
"speed_emb.",
"conditioning_encoder.",
"perceiver_encoder.",
]
)
if self.mel_head is not None:
required_prefixes.append("mel_head.")
missing_prefixes = [
@@ -1033,18 +1244,22 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
]
if missing_prefixes:
raise RuntimeError(
"IndexTTS2 GPT checkpoint did not load required parameter groups: " + ", ".join(missing_prefixes)
f"{self.config.model_type} GPT checkpoint did not load required parameter groups: "
+ ", ".join(missing_prefixes)
)
# Ensure all sub-modules on correct device (some nn.Parameter created
# with torch.Tensor() end up on CPU even when model is on CUDA).
target_device = next((p.device for p in self.h.parameters()), torch.device("cpu"))
for mod in [
self.conditioning_encoder,
modules = [
self.emo_conditioning_encoder,
self.perceiver_encoder,
self.emo_perceiver_encoder,
]:
]
if self.conditioning_policy.uses_campplus_projection:
modules.extend([self.spk_emb_proj, self.lang_embedding])
else:
modules.extend([self.conditioning_encoder, self.perceiver_encoder])
for mod in modules:
mod.to(target_device)
self._warmup_external_models(target_device)
@@ -1054,9 +1269,17 @@ class IndexTTS2TalkerForConditionalGeneration(nn.Module):
"""Preload external models to eliminate first-request latency."""
try:
load_wav2vec2(self.model_path, device)
load_semantic_codec(self.model_path, self.config.semantic_codec, device)
if not self.conditioning_policy.uses_campplus_projection:
load_semantic_codec(
self.model_path,
self.config.semantic_codec,
device,
)
load_campplus(self.model_path, device)
self._ensure_w2v_stat_loaded(device)
logger.info("External models preloaded: Wav2Vec2, RepCodec, CAMPPlus")
logger.info(
"External models preloaded: Wav2Vec2, %sCAMPPlus",
"RepCodec, " if not self.conditioning_policy.uses_campplus_projection else "",
)
except Exception as e:
logger.warning("Failed to preload external models: %s", e)
raise RuntimeError("Failed to preload required IndexTTS Stage 0 models") from e
@@ -43,7 +43,43 @@ INDEXTTS2_PIPELINE = PipelineConfig(
engine_output_type="audio",
model_arch="IndexTTS2S2MelDecoder",
sync_process_input_func=f"{_PROC}.talker2s2mel_token_only",
extras={"skip_tokenizer_init": True, "tokenizer": "gpt2"},
extras={"skip_tokenizer_init": True},
sampling_constraints={"detokenize": True},
),
),
)
INDEXTTS25_PIPELINE = PipelineConfig(
model_type="indextts2_5",
default_deploy_config_name="indextts2_5.yaml",
model_arch="IndexTTS25TalkerForConditionalGeneration",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="indextts2_5_talker",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
owns_tokenizer=True,
engine_output_type="latent",
extras={"skip_tokenizer_init": True},
custom_process_next_stage_input_func=f"{_PROC}.talker2s2mel_full_payload",
sampling_constraints={
"detokenize": False,
"stop_token_ids": [8193],
},
),
StagePipelineConfig(
stage_id=1,
model_stage="indextts2_5_s2mel_decoder",
execution_type=StageExecutionType.LLM_GENERATION,
input_sources=(0,),
final_output=True,
final_output_type="audio",
engine_output_type="audio",
model_arch="IndexTTS25S2MelDecoder",
sync_process_input_func=f"{_PROC}.talker2s2mel_token_only",
extras={"skip_tokenizer_init": True},
sampling_constraints={"detokenize": True},
),
),
@@ -22,7 +22,7 @@ logger = init_logger(__name__)
# ---------------------------------------------------------------------------
_wav2vec2_cache: dict[tuple[str, str], tuple] = {}
_semantic_codec_cache: dict[tuple[str, str], Any] = {}
_semantic_codec_cache: dict[tuple[str, str, str], Any] = {}
_campplus_cache: dict[tuple[str, str], Any] = {}
_qwen_emotion_cache: dict[tuple[str, str], tuple] = {}
@@ -34,11 +34,40 @@ def _freeze(model):
return model
def local_model_roots(model_path: str) -> tuple[str, ...]:
"""Return native IndexTTS asset roots in lookup order.
HuggingFace-converted checkpoints place assets at the model root, while
the official IndexTTS repository and lab-published bundle keep them under
``checkpoints/``.
"""
roots = [model_path]
checkpoints = os.path.join(model_path, "checkpoints")
if os.path.isdir(checkpoints):
roots.append(checkpoints)
return tuple(roots)
def resolve_model_directory(
model_path: str,
*relative_paths: str,
) -> str | None:
for root in local_model_roots(model_path):
for relative_path in relative_paths:
candidate = os.path.join(root, relative_path)
if os.path.isdir(candidate):
return candidate
return None
def resolve_model_file(model_path: str, filename: str) -> str | None:
"""Resolve an IndexTTS2 asset from a local model dir or HF repo id."""
local_path = os.path.join(model_path, filename)
if os.path.isfile(local_path):
return local_path
for root in local_model_roots(model_path):
local_path = os.path.join(root, filename)
if os.path.isfile(local_path):
return local_path
if os.path.isdir(model_path):
return None
try:
return cached_file(model_path, filename)
except Exception as e:
@@ -53,11 +82,38 @@ def load_wav2vec2(model_path: str, device: torch.device):
from transformers import AutoFeatureExtractor, Wav2Vec2BertModel
model, processor = None, None
# Try local subfolder first (no lock needed), then HF hub with lock.
# Try the layouts shipped by IndexTTS 2 and 2.5 before the Hub fallback.
local_dir = resolve_model_directory(
model_path,
"wav2vec2bert",
"w2v-bert-2.0",
os.path.join("hf_cache", "w2v-bert-2.0"),
)
if local_dir is not None:
try:
processor = AutoFeatureExtractor.from_pretrained(
local_dir,
local_files_only=True,
)
model = Wav2Vec2BertModel.from_pretrained(
local_dir,
local_files_only=True,
)
except Exception as exc:
raise RuntimeError(f"Failed to load local IndexTTS Wav2Vec2-BERT assets from {local_dir!r}") from exc
if local_dir is None and os.path.isdir(model_path):
raise FileNotFoundError(f"IndexTTS Wav2Vec2-BERT assets are missing from local bundle {model_path!r}")
# Try a remote model subfolder, then the public base model.
try:
src_args = dict(pretrained_model_name_or_path=model_path, subfolder="wav2vec2bert")
processor = AutoFeatureExtractor.from_pretrained(**src_args)
model = Wav2Vec2BertModel.from_pretrained(**src_args)
if model is None or processor is None:
src_args = dict(
pretrained_model_name_or_path=model_path,
subfolder="wav2vec2bert",
)
processor = AutoFeatureExtractor.from_pretrained(**src_args)
model = Wav2Vec2BertModel.from_pretrained(**src_args)
except Exception:
try:
with _repo_prefetch_lock("facebook/w2v-bert-2.0"):
@@ -67,16 +123,43 @@ def load_wav2vec2(model_path: str, device: torch.device):
except Exception:
pass
if model is None or processor is None:
raise RuntimeError(f"Could not load Wav2Vec2-BERT from {model_path}/wav2vec2bert or facebook/w2v-bert-2.0")
raise RuntimeError(
f"Could not load Wav2Vec2-BERT from IndexTTS model assets under {model_path!r} or facebook/w2v-bert-2.0"
)
_freeze(model.to(device=device, dtype=torch.float32))
_wav2vec2_cache[cache_key] = (model, processor)
return model, processor
def load_semantic_codec(model_path: str, config: dict, device: torch.device):
cache_key = (model_path, str(device))
def load_semantic_codec(
model_path: str,
config: dict,
device: torch.device,
*,
codec_type: str = "repcodec",
checkpoint_name: str | None = None,
):
cache_key = (model_path, str(device), codec_type)
if cache_key in _semantic_codec_cache:
return _semantic_codec_cache[cache_key]
if codec_type == "enhanced":
from .codec import EnhancedCodec
codec = EnhancedCodec(**config, cfg=config)
checkpoint_name = checkpoint_name or "codec.pth"
ckpt_path = resolve_model_file(model_path, checkpoint_name)
if ckpt_path is None:
raise FileNotFoundError(
f"IndexTTS 2.5 EnhancedCodec checkpoint {checkpoint_name!r} not found in {model_path!r}"
)
codec.load_checkpoint(ckpt_path)
_freeze(codec.to(device=device, dtype=torch.float32))
_semantic_codec_cache[cache_key] = codec
return codec
if codec_type != "repcodec":
raise ValueError(f"Unsupported IndexTTS semantic codec type: {codec_type!r}")
from .utils.maskgct.repcodec_model import RepCodec
codec = RepCodec(
@@ -84,11 +167,14 @@ def load_semantic_codec(model_path: str, config: dict, device: torch.device):
hidden_size=config.get("hidden_size", 1024),
codebook_dim=config.get("codebook_dim", 8),
)
ckpt_path = resolve_model_file(model_path, "semantic_codec.pth")
checkpoint_name = checkpoint_name or "semantic_codec.pth"
ckpt_path = resolve_model_file(model_path, checkpoint_name)
if ckpt_path is not None:
state = torch.load(ckpt_path, map_location="cpu", weights_only=True)
codec.load_state_dict(state, strict=False)
else:
if os.path.isdir(model_path):
raise FileNotFoundError(f"IndexTTS RepCodec checkpoint is missing from local bundle {model_path!r}")
import safetensors.torch
from huggingface_hub import hf_hub_download
@@ -106,8 +192,19 @@ def load_campplus(model_path: str, device: torch.device):
from .utils.campplus.dtdnn import CAMPPlus
campplus = CAMPPlus(feat_dim=80, embedding_size=192)
ckpt_path = resolve_model_file(model_path, "campplus.pth")
ckpt_path = None
for relative_path in (
"campplus.pth",
"campplus_cn_common.bin",
os.path.join("hf_cache", "campplus_cn_common.bin"),
os.path.join("campplus", "campplus_cn_common.bin"),
):
ckpt_path = resolve_model_file(model_path, relative_path)
if ckpt_path is not None:
break
if ckpt_path is None:
if os.path.isdir(model_path):
raise FileNotFoundError(f"IndexTTS CAMPPlus checkpoint is missing from local bundle {model_path!r}")
from huggingface_hub import hf_hub_download
ckpt_path = hf_hub_download("funasr/campplus", filename="campplus_cn_common.bin")
@@ -125,20 +222,29 @@ def load_qwen_emotion(model_path: str, device: torch.device, *, trust_remote_cod
from transformers import AutoModelForCausalLM, AutoTokenizer
subfolder = "qwen0.6bemo4-merge"
local_dir = resolve_model_directory(model_path, subfolder)
source_args = (
{"pretrained_model_name_or_path": local_dir, "local_files_only": True}
if local_dir is not None
else {
"pretrained_model_name_or_path": model_path,
"subfolder": subfolder,
}
)
try:
tokenizer = AutoTokenizer.from_pretrained(
model_path,
subfolder=subfolder,
**source_args,
trust_remote_code=trust_remote_code,
)
model = AutoModelForCausalLM.from_pretrained(
model_path,
subfolder=subfolder,
**source_args,
torch_dtype="float16",
device_map={"": str(device)},
trust_remote_code=trust_remote_code,
)
except Exception as e:
if local_dir is not None:
raise RuntimeError(f"Failed to load local IndexTTS QwenEmotion assets from {local_dir!r}") from e
logger.warning(
"Could not load QwenEmotion from %s/%s: %s. use_emo_text will fall back to default emotion.",
model_path,
@@ -172,9 +278,8 @@ def compute_fbank(wav_16k: torch.Tensor, device: torch.device) -> torch.Tensor:
)
fbank = fbank - fbank.mean(dim=0, keepdim=True)
return fbank.unsqueeze(0).to(device=device) # [1, T, 80]
except (ImportError, RuntimeError, OSError):
logger.warning("torchaudio not available, returning zero fbank")
return torch.zeros(1, 100, 80, device=device)
except (ImportError, RuntimeError, OSError) as exc:
raise RuntimeError("IndexTTS requires torchaudio Kaldi fbank support") from exc
def wav2vec_extract(
@@ -7,10 +7,19 @@ from __future__ import annotations
from functools import lru_cache
from vllm_omni.model_executor.models.indextts2.preprocess_utils import resolve_model_file
from vllm_omni.model_executor.models.indextts2.text_processing_v2_5 import (
prepare_indextts25_text,
)
from vllm_omni.model_executor.models.indextts2.tokenizer import IndexTTS2Tokenizer
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
)
_CONDITIONING_PREFIX_TOKENS = 34
_TEXT_WRAPPER_TOKENS = 2
_V2_CONDITIONING_PREFIX_TOKENS = 34
_V25_CONDITIONING_PREFIX_TOKENS = 3
_V2_TEXT_WRAPPER_TOKENS = 2
_V25_TEXT_WRAPPER_TOKENS = 2
_V25_TEXT_WRAPPER_TOKEN_IDS = frozenset({0, 1})
_START_MEL_TOKENS = 1
@@ -26,22 +35,55 @@ def _get_text_tokenizer(model_id_or_path: str) -> IndexTTS2Tokenizer:
return IndexTTS2Tokenizer(_resolve_bpe_model_path(model_id_or_path), model_dir=model_id_or_path)
def estimate_indextts2_prefill_prompt_len(model_id_or_path: str, text: str) -> int:
def estimate_indextts2_prefill_prompt_len(
model_id_or_path: str,
text: str,
*,
model_type: str = "indextts2",
lang: str = "zh",
text_normalization: bool = True,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
) -> int:
"""Return the placeholder prompt length expected by the IndexTTS2 talker.
The model-side prefill replaces placeholder embeddings with:
34 conditioning tokens, text tokens wrapped with start/stop markers, and
one start-mel token.
IndexTTS 2 uses 34 conditioning tokens. IndexTTS 2.5 uses one projected
CAMPPlus speaker token followed by two zero tokens.
"""
tokenizer = _get_text_tokenizer(model_id_or_path)
text_token_count = len(tokenizer.encode(text, add_special_tokens=False)) + _TEXT_WRAPPER_TOKENS
return _CONDITIONING_PREFIX_TOKENS + text_token_count + _START_MEL_TOKENS
if model_type == "indextts2_5":
text_ids, _ = prepare_indextts25_text(
text,
lang=lang,
model_dir=model_id_or_path,
text_normalization=text_normalization,
tokenizer_file=tokenizer_file,
)
conditioning_prefix_tokens = _V25_CONDITIONING_PREFIX_TOKENS
text_token_count = (
sum(token_id not in _V25_TEXT_WRAPPER_TOKEN_IDS for token_id in text_ids) + _V25_TEXT_WRAPPER_TOKENS
)
else:
tokenizer = _get_text_tokenizer(model_id_or_path)
text_token_count = len(tokenizer.encode(text, add_special_tokens=False)) + _V2_TEXT_WRAPPER_TOKENS
conditioning_prefix_tokens = _V2_CONDITIONING_PREFIX_TOKENS
return conditioning_prefix_tokens + text_token_count + _START_MEL_TOKENS
def build_indextts2_prefill_prompt_ids(
model_id_or_path: str,
text: str,
*,
model_type: str = "indextts2",
lang: str = "zh",
text_normalization: bool = True,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
placeholder_token_id: int = 1,
) -> list[int]:
return [placeholder_token_id] * estimate_indextts2_prefill_prompt_len(model_id_or_path, text)
prompt_len = estimate_indextts2_prefill_prompt_len(
model_id_or_path,
text,
model_type=model_type,
lang=lang,
text_normalization=text_normalization,
tokenizer_file=tokenizer_file,
)
return [placeholder_token_id] * prompt_len
@@ -68,6 +68,6 @@ class MyModel(nn.Module):
x = self.models["gpt_layer"](x)
return x
def enable_torch_compile(self):
def enable_torch_compile(self, mode: str = "default"):
if "cfm" in self.models:
self.models["cfm"].enable_torch_compile()
self.models["cfm"].enable_torch_compile(mode=mode)
@@ -163,7 +163,18 @@ class DiT(torch.nn.Module):
def setup_caches(self, max_batch_size, max_seq_length):
self.transformer.setup_caches(max_batch_size, max_seq_length, use_kv_cache=False)
def forward(self, x, prompt_x, x_lens, t, style, cond, mask_content=False, pre_mask=None):
def forward(
self,
x,
prompt_x,
x_lens,
t,
style,
cond,
mask_content=False,
pre_mask=None,
unpad_data: tuple[torch.Tensor, torch.Tensor] | None = None,
):
"""
x (torch.Tensor): random noise
prompt_x (torch.Tensor): reference mel + zero mel
@@ -226,10 +237,16 @@ class DiT(torch.nn.Module):
input_pos = self.input_pos[: x_in.size(1)] # (T,) range(0,1863)
# Route through per-shape CUDA graph runner if enabled.
graph_runner = getattr(self, "_cuda_graph_runner", None)
if graph_runner is not None:
if graph_runner is not None and unpad_data is None:
x_res = graph_runner(x_in, t1.unsqueeze(1), input_pos, x_mask_expanded)
else:
x_res = self.transformer(x_in, t1.unsqueeze(1), input_pos, x_mask_expanded) # [2, 1863, 512]
x_res = self.transformer(
x_in,
t1.unsqueeze(1),
input_pos,
x_mask_expanded,
unpad_data=unpad_data,
) # [2, 1863, 512]
x_res = x_res[:, 1:] if self.time_as_token else x_res
x_res = x_res[:, 1:] if self.style_as_token else x_res
@@ -8,6 +8,41 @@ import torch
from vllm_omni.model_executor.models.indextts2.s2mel.modules.diffusion_transformer import DiT
def build_cfm_unpad_data(
lengths: list[int],
*,
padded_length: int,
token_offset: int,
repeat_for_cfg: bool,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Build row-major FlashAttention unpadding metadata for one CFM group."""
if not lengths:
raise ValueError("CFM unpad data requires at least one sequence length")
if token_offset < 0:
raise ValueError(f"CFM unpad token offset must be non-negative: {token_offset}")
if any(length < 0 or length > padded_length for length in lengths):
raise ValueError(f"CFM unpad lengths out of range: lengths={lengths} padded={padded_length}")
if max(lengths) != padded_length:
raise ValueError(f"CFM unpad group must contain its padded max: lengths={lengths} padded={padded_length}")
valid_lengths = [length + token_offset for length in lengths]
if repeat_for_cfg:
valid_lengths = [*valid_lengths, *valid_lengths]
padded_stride = padded_length + token_offset
indices = torch.cat(
[
torch.arange(valid_length, device=device, dtype=torch.int64) + row * padded_stride
for row, valid_length in enumerate(valid_lengths)
]
)
cumulative_lengths = [0]
for valid_length in valid_lengths:
cumulative_lengths.append(cumulative_lengths[-1] + valid_length)
cu_seqlens = torch.tensor(cumulative_lengths, device=device, dtype=torch.int32)
return indices, cu_seqlens
class BASECFM(torch.nn.Module, ABC):
def __init__(
self,
@@ -26,7 +61,20 @@ class BASECFM(torch.nn.Module, ABC):
self.zero_prompt_speech_token = False
@torch.inference_mode()
def inference(self, mu, x_lens, prompt, style, f0, n_timesteps, temperature=1.0, inference_cfg_rate=0.5):
def inference(
self,
mu,
x_lens,
prompt,
style,
f0,
n_timesteps,
temperature=1.0,
inference_cfg_rate=0.5,
initial_noise=None,
prompt_lens=None,
unpad_data: tuple[torch.Tensor, torch.Tensor] | None = None,
):
"""Forward diffusion
Args:
@@ -47,11 +95,48 @@ class BASECFM(torch.nn.Module, ABC):
shape: (batch_size, 80, mel_timesteps)
"""
B, T = mu.size(0), mu.size(1)
z = torch.randn([B, self.in_channels, T], device=mu.device, dtype=mu.dtype) * temperature
expected_noise_shape = (B, self.in_channels, T)
if initial_noise is None:
z = torch.randn(
expected_noise_shape,
device=mu.device,
dtype=mu.dtype,
)
else:
if tuple(initial_noise.shape) != expected_noise_shape:
raise ValueError(
"CFM initial noise shape mismatch: "
f"expected={expected_noise_shape}, got={tuple(initial_noise.shape)}"
)
z = initial_noise.to(device=mu.device, dtype=mu.dtype)
z = z * temperature
t_span = torch.linspace(0, 1, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
return self.solve_euler(z, x_lens, prompt, mu, style, f0, t_span, inference_cfg_rate)
return self.solve_euler(
z,
x_lens,
prompt,
mu,
style,
f0,
t_span,
inference_cfg_rate,
prompt_lens=prompt_lens,
unpad_data=unpad_data,
)
def solve_euler(self, x, x_lens, prompt, mu, style, f0, t_span, inference_cfg_rate=0.5):
def solve_euler(
self,
x,
x_lens,
prompt,
mu,
style,
f0,
t_span,
inference_cfg_rate=0.5,
prompt_lens=None,
unpad_data: tuple[torch.Tensor, torch.Tensor] | None = None,
):
"""
Fixed euler solver for ODEs.
Args:
@@ -82,19 +167,35 @@ class BASECFM(torch.nn.Module, ABC):
out = self.estimator(*args, **kw)
return out.float()
prompt_len = prompt.size(-1)
batch = x.shape[0]
if prompt_lens is None:
prompt_lens = torch.full(
(batch,),
prompt.size(-1),
device=x.device,
dtype=torch.long,
)
else:
prompt_lens = prompt_lens.to(device=x.device, dtype=torch.long).reshape(-1)
if prompt_lens.shape != (batch,):
raise ValueError(f"CFM prompt length/batch mismatch: lengths={prompt_lens.numel()} batch={batch}")
prompt_positions = torch.arange(prompt.size(-1), device=x.device)
prompt_mask = prompt_positions.unsqueeze(0) < prompt_lens.unsqueeze(1)
x_positions = torch.arange(x.size(-1), device=x.device)
noise_prefix_mask = x_positions.unsqueeze(0) < prompt_lens.unsqueeze(1)
prompt_x = torch.zeros_like(x)
prompt_x[..., :prompt_len] = prompt[..., :prompt_len]
x[..., :prompt_len] = 0
prompt_x[..., : prompt.size(-1)] = prompt * prompt_mask.unsqueeze(1).to(prompt.dtype)
x.masked_fill_(noise_prefix_mask.unsqueeze(1), 0)
if self.zero_prompt_speech_token:
mu[..., :prompt_len] = 0
mu = mu.masked_fill(noise_prefix_mask.unsqueeze(-1), 0)
# Precompute mask once — T and x_lens are constant across all ODE steps.
# x_mask [B, 1, T] broadcasts to [2B, 1, T] when CFG doubles batch.
pre_mask = self._precompute_mask(x, x_lens)
if inference_cfg_rate > 0:
batch = x.shape[0]
cfg_prompt_x = prompt_x.new_zeros((2 * batch, *prompt_x.shape[1:]))
cfg_prompt_x[:batch].copy_(prompt_x)
cfg_style = style.new_zeros((2 * batch, *style.shape[1:]))
@@ -120,16 +221,26 @@ class BASECFM(torch.nn.Module, ABC):
cfg_style,
cfg_mu,
pre_mask=cfg_pre_mask,
unpad_data=unpad_data,
)
dphi_dt, cfg_dphi_dt = stacked_dphi_dt.chunk(2, dim=0)
dphi_dt = (1.0 + inference_cfg_rate) * dphi_dt - inference_cfg_rate * cfg_dphi_dt
else:
dphi_dt = _run_estimator(x, prompt_x, x_lens, t.unsqueeze(0), style, mu, pre_mask=pre_mask)
dphi_dt = _run_estimator(
x,
prompt_x,
x_lens,
t.unsqueeze(0),
style,
mu,
pre_mask=pre_mask,
unpad_data=unpad_data,
)
x = x + dt * dphi_dt
t = t + dt
x[:, :, :prompt_len] = 0
x.masked_fill_(noise_prefix_mask.unsqueeze(1), 0)
return x
@@ -180,7 +291,7 @@ class CFM(BASECFM):
raise NotImplementedError(f"Unknown diffusion type {args.dit_type}")
self._compiled = False
def enable_torch_compile(self):
def enable_torch_compile(self, mode: str = "default"):
"""Enable torch.compile optimization for the estimator model.
This method applies torch.compile to the estimator (DiT model) for significant
@@ -191,6 +302,7 @@ class CFM(BASECFM):
torch._inductor.config.reorder_for_compute_comm_overlap = True
self.estimator = torch.compile(
self._eager_estimator,
mode=mode,
fullgraph=True,
dynamic=True,
)
@@ -116,6 +116,7 @@ class Transformer(nn.Module):
context: Tensor | None = None,
context_input_pos: Tensor | None = None,
cross_attention_mask: Tensor | None = None,
unpad_data: tuple[Tensor, Tensor] | None = None,
) -> Tensor:
assert self.freqs_cis is not None, "Caches must be initialized first"
if mask is None: # in case of non-causal model
@@ -135,7 +136,18 @@ class Transformer(nn.Module):
skip_in_x = skip_in_x_list.pop(-1)
else:
skip_in_x = None
x = layer(x, c, input_pos, freqs_cis, mask, context, context_freqs_cis, cross_attention_mask, skip_in_x)
x = layer(
x,
c,
input_pos,
freqs_cis,
mask,
context,
context_freqs_cis,
cross_attention_mask,
skip_in_x,
unpad_data=unpad_data,
)
if self.uvit_skip_connection and i in self.layers_emit_skip:
skip_in_x_list.append(x)
x = self.norm(x, c)
@@ -169,11 +181,18 @@ class TransformerBlock(nn.Module):
context_freqs_cis: Tensor | None = None,
cross_attention_mask: Tensor | None = None,
skip_in_x: Tensor | None = None,
unpad_data: tuple[Tensor, Tensor] | None = None,
) -> Tensor:
c = None if self.time_as_token else c
if self.uvit_skip_connection and skip_in_x is not None:
x = self.skip_in_linear(torch.cat([x, skip_in_x], dim=-1))
h = x + self.attention(self.attention_norm(x, c), freqs_cis, mask, input_pos)
h = x + self.attention(
self.attention_norm(x, c),
freqs_cis,
mask,
input_pos,
unpad_data=unpad_data,
)
out = h + self.feed_forward(self.ffn_norm(h, c))
return out
@@ -218,6 +237,7 @@ class Attention(nn.Module):
input_pos: Tensor | None = None,
context: Tensor | None = None,
context_freqs_cis: Tensor | None = None,
unpad_data: tuple[Tensor, Tensor] | None = None,
) -> Tensor:
bsz, seqlen, _ = x.shape
@@ -252,7 +272,37 @@ class Attention(nn.Module):
# building AttentionMetadata entirely — this avoids the backend's
# `torch.any(~mask)` D2H sync per ODE step and keeps the forward
# CUDA-graph capturable.
if getattr(self, "_assume_full_mask", False):
full_mask_state = getattr(self, "_assume_full_mask", None)
packed_varlen = (
unpad_data is not None
and context is None
and q.device.type == "cuda"
and q.dtype != torch.float32
and hasattr(getattr(self.diff_attn, "attention", None), "_forward_varlen_packed")
)
if packed_varlen:
indices, cu_seqlens = unpad_data
packed_q = q.flatten(0, 1).index_select(0, indices).unsqueeze(0)
packed_k = k.flatten(0, 1).index_select(0, indices).unsqueeze(0)
packed_v = v.flatten(0, 1).index_select(0, indices).unsqueeze(0)
packed_metadata = AttentionMetadata(
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": seqlen,
"max_seqlen_k": seqlen,
}
)
packed_y = self.diff_attn(
packed_q,
packed_k,
packed_v,
attn_metadata=packed_metadata,
)
flat_y = torch.zeros_like(q.flatten(0, 1))
flat_y.index_copy_(0, indices, packed_y.squeeze(0))
y = flat_y.view_as(q)
elif full_mask_state is True:
attn_metadata = None
else:
attn_mask = mask
@@ -260,8 +310,15 @@ class Attention(nn.Module):
# (B, 1, T, T) → (B, T) — take the first query row;
# all query positions share the same padding pattern.
attn_mask = attn_mask[:, 0, 0, :]
attn_metadata = AttentionMetadata(attn_mask=attn_mask) if attn_mask is not None else None
y = self.diff_attn(q, k, v, attn_metadata=attn_metadata)
attn_metadata = (
AttentionMetadata(
attn_mask=attn_mask,
)
if attn_mask is not None
else None
)
if not packed_varlen:
y = self.diff_attn(q, k, v, attn_metadata=attn_metadata)
y = y.flatten(2, 3)
y = self.wo(y)
@@ -3,6 +3,7 @@
import torch
from torch import nn
from torch.nn import functional as F
from vllm_omni.model_executor.models.indextts2.s2mel.modules.encodec import SConv1d
@@ -53,14 +54,82 @@ class WN(torch.nn.Module):
res_skip_layer = conv1d_type(hidden_channels, res_skip_channels, 1, norm="weight_norm", causal=causal)
self.res_skip_layers.append(res_skip_layer)
# The vectorized ragged path rebuilds logical right-reflect values in
# place and therefore requires each row to be strictly longer than the
# widest right padding. The decoder keeps shorter rows on the original
# exact-length singleton path, preserving SConv1d's existing behavior.
self.ragged_min_length = 1 + max(
((layer.conv.conv.kernel_size[0] - 1) * layer.conv.conv.dilation[0] + 1 - layer.conv.conv.stride[0]) // 2
for layer in self.in_layers
)
@staticmethod
def _logical_reflect_in_layer(
in_layer: SConv1d,
x: torch.Tensor,
logical_lengths: torch.Tensor,
) -> torch.Tensor:
"""Run a WaveNet input convolution at each row's logical boundary.
``SConv1d`` reflects at the physical tensor boundary. In a padded
request batch, shorter rows end before that boundary, so their right
reflection values must be rebuilt before the convolution. This path is
intentionally WaveNet-specific: its input layers are non-causal,
reflect-padded, stride-one convolutions. The decoder keeps rows that
are not strictly longer than the right reflection width on the
original singleton path.
"""
conv = in_layer.conv.conv
stride = conv.stride[0]
dilation = conv.dilation[0]
effective_kernel = (conv.kernel_size[0] - 1) * dilation + 1
if in_layer.causal or in_layer.pad_mode != "reflect" or stride != 1:
raise RuntimeError(
"Ragged WaveNet batching requires non-causal reflect-padded stride-one input convolutions"
)
padding_total = effective_kernel - stride
padding_right = padding_total // 2
padding_left = padding_total - padding_right
padded = F.pad(x, (padding_left, padding_right), mode="reflect")
if padding_right:
offsets = torch.arange(
padding_right,
device=x.device,
dtype=torch.long,
).unsqueeze(0)
lengths = logical_lengths.to(device=x.device, dtype=torch.long).reshape(-1, 1)
source_positions = padding_left + lengths - 2 - offsets
target_positions = padding_left + lengths + offsets
source_indices = source_positions.unsqueeze(1).expand(-1, x.shape[1], -1)
target_indices = target_positions.unsqueeze(1).expand(-1, x.shape[1], -1)
reflected = torch.gather(padded, dim=2, index=source_indices)
padded.scatter_(dim=2, index=target_indices, src=reflected)
return in_layer.conv(padded)
def forward(self, x, x_mask, g=None, **kwargs):
output = torch.zeros_like(x)
logical_lengths = None
if getattr(self, "_assume_full_mask", None) is False:
# Under CFG, x_mask has already been repeated from B to 2B in the
# same [conditional rows, unconditional rows] order as x.
logical_lengths = x_mask[:, 0, :].sum(dim=-1)
if g is not None:
g = self.cond_layer(g)
for i in range(self.n_layers):
x_in = self.in_layers[i](x)
if logical_lengths is None:
x_in = self.in_layers[i](x)
else:
x_in = self._logical_reflect_in_layer(
self.in_layers[i],
x,
logical_lengths,
)
if g is not None:
cond_offset = i * 2 * self.hidden_channels
g_l = g[:, cond_offset : cond_offset + 2 * self.hidden_channels, :]
@@ -0,0 +1,328 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Text preparation shared by IndexTTS 2.5 prompt sizing and model prefill."""
from __future__ import annotations
import re
from collections.abc import Callable
from functools import lru_cache
from .tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
encode_indextts25_text,
lang_to_token,
normalize_language_code,
)
PRONUNCIATION_ANNOTATION_PATTERN = re.compile(r"<([^|>\n]+)\|([^>\n]+)>")
_SPECIAL_TOKEN_PATTERN = re.compile(r"<\|([^|]+)\|>")
_PINYIN_TONE_PATTERN = re.compile(
r"(?<![a-z])"
r"((?:[bpmfdtnlgkhjqxzcsryw]|[zcs]h)?"
r"(?:[aeiouüv]|[ae]i|u[aio]|ao|ou|i[aue]|[uüv]e|[uvü]ang?|"
r"uai|[aeiuv]n|[aeio]ng|ia[no]|i[ao]ng)|ng|er)([1-5])",
re.IGNORECASE,
)
_NAME_PATTERN = re.compile(r"[\u4e00-\u9fff]+(?:[-·—][\u4e00-\u9fff]+){1,2}")
_TECH_TERM_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+")
_ENGLISH_CONTRACTION_PATTERN = re.compile(
r"(what|where|who|which|how|t?here|it|s?he|that|this)'s",
re.IGNORECASE,
)
_EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[a-zA-Z]+$")
_CHAR_REPLACEMENTS = {
":": ",",
";": ",",
";": ",",
",": ",",
"。": ".",
"!": "!",
"?": "?",
"\n": " ",
"·": "-",
"、": ",",
"...": "…",
",,,": "…",
",,,": "…",
"……": "…",
"“": "'",
"”": "'",
'"': "'",
"‘": "'",
"’": "'",
"(": "'",
")": "'",
"(": "'",
")": "'",
"《": "'",
"》": "'",
"【": "'",
"】": "'",
"[": "'",
"]": "'",
"—": "-",
"~": "-",
"~": "-",
"「": "'",
"」": "'",
":": ",",
}
# Deliberately prefer the longest replacement. This diverges from the
# insertion-ordered upstream regex so ``,,,`` becomes one ellipsis, not ``,,,``.
_CLEAN_PATTERN = re.compile("|".join(re.escape(key) for key in sorted(_CHAR_REPLACEMENTS, key=len, reverse=True)))
def _is_kana(text: str) -> bool:
return bool(re.fullmatch(r"[\u3040-\u309f]+", text) or re.fullmatch(r"[\u30a0-\u30ff]+", text))
def apply_pronunciation_annotations(text: str) -> str:
"""Convert ``<word|pronunciation>`` to the official special-token form."""
def replace(match: re.Match[str]) -> str:
word = match.group(1)
pronunciation = match.group(2).upper()
if _is_kana(pronunciation):
return f" {pronunciation} "
token = "SPECIAL_TOKEN_2" if re.search(r"[\u4e00-\u9fff]", word) else "SPECIAL_TOKEN_1"
return f"<|{token}|>{pronunciation}<|{token}|>"
return PRONUNCIATION_ANNOTATION_PATTERN.sub(replace, text)
def clean_indextts25_text(text: str) -> str:
return _CLEAN_PATTERN.sub(lambda match: _CHAR_REPLACEMENTS[match.group()], text)
@lru_cache(maxsize=2)
def _load_wetext_normalizer(lang: str):
import platform
if platform.system() == "Linux":
try:
if lang == "zh":
from tn.chinese.normalizer import Normalizer
else:
from tn.english.normalizer import Normalizer
except ModuleNotFoundError as exc:
if exc.name != "tn":
raise
raise RuntimeError(
f"IndexTTS 2.5 text normalization backend for {lang!r} is unavailable. "
"Install it with pip install 'vllm-omni[indextts2]'"
) from exc
if lang == "zh":
return Normalizer(
remove_interjections=False,
remove_erhua=False,
overwrite_cache=False,
)
return Normalizer(overwrite_cache=False)
try:
from wetext import Normalizer
except ModuleNotFoundError as exc:
if exc.name != "wetext":
raise
raise RuntimeError(
f"IndexTTS 2.5 text normalization backend for {lang!r} is unavailable. "
"Install it with pip install 'vllm-omni[indextts2]'"
) from exc
if lang == "zh":
return Normalizer(remove_erhua=False, lang="zh", operator="tn")
return Normalizer(lang="en", operator="tn")
def _uses_chinese_normalizer(text: str) -> bool:
has_chinese = bool(re.search(r"[\u4e00-\u9fff]", text))
has_alpha = bool(re.search(r"[a-zA-Z]", text))
if has_chinese or not has_alpha or _EMAIL_PATTERN.fullmatch(text):
return True
return bool(_PINYIN_TONE_PATTERN.search(text))
def _protect_values(
text: str,
values: list[str],
*,
prefix: str,
) -> tuple[str, list[str]]:
unique_values = sorted(set(values), key=len, reverse=True)
for index, value in enumerate(unique_values):
marker = f"<{prefix}_{chr(ord('a') + index)}>"
text = text.replace(value, marker)
return text, unique_values
def _restore_values(
text: str,
values: list[str],
*,
prefix: str,
transform: Callable[[str], str] | None = None,
) -> str:
for index, value in enumerate(values):
marker = f"<{prefix}_{chr(ord('a') + index)}>"
text = text.replace(marker, transform(value) if transform else value)
return text
def _correct_pinyin(pinyin: str) -> str:
if not pinyin or pinyin[0] not in "jqxJQX":
return pinyin.upper()
return re.sub(
r"([jqx])[uü](n|e|an)*(\d)",
r"\g<1>v\g<2>\g<3>",
pinyin,
flags=re.IGNORECASE,
).upper()
def _normalize_zh_or_en(text: str) -> str:
normalizer_lang = "zh" if _uses_chinese_normalizer(text) else "en"
text = _ENGLISH_CONTRACTION_PATTERN.sub(r"\1 is", text)
tech_terms = _TECH_TERM_PATTERN.findall(text)
for term in sorted(set(tech_terms), key=len, reverse=True):
text = text.replace(term, term.replace("-", "<H>"))
pinyin_values: list[str] = []
name_values: list[str] = []
if normalizer_lang == "zh":
pinyin_values = [match.group(0) for match in _PINYIN_TONE_PATTERN.finditer(text)]
text, pinyin_values = _protect_values(
text,
pinyin_values,
prefix="pinyin",
)
text, name_values = _protect_values(
text,
_NAME_PATTERN.findall(text),
prefix="n",
)
result = _load_wetext_normalizer(normalizer_lang).normalize(text)
result = _restore_values(result, name_values, prefix="n")
result = _restore_values(
result,
pinyin_values,
prefix="pinyin",
transform=_correct_pinyin,
)
if tech_terms:
result = re.sub(r"\s*<H>\s*", "-", result)
if normalizer_lang == "zh":
result = result.replace("$", ".")
return clean_indextts25_text(result)
def _normalize_with_official_backend(text: str, lang: str) -> str:
if lang in {"zh", "en", "zhen"}:
return _normalize_zh_or_en(text)
if lang == "es":
# NeMo is optional in the upstream implementation and falls back to
# raw text when its grammar package is unavailable.
try:
from nemo_text_processing.text_normalization.normalize import (
Normalizer,
)
normalizer = Normalizer(input_case="cased", lang="es")
return normalizer.normalize(text, verbose=False)
except ModuleNotFoundError as exc:
if exc.name != "nemo_text_processing":
raise
return text
return text
@lru_cache(maxsize=1)
def _load_japanese_tagger():
try:
import fugashi
import unidic_lite
except ModuleNotFoundError as exc:
if exc.name not in {"fugashi", "unidic_lite"}:
raise
raise RuntimeError(
"IndexTTS 2.5 Japanese preprocessing requires fugashi and unidic-lite. "
"Install them with pip install 'vllm-omni[indextts2]'"
) from exc
_ = unidic_lite
return fugashi.Tagger()
def _process_japanese_text(text: str) -> str:
"""Tokenize Japanese like the official g2p_ratio=0 frontend."""
def process_segment(segment: str) -> str:
return " ".join(token.surface for token in _load_japanese_tagger()(segment))
parts = re.split(r"( +)", text)
return "".join(process_segment(part) if part.strip() else part for part in parts)
def normalize_indextts25_text(
text: str,
*,
lang: str,
text_normalization: bool = True,
normalizer: Callable[[str, str], str] | None = None,
) -> str:
"""Apply the deterministic portion of the official preprocessing order.
A caller-provided normalizer is used for number/date expansion. Keeping it
injectable lets prompt sizing and Talker prefill use the same normalizer
instance and avoids constructing heavyweight TN grammars per request.
"""
lang = normalize_language_code(lang)
text = clean_indextts25_text(text)
if text_normalization:
normalize = normalizer or _normalize_with_official_backend
text = normalize(text, lang)
if lang in {"ja", "zh", "en", "zhen"}:
text = text.lower()
elif lang == "es":
text = text.upper()
text = apply_pronunciation_annotations(text)
if lang == "ja":
text = _process_japanese_text(text)
return _SPECIAL_TOKEN_PATTERN.sub(
lambda match: f"<|{match.group(1).upper()}|>",
text,
)
def prepare_indextts25_text(
text: str,
*,
lang: str,
model_dir: str,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
text_normalization: bool = True,
normalizer: Callable[[str, str], str] | None = None,
) -> tuple[list[int], int]:
if not text or not text.strip():
raise ValueError("IndexTTS 2.5 text cannot be empty")
lang_code = normalize_language_code(lang)
# When no heavyweight TN callback is installed, punctuation/casing and
# annotations are still deterministic. The Talker installs the official
# language-specific normalizer before enabling text_normalization.
normalized = normalize_indextts25_text(
text,
lang=lang_code,
text_normalization=text_normalization,
normalizer=normalizer,
)
# ``zhen`` is not a registered special token. Keep its official literal
# prefix so tiktoken encodes it through existing mergeable ranks; remapping
# it to ``common`` would change both the text ABI and preprocessing route.
prefixed = f"<|{lang_code}|> {normalized}"
token_ids = encode_indextts25_text(
prefixed,
model_dir=model_dir,
tokenizer_file=tokenizer_file,
)
return token_ids, lang_to_token(lang_code)
@@ -0,0 +1,286 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""IndexTTS 2.5 multilingual tiktoken vocabulary.
The vocabulary layout mirrors the official IndexTTS 2.5 tokenizer: 58,836
mergeable ranks followed by 1,673 registered special tokens.
"""
from __future__ import annotations
import base64
from functools import lru_cache
from pathlib import Path
from typing import Any
INDEXTTS25_TOKENIZER_FILE = "multilingual_zh_ja_yue_char_del.tiktoken"
INDEXTTS25_MERGEABLE_RANKS = 58836
INDEXTTS25_VOCAB_SIZE = 60509
INDEXTTS25_NUM_SPECIAL_LANGUAGES = 99
# Order is checkpoint ABI: lang_embedding rows were trained against these IDs.
LANGUAGES = (
"en",
"zh",
"de",
"es",
"ru",
"ko",
"fr",
"ja",
"pt",
"tr",
"pl",
"ca",
"nl",
"ar",
"sv",
"it",
"id",
"hi",
"fi",
"vi",
"he",
"uk",
"el",
"ms",
"cs",
"ro",
"da",
"hu",
"ta",
"no",
"th",
"ur",
"hr",
"bg",
"lt",
"la",
"mi",
"ml",
"cy",
"sk",
"te",
"fa",
"lv",
"bn",
"sr",
"az",
"sl",
"kn",
"et",
"mk",
"br",
"eu",
"is",
"hy",
"ne",
"mn",
"bs",
"kk",
"sq",
"sw",
"gl",
"mr",
"pa",
"si",
"km",
"sn",
"yo",
"so",
"af",
"oc",
"ka",
"be",
"tg",
"sd",
"gu",
"am",
"yi",
"lo",
"uz",
"fo",
"ht",
"ps",
"tk",
"nn",
"mt",
"sa",
"lb",
"my",
"bo",
"tl",
"mg",
"as",
"tt",
"haw",
"ln",
"ha",
"ba",
"jw",
"su",
"yue",
"minnan",
"wuyu",
"dialect",
"zh/en",
"en/zh",
"common",
)
LANGUAGE_DICT = {lang: index for index, lang in enumerate(LANGUAGES)}
# ``zhen`` is an official mixed Chinese/English frontend mode, but it has no
# dedicated checkpoint language-embedding row or registered special token.
_FRONTEND_LANGUAGE_TO_EMBEDDING = {"zhen": "common"}
TO_LANGUAGE_CODE = {
"english": "en",
"chinese": "zh",
"german": "de",
"spanish": "es",
"russian": "ru",
"korean": "ko",
"french": "fr",
"japanese": "ja",
"portuguese": "pt",
"turkish": "tr",
"cantonese": "yue",
# Intentional vLLM-Omni convenience alias; upstream would route the name
# ``mandarin`` to the ``common`` language-embedding row.
"mandarin": "zh",
"burmese": "my",
"valencian": "ca",
"flemish": "nl",
"haitian": "ht",
"letzeburgesch": "lb",
"pushto": "ps",
"panjabi": "pa",
"moldavian": "ro",
"moldovan": "ro",
"sinhalese": "si",
"castilian": "es",
}
_AUDIO_EVENTS = (
"ASR",
"AED",
"SER",
"Speech",
"/Speech",
"BGM",
"/BGM",
"Laughter",
"/Laughter",
"Applause",
"/Applause",
)
_EMOTIONS = ("HAPPY", "SAD", "ANGRY", "NEUTRAL")
_TTS_VOCAL_TOKENS = (
"TTS/B",
"TTS/O",
"TTS/Q",
"TTS/A",
"TTS/CO",
"TTS/CL",
"TTS/H",
*(f"TTS/SP{i:02d}" for i in range(1, 14)),
)
INDEXTTS25_SPECIAL_TOKENS = (
"<|endoftext|>",
"<|startoftranscript|>",
*(f"<|{lang}|>" for lang in LANGUAGES[:INDEXTTS25_NUM_SPECIAL_LANGUAGES]),
*(f"<|{event}|>" for event in _AUDIO_EVENTS),
*(f"<|{emotion}|>" for emotion in _EMOTIONS),
"<|translate|>",
"<|transcribe|>",
"<|startoflm|>",
"<|startofprev|>",
"<|nospeech|>",
"<|notimestamps|>",
*(f"<|SPECIAL_TOKEN_{i}|>" for i in range(1, 31)),
*(f"<|{token}|>" for token in _TTS_VOCAL_TOKENS),
*(f"<|{i * 0.02:.2f}|>" for i in range(1501)),
)
_PATTERN = r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
def normalize_language_code(lang: str) -> str:
normalized = lang.strip().lower()
normalized = TO_LANGUAGE_CODE.get(normalized, normalized)
if normalized not in LANGUAGE_DICT and normalized not in _FRONTEND_LANGUAGE_TO_EMBEDDING:
supported = (*LANGUAGES, *sorted(_FRONTEND_LANGUAGE_TO_EMBEDDING))
raise ValueError(f"Unsupported IndexTTS 2.5 language {lang!r}; expected one of {', '.join(supported)}")
return normalized
def lang_to_token(lang: str) -> int:
normalized = normalize_language_code(lang)
embedding_language = _FRONTEND_LANGUAGE_TO_EMBEDDING.get(normalized, normalized)
return LANGUAGE_DICT[embedding_language]
def resolve_indextts25_tokenizer_file(
model_dir: str,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
) -> str:
model_path = Path(model_dir).expanduser()
for root in (model_path, model_path / "checkpoints"):
direct = root / tokenizer_file
if direct.is_file():
return str(direct)
if model_path.is_file() and model_path.name == tokenizer_file:
return str(model_path)
try:
from huggingface_hub import hf_hub_download
return hf_hub_download(repo_id=model_dir, filename=tokenizer_file)
except Exception as exc:
raise FileNotFoundError(
f"Could not resolve IndexTTS 2.5 tokenizer {tokenizer_file!r} from {model_dir!r}"
) from exc
@lru_cache(maxsize=16)
def load_indextts25_encoding(
model_dir: str,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
) -> Any:
import tiktoken
vocab_path = resolve_indextts25_tokenizer_file(model_dir, tokenizer_file)
with open(vocab_path, encoding="utf-8") as vocab:
ranks = {
base64.b64decode(token): int(rank) for line in vocab if line.strip() for token, rank in (line.split(),)
}
if len(ranks) != INDEXTTS25_MERGEABLE_RANKS:
raise ValueError(
f"IndexTTS 2.5 tokenizer {vocab_path!r} has {len(ranks)} "
f"mergeable ranks; expected {INDEXTTS25_MERGEABLE_RANKS}"
)
special_tokens = {token: len(ranks) + index for index, token in enumerate(INDEXTTS25_SPECIAL_TOKENS)}
explicit_n_vocab = len(ranks) + len(special_tokens)
if explicit_n_vocab != INDEXTTS25_VOCAB_SIZE:
raise ValueError(
f"IndexTTS 2.5 tokenizer builds vocab size {explicit_n_vocab}; expected {INDEXTTS25_VOCAB_SIZE}"
)
return tiktoken.Encoding(
name=Path(vocab_path).name,
explicit_n_vocab=explicit_n_vocab,
pat_str=_PATTERN,
mergeable_ranks=ranks,
special_tokens=special_tokens,
)
def encode_indextts25_text(
text: str,
*,
model_dir: str,
tokenizer_file: str = INDEXTTS25_TOKENIZER_FILE,
) -> list[int]:
encoding = load_indextts25_encoding(model_dir, tokenizer_file)
return encoding.encode(text, allowed_special="all")
@@ -369,6 +369,17 @@ _OMNI_MODELS = {
"indextts2_s2mel_decoder",
"IndexTTS2S2MelDecoder",
),
## IndexTTS 2.5 (shared implementations, versioned HF config)
"IndexTTS25TalkerForConditionalGeneration": (
"indextts2",
"indextts2_talker",
"IndexTTS2TalkerForConditionalGeneration",
),
"IndexTTS25S2MelDecoder": (
"indextts2",
"indextts2_s2mel_decoder",
"IndexTTS2S2MelDecoder",
),
## Ming-flash-omni-2.0
"MingFlashOmniForConditionalGeneration": (
"ming_flash_omni",
@@ -26,21 +26,21 @@ def _cpu_view(tensor: torch.Tensor) -> torch.Tensor:
def _strip_stop_token(
codes: torch.Tensor,
latent: torch.Tensor,
latent: torch.Tensor | None,
stop_mel_token: int = STOP_MEL_TOKEN,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor]:
"""Strip at the first stop token, matching official IndexTTS2 v2.
Returns: (codes [B, T'], latent [B, T', D], code_lens [B]).
Returns codes, optional aligned latent, and code lengths.
"""
if codes.ndim == 1:
codes = codes.unsqueeze(0)
if latent.ndim == 2:
if latent is not None and latent.ndim == 2:
latent = latent.unsqueeze(0)
if codes.device.type != "cpu":
codes = codes.detach().cpu()
if latent.device.type != "cpu":
if latent is not None and latent.device.type != "cpu":
latent = latent.detach().cpu()
device = codes.device
@@ -50,8 +50,6 @@ def _strip_stop_token(
for i in range(codes.shape[0]):
code = codes[i]
lat = latent[i]
stop_mask = (code == stop_mel_token).nonzero(as_tuple=False)
if stop_mask.numel() > 0:
valid_len = int(stop_mask[0].item())
@@ -59,27 +57,50 @@ def _strip_stop_token(
valid_len = int(code.shape[0])
code_lens.append(valid_len)
codes_out.append(code[:valid_len])
latent_out.append(lat[:valid_len])
if latent is not None:
latent_out.append(latent[i, :valid_len])
max_len = max(code_lens) if code_lens else 0
if max_len == 0:
empty_codes = torch.zeros(codes.shape[0], 0, dtype=torch.long, device=device)
empty_latent = (
torch.zeros(
codes.shape[0],
0,
latent.shape[-1],
device=device,
dtype=latent.dtype,
)
if latent is not None
else None
)
return (
torch.zeros(codes.shape[0], 0, dtype=torch.long, device=device),
torch.zeros(codes.shape[0], 0, latent.shape[-1], device=device, dtype=latent.dtype),
torch.zeros(codes.shape[0], dtype=torch.long, device=device),
empty_codes,
empty_latent,
torch.zeros(
codes.shape[0],
dtype=torch.long,
device=device,
),
)
padded_codes = torch.zeros((len(codes_out), max_len), dtype=torch.long, device=device)
padded_latent = torch.zeros(
len(latent_out),
max_len,
latent_out[0].shape[-1],
device=device,
dtype=latent_out[0].dtype,
padded_latent = (
torch.zeros(
len(latent_out),
max_len,
latent_out[0].shape[-1],
device=device,
dtype=latent_out[0].dtype,
)
if latent_out
else None
)
for i, (c, lat) in enumerate(zip(codes_out, latent_out)):
for i, c in enumerate(codes_out):
padded_codes[i, : c.shape[0]] = c
padded_latent[i, : lat.shape[0]] = lat
if padded_latent is not None:
lat = latent_out[i]
padded_latent[i, : lat.shape[0]] = lat
return padded_codes, padded_latent, torch.tensor(code_lens, dtype=torch.long, device=device)
@@ -105,26 +126,33 @@ def _normalize_latent_sequence(latent: torch.Tensor) -> torch.Tensor:
def _build_s2mel_additional_information(
mel_codes: torch.Tensor,
latent: torch.Tensor,
latent: torch.Tensor | None,
meta: dict[str, Any],
*,
use_gpt_latent: bool,
context: str,
) -> dict[str, Any]:
"""Build the Stage-1 S2Mel tensor contract shared by legacy and connector paths."""
mel_codes_clean, latent_clean, code_lens = _strip_stop_token(mel_codes, latent)
additional_information = {
"latent": _cpu_view(latent_clean),
"mel_codes": _cpu_view(mel_codes_clean),
"code_lens": _cpu_view(code_lens),
"use_gpt_latent": use_gpt_latent,
}
if use_gpt_latent:
if latent_clean is None:
raise ValueError(f"{context} requires GPT latent but none was provided")
additional_information["latent"] = _cpu_view(latent_clean)
duration_factor = meta.get("duration_factor", 1.0)
additional_information["duration_factor"] = 1.0 if duration_factor is None else float(duration_factor)
for key in ("S_ref", "ref_mel", "style"):
val = meta.get(key)
if isinstance(val, torch.Tensor):
additional_information[key] = _cpu_view(val)
else:
logger.warning("[%s] %s MISSING — Stage 1 will use fallback", context, key)
if not isinstance(val, torch.Tensor) or val.numel() == 0:
raise ValueError(f"{context} requires non-empty IndexTTS conditioning field {key}")
additional_information[key] = _cpu_view(val)
return additional_information
@@ -143,6 +171,12 @@ def _request_id(request: Any) -> str:
return str(getattr(request, "external_req_id", None) or getattr(request, "request_id", "?"))
def _request_seed(request: Any) -> int | None:
sampling_params = getattr(request, "sampling_params", None)
seed = getattr(sampling_params, "seed", None)
return int(seed) if seed is not None else None
def talker2s2mel_token_only(
source_outputs: list[Any],
prompt: Any = None,
@@ -177,36 +211,78 @@ def talker2s2mel_full_payload(
del transfer_manager
rid = _request_id(request)
if not isinstance(pooling_output, dict):
logger.warning("talker2s2mel_full_payload: pooling_output not a dict for req=%s", rid)
return None
raise ValueError(f"IndexTTS Stage 0 payload must be a dict for req={rid}")
use_gpt_latent_value = _get_payload_value(
pooling_output,
"meta.use_gpt_latent",
"meta",
"use_gpt_latent",
)
if use_gpt_latent_value is None:
raise ValueError(f"IndexTTS payload is missing meta.use_gpt_latent for req={rid}")
use_gpt_latent = bool(use_gpt_latent_value)
payload_latent = _get_payload_value(
pooling_output,
"hidden_states.latent",
"hidden_states",
"latent",
)
payload_mel_codes = _get_payload_value(
pooling_output,
"codes.mel",
"codes",
"mel",
)
if use_gpt_latent:
mel_codes = payload_mel_codes
latent = payload_latent
else:
output_token_ids = list(getattr(request, "output_token_ids", None) or [])
if not output_token_ids:
raise ValueError(f"IndexTTS code-only payload has no completed output_token_ids for req={rid}")
mel_codes = torch.tensor(output_token_ids, dtype=torch.long)
latent = None
mel_codes = _get_payload_value(pooling_output, "codes.mel", "codes", "mel")
if not isinstance(mel_codes, torch.Tensor) or mel_codes.numel() == 0:
logger.warning("talker2s2mel_full_payload: missing codes.mel for req=%s", rid)
return None
latent = _get_payload_value(pooling_output, "hidden_states.latent", "hidden_states", "latent")
if not isinstance(latent, torch.Tensor) or latent.numel() == 0:
logger.warning("talker2s2mel_full_payload: missing hidden_states.latent for req=%s", rid)
return None
raise ValueError(f"IndexTTS payload is missing mel codes for req={rid} (use_gpt_latent={use_gpt_latent})")
mel_seq = _normalize_mel_sequence(mel_codes)
latent_seq = _normalize_latent_sequence(latent)
if mel_seq.numel() == 0 or latent_seq.numel() == 0:
logger.warning("talker2s2mel_full_payload: empty normalized mel/latent for req=%s", rid)
return None
if mel_seq.numel() == 0:
raise ValueError(f"IndexTTS payload has an empty mel sequence for req={rid}")
common_len = min(int(mel_seq.shape[0]), int(latent_seq.shape[0]))
if common_len <= 0:
logger.warning("talker2s2mel_full_payload: no common mel/latent length for req=%s", rid)
return None
mel_seq = mel_seq[:common_len]
latent_seq = latent_seq[:common_len]
latent_seq: torch.Tensor | None = None
if use_gpt_latent:
if not isinstance(latent, torch.Tensor) or latent.numel() == 0:
raise ValueError(f"IndexTTS latent payload is missing hidden_states.latent for req={rid}")
latent_seq = _normalize_latent_sequence(latent)
if latent_seq.numel() == 0:
raise ValueError(f"IndexTTS payload has an empty latent sequence for req={rid}")
if mel_seq.shape[0] != latent_seq.shape[0]:
raise ValueError(
f"IndexTTS mel/latent length mismatch for req={rid}: "
f"mel={mel_seq.shape[0]} latent={latent_seq.shape[0]}"
)
meta = {
"S_ref": _get_payload_value(pooling_output, "meta.S_ref", "meta", "S_ref"),
"ref_mel": _get_payload_value(pooling_output, "meta.ref_mel", "meta", "ref_mel"),
"style": _get_payload_value(pooling_output, "meta.style", "meta", "style"),
"duration_factor": _get_payload_value(
pooling_output,
"meta.duration_factor",
"meta",
"duration_factor",
),
}
return _build_s2mel_additional_information(mel_seq, latent_seq, meta, context="full_payload")
additional_information = _build_s2mel_additional_information(
mel_seq,
latent_seq,
meta,
use_gpt_latent=use_gpt_latent,
context="full_payload",
)
seed = _request_seed(request)
if seed is not None:
additional_information["seed"] = seed
return additional_information
+2
View File
@@ -9,6 +9,7 @@ from vllm_omni.model_extras.registry import (
get_extra_body_params,
get_extra_output_params,
get_model_class_name,
get_output_tensor_range,
get_x_to_text_model_family,
should_init_extra_args_for_non_diffusion_stages,
)
@@ -21,6 +22,7 @@ __all__ = [
"get_extra_body_params",
"get_extra_output_params",
"get_model_class_name",
"get_output_tensor_range",
"get_x_to_text_model_family",
"should_init_extra_args_for_non_diffusion_stages",
]
-26
View File
@@ -3,32 +3,6 @@
from __future__ import annotations
from typing import Any
def build_text_to_image_prompt(
prompt: str,
negative_prompt: str | None,
height: int | None = None,
width: int | None = None,
) -> dict[str, Any]:
"""Build a Cosmos3 T2I prompt by selecting image output modality.
Cosmos3 uses the same pipeline class for T2I and video modes. The OpenAI
image endpoint selects T2I by adding ``modalities=["image"]``. ``height``
and ``width`` are accepted for registry compatibility; sizing travels
through sampling params instead of the prompt payload.
"""
del height, width
text_prompt: dict[str, Any] = {
"prompt": prompt,
"modalities": ["image"],
}
if negative_prompt is not None:
text_prompt["negative_prompt"] = negative_prompt
return text_prompt
COSMOS3_EXTRA_BODY_PARAMS = frozenset(
{
"flow_shift",
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more