Compare commits

...
Author SHA1 Message Date
Gao Han 6e0bd92f6d Merge branch 'main' into fix/rebase-pipeline-upload-all-e2e 2026-06-22 22:41:00 +08:00
Weiming Liao fb0f17858f [CI][NPU] Update VLLM ASCEND IMAGE (#4602)
Signed-off-by: Weiming Liao <liaowm5@gmail.com>
2026-06-22 22:33:06 +08:00
bjf-frz 2447740308 [Perf]perf: skip unused Cosmos3 I2V conditioning latents (#4614)
Signed-off-by: bjf-frz <frz123db@gmail.com>
2026-06-22 09:51:47 +00:00
汪志鹏 a10eac0f8c [Feature]: Migrate magi human example (#4572)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
2026-06-22 15:17:00 +08:00
+2 ec4ec0f604 Add SSE stream_format support for audio speech streaming (#4490)
Signed-off-by: syd520zy <529477025@qq.com>
Signed-off-by: faceless void <100186390+syd520zy@users.noreply.github.com>
Signed-off-by: Nick Cao <ncao@redhat.com>
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
Signed-off-by: kTorp <Kristoffer.Torp@amd.com>
Signed-off-by: Junhong Liu <98734602+LJH-LBJ@users.noreply.github.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Signed-off-by: Hyoseop Song <crad_on25@naver.com>
Signed-off-by: Hyoseop Song  <crad_on25@naver.com>
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Chendi.Xue <chendi.xue@intel.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Kristoffer <kristoffer.torp@amd.com>
Co-authored-by: Junhong Liu <ljh_lbj@163.com>
Co-authored-by: lishunyang <lishunyang12@163.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Hyoseop Song <crad_on25@naver.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-06-22 06:42:25 +00:00
ad07f98b73 [Feat] Streaming TTS word-level timestamps via forced aligner (#4034)
Signed-off-by: wjinxu <1299461899@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:51:45 +00:00
Yihong JinandClaude Opus 4.8 a60242092a [Bugfix][Qwen3-Omni] Pass Thinker lm_head prefix so NVFP4 exclude-list is honored (#4528)
Signed-off-by: Yihong Jin <yihongjin404@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:16:44 +08:00
WeiQing Chen 991b1ec752 Update WeChat group QR code (#4601)
Signed-off-by: David Chen <530634352@qq.com>
2026-06-22 10:30:22 +08:00
Kwokho TsuiandKwokho Tsui 6bdd85b384 [Doc] validate Cosmos3-Super online FP8 (#4584)
Signed-off-by: Kwokho Tsui <kh_tsui@163.com>
Co-authored-by: Kwokho Tsui <kh_tsui@163.com>
2026-06-22 02:24:04 +00:00
LancerandHongsheng Liu 36bd8029f3 [chore][skills]: tightens the add-diffusion-model skill under .claude/skills to better match the current vllm-omni codebase and repo conventions (#4110)
Signed-off-by: Lancer <maruixiang6688@gmail.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-22 10:17:27 +08:00
WeiQing ChenandHongsheng Liu f0d7fe18f2 [Perf][Cosmos3] Optimize I2V conditioning transfer (8.63% faster x-inference) (#4467)
Signed-off-by: david6666666 <530634352@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-22 01:59:03 +00:00
yangyonggit ccb5c53ea5 [Cleanup] Remove dead/stale modules with no repo references (fixes #4009) (#4044)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
2026-06-22 09:23:04 +08:00
Optance 8efd9d832d [Bugfix] Fix Fish Speech kvcache attention KV cache unpacking for vLLM 0.23.0 (#4428)
Signed-off-by: nagelanping <xiao.yi.ming@foxmail.com>
2026-06-22 09:22:41 +08:00
Alex Brooksand汪志鹏 7ff11d8d0f [Perf/Fix] Reimplement Batched CFG Forward for Bagel (#4098)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
2026-06-22 07:37:21 +08:00
MaciejBalaNVandHongsheng Liu fc32d9fe6f Improve Cosmos3 v2v quality (#4514)
Signed-off-by: Maciej Bala <mbala@nvidia.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-21 22:20:30 +08:00
rein yang afbcc51672 [Model] Aura support - Non async chunk path (#4257)
Signed-off-by: Rein Yang <ruiruyang2@gmail.com>
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-06-21 22:08:26 +08:00
Shin 1292c47804 [BugFix]Fix diffusion profile RPC None results (#4372)
Signed-off-by: yixiaoer <shin@yixiaoer.sg>
2026-06-21 17:14:21 +08:00
e241005a71 [Feature] JoyAI-VL-Interaction streaming interaction serving layer (#4575)
Signed-off-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: lishunyang <lishunyang12@163.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
2026-06-21 07:42:51 +00:00
汪志鹏 950e66a44c [Feature]Migrate helios example (#4569)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-06-21 07:37:16 +00:00
MaciejBalaNV a1e1ac0155 Cosmos3 transfer (#4379)
Signed-off-by: Maciej Bala <mbala@nvidia.com>
Signed-off-by: MaciejBalaNV <mbala@nvidia.com>
2026-06-21 09:59:18 +08:00
tzhouamandClaude Opus 4.8 7e7b99b60f [CI][Rebase] Fix Ready/Merge pipeline upload by stripping source_file_dependencies
Build #2233 on the vllm-omni-rebase pipeline failed both "Upload Ready
Pipeline" and "Upload Merge Pipeline" with:

  Pipeline upload rejected: `source_file_dependencies` is not a valid
  property on the `command` step.

`source_file_dependencies` is an uploader-only key understood by
upload_pipeline.py, not by Buildkite. #4478 switched the rebase pipeline's
Ready/Merge steps to raw `buildkite-agent pipeline upload` to run the full
e2e suite (bypassing diff-aware skipping), but that also bypassed the
key-stripping, so Buildkite rejected the YAML.

Add a `--all` flag to upload_pipeline.py that forces the keep-all-steps
path (changed_files=None) while still stripping source_file_dependencies,
and use it for the rebase pipeline's Ready/Merge (and Nightly) uploads.
This restores a valid upload AND retains running the full e2e suite on
main builds. Normal-CI .buildkite/pipeline.yml is unchanged (still
diff-aware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
2026-06-18 17:11:33 +00:00
163 changed files with 12586 additions and 2607 deletions

No files matched your search

+3 -3
View File
@@ -14,7 +14,7 @@ steps:
depends_on: image-build
key: upload-ready-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-ready.yml
- python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
@@ -23,7 +23,7 @@ steps:
depends_on: image-build
key: upload-merge-pipeline
commands:
- buildkite-agent pipeline upload .buildkite/test-merge.yml
- python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
@@ -33,6 +33,6 @@ steps:
key: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1"
commands:
- buildkite-agent pipeline upload .buildkite/test-nightly.yml
- python3 .buildkite/scripts/upload_pipeline.py --upload --all .buildkite/test-nightly.yml
agents:
queue: "cpu_queue_premerge"
+17 -3
View File
@@ -13,13 +13,16 @@ Test pipeline mode (e.g. test-merge.yml):
recognize this uploader-only key).
Usage:
python3 upload_pipeline.py [--upload] <pipeline.yml>
python3 upload_pipeline.py [--upload] [--all] <pipeline.yml>
# Bootstrap (replaces upload_pipeline_with_skip_ci.sh):
python3 upload_pipeline.py --upload .buildkite/pipeline.yml
# Test pipeline (replaces upload_test_pipeline_with_diff_skip.py):
python3 upload_pipeline.py --upload .buildkite/test-merge.yml
# Full suite (rebase pipeline): keep all steps, still strip the uploader-only key:
python3 upload_pipeline.py --upload --all .buildkite/test-merge.yml
"""
from __future__ import annotations
@@ -437,11 +440,17 @@ def resolve_pipeline_path(arg: str) -> Path:
return ROOT / path
def render_pipeline(path: Path) -> str:
def render_pipeline(path: Path, *, force_all: bool = False) -> str:
text = path.read_text(encoding="utf-8")
diff_range = resolve_diff_range()
changed_files = resolve_changed_files()
# ``--all`` forces the keep-all-steps path (no diff-aware skipping) while still
# stripping ``source_file_dependencies``. Used by the rebase pipeline so main builds
# run the full e2e suite (see .buildkite/rebase-pipeline.yaml).
if force_all:
changed_files = None
if BOOTSTRAP_MARKER in text:
rendered = render_bootstrap_pipeline(
text,
@@ -480,6 +489,11 @@ def main() -> int:
action="store_true",
help="Pipe rendered YAML to buildkite-agent pipeline upload",
)
parser.add_argument(
"--all",
action="store_true",
help="Keep all steps (disable diff-aware skipping); still strips source_file_dependencies",
)
args = parser.parse_args()
path = resolve_pipeline_path(args.pipeline)
@@ -487,7 +501,7 @@ def main() -> int:
_log(f"missing pipeline file: {path}")
return 1
rendered = render_pipeline(path)
rendered = render_pipeline(path, force_all=args.all)
if args.upload:
upload_to_buildkite(rendered)
else:
+40 -13
View File
@@ -24,7 +24,7 @@ Check the model's HF repo for `model_index.json`. This determines your path:
| Scenario | How to identify | Migration path |
|----------|----------------|----------------|
| **Already supported** | `_class_name` in `model_index.json` matches a key in `_DIFFUSION_MODELS` in `registry.py` | Skip to Step 5 (test) and Step 7 (docs) |
| **Already supported** | `_class_name` in `model_index.json` matches a key in `_DIFFUSION_MODELS` in `registry.py` | Skip implementation, then validate model-specific examples, tests, and docs as needed |
| **Diffusers-based** | Has standard `model_index.json` with `_diffusers_version`, subfolders for `transformer/`, `vae/`, etc. | Follow **Path A** below |
| **Custom/private repo** | No diffusers `model_index.json`, weights in non-standard format, custom model code in a separate git repo | Follow **Path B** below |
| **Hybrid** | Has some diffusers components (VAE) but custom transformer/fusion | Mix of Path A and Path B |
@@ -52,7 +52,7 @@ vllm_omni/diffusion/models/your_model_name/
2. Replace attention with `vllm_omni.diffusion.attention.layer.Attention` (QKV shape: `[B, seq, heads, head_dim]`).
3. Add `od_config: OmniDiffusionConfig | None = None` to `__init__`.
4. Add `load_weights()` method mapping diffusers weight names to vllm-omni names.
5. Add class attributes: `_repeated_blocks`, `_layerwise_offload_blocks_attr`.
5. Add class attributes for acceleration features such as `_repeated_blocks` and `_layerwise_offload_blocks_attrs` (see [references/transformer-adaptation.md](references/transformer-adaptation.md) for examples).
### A4. Adapt pipeline
@@ -78,13 +78,34 @@ class YourPipeline(nn.Module):
Add post/pre-process functions in the same pipeline file. Register them in `registry.py`.
### A4.1 Add progress bar support (recommended)
For pipelines with a standard denoising loop, prefer the existing progress bar pattern instead of hand-rolled logging.
```python
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
class YourPipeline(nn.Module, ProgressBarMixin):
def forward(self, req: OmniDiffusionRequest) -> DiffusionOutput:
# ... prepare timesteps / latents ...
with self.progress_bar(total=len(timesteps)) as progress_bar:
for i, t in enumerate(timesteps):
# predict noise / scheduler step
latents = ...
progress_bar.update()
return DiffusionOutput(output=output)
```
For custom loop structures, follow `vllm_omni/diffusion/models/progress_bar.py` and existing pipelines using `ProgressBarMixin`.
### A5. Register, test, docs → continue at Step 4 below.
---
## Path B: Custom/Private Repo Model
For models without a diffusers pipeline — weights in custom format, model code in a private repo. Real examples: DreamID-Omni, BAGEL, HunyuanImage3.
For models without a diffusers pipeline — weights in custom format, model code in a private repo. Real examples: DreamID-Omni, BAGEL, HunyuanImage3. See [references/custom-model-patterns.md](references/custom-model-patterns.md) for concrete custom-model integration patterns.
### B1. Understand the reference repo
@@ -134,7 +155,7 @@ Copy the essential model files into `vllm_omni/diffusion/models/<name>/` and ada
### B4. Handle custom weight loading
Custom models have two patterns for weight loading:
Custom models have two common patterns for weight loading:
**Pattern 1: Bypass standard loader** (DreamID-Omni style)
@@ -149,8 +170,10 @@ class CustomPipeline(nn.Module):
self.vae = custom_init_vae(model, device=self.device)
self.text_encoder = custom_init_text_encoder(model, device=self.device)
self.transformer = CustomFusionModel(CONFIG)
load_custom_checkpoint(self.transformer,
checkpoint_path=os.path.join(model, "model.safetensors"))
load_custom_checkpoint(
self.transformer,
checkpoint_path=os.path.join(model, "model.safetensors"),
)
# NO weights_sources defined — bypasses standard loader
def load_weights(self, weights):
@@ -162,6 +185,8 @@ class CustomPipeline(nn.Module):
When weights are in safetensors format but need name remapping:
```python
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
class CustomPipeline(nn.Module):
def __init__(self, *, od_config, prefix=""):
super().__init__()
@@ -187,7 +212,9 @@ class CustomPipeline(nn.Module):
# Remap original weight names to vllm-omni module names
name = self._remap_weight_name(name)
if name in params:
default_weight_loader(params[name], tensor)
param = params[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, tensor)
loaded.add(name)
return loaded
```
@@ -281,12 +308,12 @@ For Omni or custom models, create:
### Step 7: Update Documentation
Required updates:
1. `docs/user_guide/diffusion/parallelism_acceleration.md` — parallelism support table
1. `docs/user_guide/diffusion/parallelism/overview.md` — parallelism support overview/table
2. `docs/user_guide/diffusion/cpu_offload_diffusion.md` — if CPU offload supported (add to supported models table)
3. `docs/user_guide/diffusion/teacache.md` — if TeaCache supported
4. `docs/user_guide/diffusion/cache_dit_acceleration.md` — if Cache-DiT supported
5. `examples/offline_inference/xxx/README.md` — offline example docs
6. `examples/online_serve/xxx/README.md` — online serve docs
3. `docs/user_guide/diffusion/cache_acceleration/teacache.md` — if TeaCache supported
4. `docs/user_guide/diffusion/cache_acceleration/cache_dit.md` — if Cache-DiT supported
5. Offline example docs under `examples/offline_inference/<name>/` (`README.md` or category-specific `.md`)
6. `examples/online_serving/<name>/README.md` — online serving docs
### Step 8: Add E2E Tests
@@ -537,7 +564,7 @@ class YourTransformer(nn.Module):
#### 10e. Update parallelism documentation
After adding parallelism support, update:
1. `docs/user_guide/diffusion/parallelism_acceleration.md` — add your model to the support table
1. `docs/user_guide/diffusion/parallelism/overview.md` — add your model to the support overview/table
2. Record which parallelism methods are supported (USP, Ring, CFG, TP, HSDP, VAE-Patch)
### Step 11: Add CPU Offload Support
@@ -569,4 +569,4 @@ Check which parallelism methods a model supports:
| **TP** | Uses `ColumnParallelLinear` / `QKVParallelLinear`. Search: `grep -r 'ParallelLinear\|QKVParallel' vllm_omni/diffusion/models/<model>/` |
| **HSDP** | Transformer defines `_hsdp_shard_conditions`. Search: `grep -r '_hsdp_shard_conditions' vllm_omni/diffusion/models/` |
The canonical per-model support table is in `docs/user_guide/diffusion/parallelism_acceleration.md`.
The canonical per-model support overview/table is in `docs/user_guide/diffusion/parallelism/overview.md`.
@@ -219,13 +219,14 @@ def load_weights(self, weights):
### Class-Level Attributes for Features
```python
class MyTransformer(nn.Module):
# torch.compile: list block class names that repeat and can be compiled
_repeated_blocks = ["MyTransformerBlock"]
# CPU offload: attribute name of the nn.ModuleList containing blocks
_layerwise_offload_blocks_attr = "blocks"
_layerwise_offload_blocks_attrs = ["blocks"]
# LoRA: mapping of fused param names to original param names
packed_modules_mapping = {"to_qkv": ["to_q", "to_k", "to_v"]}
@@ -1031,6 +1031,14 @@ def _save_generated_outputs(
if not out.success or not out.response_body:
continue
if isinstance(out.response_body, bytes):
fname = f"req_{idx:04d}.mp4"
fpath = os.path.join(save_dir, fname)
with open(fpath, "wb") as f:
f.write(out.response_body)
saved += 1
continue
media_urls: list[str] = []
# Chat-completions style: choices[*].message.content[*].image_url.url
+2 -2
View File
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.18.0rc1
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
# WORKDIR /vllm-workspace/vllm
+2 -2
View File
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.18.0rc1-a3
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
# WORKDIR /vllm-workspace/vllm
+2 -2
View File
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
ARG VLLM_ASCEND_TAG=nightly-main
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
ARG APP_DIR=/vllm-workspace/vllm-omni
+2 -2
View File
@@ -1,5 +1,5 @@
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
ARG VLLM_ASCEND_TAG=nightly-main-a3
ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend
ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
ARG APP_DIR=/vllm-workspace/vllm-omni
-1
View File
@@ -18,7 +18,6 @@ nav:
- Offline Inference:
- BAGEL-7B-MoT: user_guide/examples/offline_inference/bagel.md
- GLM-Image Multistage End-to-End Inference: user_guide/examples/offline_inference/glm_image.md
- Helios Video Generation: user_guide/examples/offline_inference/helios.md
- HunyuanImage-3.0 Image-to-Text Inference: user_guide/examples/offline_inference/hunyuan_image3.md
- Image-To-Image: user_guide/examples/offline_inference/image_to_image.md
- Image-To-Video: user_guide/examples/offline_inference/image_to_video.md
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 410 KiB

+1
View File
@@ -144,6 +144,7 @@ def scan_package(package_name: str = "vllm_omni") -> dict[str, list[str]]:
"vllm_omni.entrypoints.async_diffusion",
"vllm_omni.entrypoints.openai",
"vllm_omni.model_executor.models.voxtral_tts.configuration_voxtral_tts",
"vllm_omni.experimental", # optional serving deps not installed in docs build
]
if any(module_path.startswith(prefix) for prefix in excluded_prefixes):
continue
+1 -1
View File
@@ -36,7 +36,7 @@ th {
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanImageToVideoPipeline` | Wan2.2-I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `WanSpeechToVideoPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `LTX2Pipeline` | LTX-2-T2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | |
+6 -1
View File
@@ -160,7 +160,12 @@ JSON references currently support `image_url`/`video_url`; `file_id` references
are not implemented yet. Models may expose additional V2V controls through
`extra_params`. For example, Cosmos3 supports
`condition_frame_indexes_vision` and `condition_video_keep` to select which
decoded reference frames are used as clean conditioning.
decoded reference frames are used as clean conditioning. Cosmos3 transfer mode
also accepts `edge`, `blur`, `depth`, `seg`, or `wsm` control hints plus
transfer options such as `control_path`, `control_guidance`,
`control_guidance_interval`, `num_video_frames_per_chunk`,
`num_conditional_frames`, `show_control_condition`, and `show_input`; see the
Cosmos3 recipe for complete examples.
### Speech-to-Video
@@ -1,203 +0,0 @@
# Helios Video Generation
Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/helios>.
Helios is a text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) diffusion model. This example demonstrates end-to-end video generation using vLLM-Omni with three model variants:
| Variant | Description | Key Features |
|---------|-------------|--------------|
| **Helios-Base** | Base model, Stage 1 only | Single-stage denoising, `guidance_scale=5.0` |
| **Helios-Mid** | Mid model, Stage 2 pyramid | Multi-stage pyramid denoising, CFG-Zero* support |
| **Helios-Distilled** | Distilled model, Stage 2+3 | Few-step inference with DMD, `guidance_scale=1.0` |
## Setup
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
## Run Examples
Get into the example folder:
```bash
cd examples/offline_inference/helios
```
### Text-to-Video (T2V)
**Helios-Base** (Stage 1 only):
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_t2v_mid.mp4
```
**Helios-Distilled** (Stage 2 pyramid + DMD):
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_t2v_distilled.mp4
```
### Image-to-Video (I2V)
**Helios-Base**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--guidance-scale 5.0 \
--output helios_i2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_i2v_mid.mp4
```
**Helios-Distilled**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_i2v_distilled.mp4
```
### Video-to-Video (V2V)
**Helios-Base**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--guidance-scale 5.0 \
--output helios_v2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_v2v_mid.mp4
```
**Helios-Distilled**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_v2v_distilled.mp4
```
## Common Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--model` | `BestWishYsh/Helios-Base` | Model ID or local path |
| `--sample-type` | `t2v` | Generation mode: `t2v`, `i2v`, or `v2v` |
| `--prompt` | — | Text prompt describing the video |
| `--negative-prompt` | *(see source)* | Negative prompt for CFG (includes anti-static terms) |
| `--image-path` | — | Input image (required for `i2v`) |
| `--video-path` | — | Input video (required for `v2v`) |
| `--height` | `384` | Video height in pixels |
| `--width` | `640` | Video width in pixels |
| `--num-frames` | `99` | Number of output frames |
| `--num-inference-steps` | `50` | Denoising steps (Stage 1 only) |
| `--guidance-scale` | `5.0` | CFG scale (`1.0` for Distilled) |
| `--seed` | `42` | Random seed |
| `--fps` | `16` | Output video frame rate |
| `--output` | `helios_output.mp4` | Output file path |
### Stage 2 / Pyramid Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--is-enable-stage2` | off | Enable pyramid multi-stage denoising |
| `--pyramid-num-stages` | `3` | Number of pyramid stages |
| `--pyramid-num-inference-steps-list` | `10 10 10` | Steps per pyramid stage |
| `--is-amplify-first-chunk` | off | DMD amplification (Distilled) |
### CFG-Zero* Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--use-cfg-zero-star` | off | Enable CFG-Zero* guidance (Mid) |
| `--use-zero-init` | off | Zero init for first steps |
| `--zero-steps` | `1` | Number of zero-init steps |
### Memory & Parallelism
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--vae-use-slicing` | off | Enable VAE slicing |
| `--vae-use-tiling` | off | Enable VAE tiling |
| `--enforce-eager` | off | Disable torch.compile |
| `--enable-cpu-offload` | off | CPU offloading |
| `--enable-layerwise-offload` | off | Layerwise offloading |
| `--ulysses-degree` | `1` | Ulysses SP degree |
| `--ring-degree` | `1` | Ring SP degree |
| `--cfg-parallel-size` | `1` | CFG parallel size (1 or 2) |
| `--tensor-parallel-size` | `1` | Tensor parallelism size |
## Example materials
??? abstract "end2end.py"
``````py
--8<-- "examples/offline_inference/helios/end2end.py"
``````
@@ -0,0 +1,65 @@
# AURA Omni Pipeline
`aura_omni` wires ASR, AURA, and Qwen3-TTS into one vLLM-Omni pipeline:
```text
ASR -> AURA -> Qwen3-TTS Talker -> Code2Wav
```
Qwen3-TTS remains two engine stages so the pipeline reuses the existing native
Talker and Code2Wav implementation.
```bash
vllm serve aurateam/AURA \
--omni \
--deploy-config vllm_omni/deploy/aura_omni.yaml \
--served-model-name aurateam/AURA \
--trust-remote-code
```
Configure local checkpoints by editing per-stage `model` values in
`vllm_omni/deploy/aura_omni.yaml`. The deploy file sets
`pipeline: aura_omni`, so the four-stage topology is used even if the
command-line `--model` points at one of the component checkpoints.
Send requests with `"model": "aurateam/AURA"`. The ASR, AURA, and Qwen3-TTS
checkpoint paths are internal stage models from the deploy YAML, not the
OpenAI-facing served model name.
The AURA stage can emit `<|silent|>`. Silent outputs are treated as a gate:
they produce no Qwen3-TTS Talker input, so no audio is synthesized for that
turn.
## GPU Utilization Recommendation
`gpu_memory_utilization` in `vllm_omni/deploy/aura_omni.yaml` controls how much
VRAM each stage can reserve. Start with this split for a single GPU:
- Stage 0 (ASR): `0.10`
- Stage 1 (AURA): `0.40`
- Stage 2 (Qwen3-TTS Talker): `0.20`
- Stage 3 (Qwen3-TTS Code2Wav): `0.20`
## TTS Modes
`aura_omni` can pass AURA text to Qwen3-TTS in two task modes:
- `Base`: voice clone from `tts_ref_audio` with ICL enabled in the AURA
pipeline. Provide both `tts_ref_audio` and `tts_ref_text`. Set
`tts_x_vector_only_mode=true` to disable ICL and use speaker embedding only.
- `CustomVoice`: predefined speaker mode. Use a Qwen3-TTS CustomVoice
checkpoint for stages 2 and 3 in `aura_omni.yaml`, then pass
`tts_task_type=CustomVoice` and `tts_speaker`.
By default, AURA responses are passed to Qwen3-TTS as text. Set
`additional_information.tts_pass_token_ids=true` to pass AURA-generated
assistant token ids directly instead. Even when token passthrough is disabled,
the stage processor uses AURA token ids when available to estimate the Talker
prefill length, so scheduling does not rely on raw character length.
The example client exposes this as:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-pass-token-ids
```
+1 -1
View File
@@ -85,7 +85,7 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
| FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B` | Yes | Yes | All layers | None | |
| HunyuanImage-3.0 | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | Yes | Yes | All layers; use the Hunyuan stage config for multi-stage runs | None | |
| HunyuanVideo-1.5 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `720p_t2v`, `480p_i2v` | Yes | Yes | All layers | None | |
| Cosmos3 | `nvidia/Cosmos3-Nano` | Yes | Not validated | All layers | None | |
| Cosmos3 | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | Yes | Not validated | All layers | None | |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
@@ -0,0 +1,61 @@
# AURA Omni
This example runs the native AURA Omni pipeline offline:
```text
Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Qwen3-TTS Code2Wav
```
The first stage consumes speech and produces a transcript. The AURA stage then combines the transcript with the video frames from the original request and returns text or `<|silent|>`. Non-silent responses are passed to Qwen3-TTS as AURA-generated token ids.
## Run
```bash
cd examples/offline_inference/aura_omni
bash run_single_prompt.sh
```
Use local media:
```bash
python end2end.py \
--audio-path /path/to/input.wav \
--video-path /path/to/video.mp4 \
--modalities text,audio
```
Base voice clone mode is the default. It uses the AURA reference audio unless
you override it:
```bash
python end2end.py \
--tts-task-type Base \
--tts-ref-audio /data/yrr/rein_test/shuhan.mp3 \
--tts-x-vector-only-mode
```
CustomVoice mode requires stages 2 and 3 in `vllm_omni/deploy/aura_omni.yaml`
to point at a Qwen3-TTS CustomVoice checkpoint:
```bash
python end2end.py \
--tts-task-type CustomVoice \
--tts-speaker Vivian
```
For local checkpoints, edit the stage `model` entries in
`vllm_omni/deploy/aura_omni.yaml` or pass a copied deploy config with
`--deploy-config`.
## GPU Utilization Recommendation
Set `gpu_memory_utilization` in `vllm_omni/deploy/aura_omni.yaml` by stage.
Suggested starting point:
- Stage 0 (ASR): `0.10`
- Stage 1 (AURA): `0.4`
- Stage 2 (Qwen3-TTS Talker): `0.20`
- Stage 3 (Qwen3-TTS Code2Wav): `0.20`
Generated text and audio are written to `--output-dir`
(default: `output_aura_omni`).
@@ -0,0 +1,236 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline AURA Omni example.
The native AURA pipeline is:
Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Code2Wav
Stage 0 consumes audio and emits a transcript. The stage input processor then
combines that transcript with the original visual payload for AURA.
"""
from __future__ import annotations
import os
from typing import NamedTuple
import numpy as np
import soundfile as sf
import vllm
from vllm import SamplingParams
from vllm.assets.audio import AudioAsset
from vllm.assets.video import VideoAsset, video_to_ndarrays
from vllm.multimodal.media.audio import load_audio
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.model_executor.stage_input_processors.aura_omni import (
DEFAULT_QWEN3_TTS_REF_TEXT,
default_qwen3_tts_ref_audio_path,
)
SEED = 42
DEFAULT_MODEL = "aurateam/AURA"
DEFAULT_DEPLOY_CONFIG = "vllm_omni/deploy/aura_omni.yaml"
class QueryResult(NamedTuple):
inputs: dict
limit_mm_per_prompt: dict[str, int]
def _load_audio(audio_path: str | None, sampling_rate: int) -> tuple[np.ndarray, int]:
if audio_path:
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
audio_signal, sr = load_audio(audio_path, sr=sampling_rate)
return audio_signal.astype(np.float32), int(sr)
audio_signal, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate
return audio_signal.astype(np.float32), int(sr)
def _load_video(video_path: str | None, num_frames: int) -> list[np.ndarray]:
if video_path:
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
return video_to_ndarrays(video_path, num_frames=num_frames)
return VideoAsset(name="baby_reading", num_frames=num_frames).np_ndarrays
def build_query(args) -> QueryResult:
prompt = (
"<|im_start|>user\n"
"<|audio_start|><|audio_pad|><|audio_end|>"
"Please transcribe this speech and use the video context to decide whether a reply is needed.<|im_end|>\n"
"<|im_start|>assistant\n"
)
audio_data = _load_audio(args.audio_path, args.sampling_rate)
video_frames = _load_video(args.video_path, args.num_frames)
return QueryResult(
inputs={
"prompt": prompt,
"multi_modal_data": {
"audio": audio_data,
"video": video_frames,
},
"additional_information": {
"aura_system_prompt": args.aura_system_prompt,
"tts_task_type": args.tts_task_type,
"tts_language": args.tts_language,
"tts_speaker": args.tts_speaker,
"tts_instruct": args.tts_instruct,
"tts_ref_audio": args.tts_ref_audio,
"tts_ref_text": args.tts_ref_text,
"tts_x_vector_only_mode": args.tts_x_vector_only_mode,
"tts_pass_token_ids": args.tts_pass_token_ids,
},
},
limit_mm_per_prompt={"audio": 1, "video": 1},
)
def build_sampling_params() -> list[SamplingParams]:
asr_sampling = SamplingParams(
temperature=0.0,
top_p=1.0,
top_k=-1,
max_tokens=256,
detokenize=True,
seed=SEED,
)
aura_sampling = SamplingParams(
temperature=0.5,
top_p=1.0,
top_k=-1,
max_tokens=256,
detokenize=True,
repetition_penalty=1.0,
seed=SEED,
)
talker_sampling = SamplingParams(
temperature=0.9,
top_k=50,
max_tokens=4096,
detokenize=False,
repetition_penalty=1.05,
stop_token_ids=[2150],
seed=SEED,
)
code2wav_sampling = SamplingParams(
temperature=0.0,
top_p=1.0,
top_k=-1,
max_tokens=65536,
detokenize=True,
repetition_penalty=1.0,
seed=SEED,
)
return [asr_sampling, aura_sampling, talker_sampling, code2wav_sampling]
def save_stage_output(stage_outputs, output_dir: str) -> None:
output = stage_outputs.request_output
request_id = output.request_id
if stage_outputs.final_output_type == "text":
text_output = output.outputs[0].text
out_txt = os.path.join(output_dir, f"{request_id}.txt")
with open(out_txt, "w", encoding="utf-8") as f:
f.write(str(text_output).strip() + "\n")
print(f"Request ID: {request_id}, text saved to {out_txt}")
print(text_output)
return
if stage_outputs.final_output_type == "audio":
mm = output.outputs[0].multimodal_output
if not mm or "audio" not in mm:
print(f"Request ID: {request_id}, no audio output")
return
audio = mm["audio"]
if isinstance(audio, list):
import torch
audio = torch.cat([(item if hasattr(item, "detach") else torch.tensor(item)).flatten() for item in audio])
sample_rate = int(mm.get("sr", 24000))
audio_numpy = audio.float().detach().cpu().numpy().flatten()
out_wav = os.path.join(output_dir, f"{request_id}.wav")
sf.write(out_wav, audio_numpy, samplerate=sample_rate, format="WAV")
print(f"Request ID: {request_id}, audio saved to {out_wav}")
def main(args) -> None:
print("=" * 20, "\n", f"vllm version: {vllm.__version__}", "\n", "=" * 20)
os.makedirs(args.output_dir, exist_ok=True)
query = build_query(args)
prompts = [query.inputs for _ in range(args.num_prompts)]
if args.modalities:
modalities = [item.strip() for item in args.modalities.split(",") if item.strip()]
for prompt in prompts:
prompt["modalities"] = modalities
omni_kwargs = vars(args).copy()
omni_kwargs["model"] = args.model
omni = Omni(**omni_kwargs)
sampling_params_list = build_sampling_params()[: omni.num_stages]
try:
for stage_outputs in omni.generate(prompts, sampling_params_list):
save_stage_output(stage_outputs, args.output_dir)
finally:
omni.close()
def parse_args():
parser = FlexibleArgumentParser(description="AURA Omni offline inference example")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Top-level model name/path.")
parser.add_argument("--deploy-config", default=DEFAULT_DEPLOY_CONFIG, help="AURA Omni deploy config.")
parser.add_argument("--audio-path", "-a", default=None, help="Local audio file. Uses a built-in asset by default.")
parser.add_argument("--video-path", "-v", default=None, help="Local video file. Uses a built-in asset by default.")
parser.add_argument("--num-frames", type=int, default=16, help="Number of video frames to sample.")
parser.add_argument("--sampling-rate", type=int, default=16000, help="Audio sampling rate for ASR input.")
parser.add_argument("--num-prompts", type=int, default=1, help="Number of repeated prompts.")
parser.add_argument("--modalities", default="text,audio", help="Comma-separated final modalities.")
parser.add_argument("--output-dir", default="output_aura_omni", help="Directory for generated files.")
parser.add_argument(
"--aura-system-prompt",
default=(
"You are receiving a live video stream where the final frame is the present moment. "
"Respond only when a response is needed. Otherwise output '<|silent|>'. Respond in Chinese."
),
help="System prompt for the AURA stage.",
)
parser.add_argument("--tts-task-type", default="Base", choices=["Base", "CustomVoice"], help="Qwen3-TTS task.")
parser.add_argument("--tts-language", default="Chinese", help="Qwen3-TTS language.")
parser.add_argument("--tts-speaker", default="Vivian", help="Qwen3-TTS speaker.")
parser.add_argument("--tts-instruct", default="", help="Optional Qwen3-TTS style instruction.")
parser.add_argument(
"--tts-ref-audio",
default=default_qwen3_tts_ref_audio_path(),
help="Base-mode reference audio path/URL.",
)
parser.add_argument(
"--tts-ref-text",
default=DEFAULT_QWEN3_TTS_REF_TEXT,
help="Base-mode reference audio transcript.",
)
parser.add_argument(
"--tts-x-vector-only-mode",
action="store_true",
help="Use speaker embedding only for Base mode (disable ICL ref_text conditioning).",
)
parser.add_argument(
"--tts-pass-token-ids",
action="store_true",
help="Pass AURA-generated assistant token ids directly to Qwen3-TTS. Defaults to sending text.",
)
parser.add_argument("--init-timeout", type=int, default=2000, help="Overall initialization timeout.")
parser.add_argument("--stage-init-timeout", type=int, default=2000, help="Per-stage initialization timeout.")
parser.add_argument("--log-stats", action="store_true", default=False, help="Enable stats logging.")
nullify_stage_engine_defaults(parser)
return parser.parse_args()
if __name__ == "__main__":
main(parse_args())
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
python end2end.py \
--model aurateam/AURA \
--deploy-config vllm_omni/deploy/aura_omni.yaml \
--modalities text,audio \
"$@"
-193
View File
@@ -1,193 +0,0 @@
# Helios Video Generation
Helios is a text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) diffusion model. This example demonstrates end-to-end video generation using vLLM-Omni with three model variants:
| Variant | Description | Key Features |
|---------|-------------|--------------|
| **Helios-Base** | Base model, Stage 1 only | Single-stage denoising, `guidance_scale=5.0` |
| **Helios-Mid** | Mid model, Stage 2 pyramid | Multi-stage pyramid denoising, CFG-Zero* support |
| **Helios-Distilled** | Distilled model, Stage 2+3 | Few-step inference with DMD, `guidance_scale=1.0` |
## Setup
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
## Run Examples
Get into the example folder:
```bash
cd examples/offline_inference/helios
```
### Text-to-Video (T2V)
**Helios-Base** (Stage 1 only):
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_t2v_mid.mp4
```
**Helios-Distilled** (Stage 2 pyramid + DMD):
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_t2v_distilled.mp4
```
### Image-to-Video (I2V)
**Helios-Base**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--guidance-scale 5.0 \
--output helios_i2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_i2v_mid.mp4
```
**Helios-Distilled**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "A towering emerald wave surges forward, its crest curling with raw power and energy." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_i2v_distilled.mp4
```
### Video-to-Video (V2V)
**Helios-Base**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Base \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--guidance-scale 5.0 \
--output helios_v2v_base.mp4
```
**Helios-Mid** (Stage 2 + CFG-Zero*):
```bash
python end2end.py \
--model BestWishYsh/Helios-Mid \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1 \
--output helios_v2v_mid.mp4
```
**Helios-Distilled**:
```bash
python end2end.py \
--model BestWishYsh/Helios-Distilled \
--sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "A bright yellow Lamborghini speeds along a curving mountain road." \
--num-frames 240 \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk \
--output helios_v2v_distilled.mp4
```
## Common Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--model` | `BestWishYsh/Helios-Base` | Model ID or local path |
| `--sample-type` | `t2v` | Generation mode: `t2v`, `i2v`, or `v2v` |
| `--prompt` | — | Text prompt describing the video |
| `--negative-prompt` | *(see source)* | Negative prompt for CFG (includes anti-static terms) |
| `--image-path` | — | Input image (required for `i2v`) |
| `--video-path` | — | Input video (required for `v2v`) |
| `--height` | `384` | Video height in pixels |
| `--width` | `640` | Video width in pixels |
| `--num-frames` | `99` | Number of output frames |
| `--num-inference-steps` | `50` | Denoising steps (Stage 1 only) |
| `--guidance-scale` | `5.0` | CFG scale (`1.0` for Distilled) |
| `--seed` | `42` | Random seed |
| `--fps` | `16` | Output video frame rate |
| `--output` | `helios_output.mp4` | Output file path |
### Stage 2 / Pyramid Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--is-enable-stage2` | off | Enable pyramid multi-stage denoising |
| `--pyramid-num-stages` | `3` | Number of pyramid stages |
| `--pyramid-num-inference-steps-list` | `10 10 10` | Steps per pyramid stage |
| `--is-amplify-first-chunk` | off | DMD amplification (Distilled) |
### CFG-Zero* Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--use-cfg-zero-star` | off | Enable CFG-Zero* guidance (Mid) |
| `--use-zero-init` | off | Zero init for first steps |
| `--zero-steps` | `1` | Number of zero-init steps |
### Memory & Parallelism
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--vae-use-slicing` | off | Enable VAE slicing |
| `--vae-use-tiling` | off | Enable VAE tiling |
| `--enforce-eager` | off | Disable torch.compile |
| `--enable-cpu-offload` | off | CPU offloading |
| `--enable-layerwise-offload` | off | Layerwise offloading |
| `--ulysses-degree` | `1` | Ulysses SP degree |
| `--ring-degree` | `1` | Ring SP degree |
| `--cfg-parallel-size` | `1` | CFG parallel size (1 or 2) |
| `--tensor-parallel-size` | `1` | Tensor parallelism size |
@@ -1,355 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Helios video generation example supporting T2V, I2V, and V2V.
Usage (T2V, Helios-Base, Stage 1 only):
python end2end.py \
--model /path/to/Helios-Base --sample-type t2v \
--prompt "A serene lakeside sunrise with mist over the water." \
--height 384 --width 640 --num-frames 99 \
--num-inference-steps 50 --guidance-scale 5.0
Usage (I2V, Helios-Base):
python end2end.py \
--model /path/to/Helios-Base --sample-type i2v \
--image-path /path/to/image.jpg \
--prompt "Description of desired animation." \
--guidance-scale 5.0
Usage (V2V, Helios-Base):
python end2end.py \
--model /path/to/Helios-Base --sample-type v2v \
--video-path /path/to/video.mp4 \
--prompt "Description of desired transformation." \
--guidance-scale 5.0
Usage (Helios-Mid, Stage 2 + CFG-Zero*):
python end2end.py \
--model /path/to/Helios-Mid --sample-type t2v \
--prompt "A serene lakeside sunrise with mist over the water." \
--guidance-scale 5.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 20 20 20 \
--use-cfg-zero-star --use-zero-init --zero-steps 1
Usage (Helios-Distilled, Stage 2 pyramid + DMD):
python end2end.py \
--model /path/to/Helios-Distilled --sample-type t2v \
--prompt "A serene lakeside sunrise with mist over the water." \
--guidance-scale 1.0 \
--is-enable-stage2 \
--pyramid-num-inference-steps-list 2 2 2 \
--is-amplify-first-chunk
"""
import argparse
import time
from pathlib import Path
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 load_image_as_tensor(image_path: str, height: int, width: int) -> torch.Tensor:
"""Load an image and return a (1, C, H, W) tensor normalized to [-1, 1]."""
from PIL import Image
from torchvision import transforms
image = Image.open(image_path).convert("RGB").resize((width, height))
transform = transforms.Compose(
[
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
]
)
return transform(image).unsqueeze(0)
def load_video_as_tensor(video_path: str, height: int, width: int) -> torch.Tensor:
"""Load a video and return a (1, C, T, H, W) tensor normalized to [-1, 1]."""
from torchvision import transforms
from torchvision.io import read_video
video_frames, _, _ = read_video(video_path, output_format="TCHW")
video_frames = video_frames.float() / 255.0
transform = transforms.Compose(
[
transforms.Resize((height, width), antialias=True),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
]
)
video_frames = torch.stack([transform(f) for f in video_frames])
return video_frames.permute(1, 0, 2, 3).unsqueeze(0)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate a video with Helios (T2V / I2V / V2V).")
parser.add_argument(
"--model",
default="BestWishYsh/Helios-Base",
help="Helios model ID or local path (e.g. Helios-Base, Helios-Mid, Helios-Distilled).",
)
parser.add_argument(
"--sample-type",
choices=["t2v", "i2v", "v2v"],
default="t2v",
help="Generation mode: t2v (text-to-video), i2v (image-to-video), v2v (video-to-video).",
)
parser.add_argument("--prompt", default="A serene lakeside sunrise with mist over the water.", help="Text prompt.")
parser.add_argument(
"--negative-prompt",
default=(
"Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, "
"images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, "
"incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, "
"misshapen limbs, fused fingers, still picture, messy background, three legs, many people "
"in the background, walking backwards"
),
help="Negative prompt.",
)
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
parser.add_argument("--guidance-scale", type=float, default=5.0, help="CFG scale.")
parser.add_argument("--height", type=int, default=384, help="Video height.")
parser.add_argument("--width", type=int, default=640, help="Video width.")
parser.add_argument("--num-frames", type=int, default=99, help="Number of video frames.")
parser.add_argument("--num-inference-steps", type=int, default=50, help="Sampling steps (Stage 1 only).")
parser.add_argument("--output", type=str, default="helios_output.mp4", help="Output video path.")
parser.add_argument("--fps", type=int, default=16, help="Frames per second for the output video.")
# I2V / V2V
parser.add_argument("--image-path", type=str, default=None, help="Input image path for I2V mode.")
parser.add_argument("--video-path", type=str, default=None, help="Input video path for V2V mode.")
# Stage 2 (pyramid multi-stage denoising)
parser.add_argument(
"--is-enable-stage2",
action="store_true",
help="Enable pyramid multi-stage denoising (Stage 2). Required for Helios-Distilled.",
)
parser.add_argument(
"--pyramid-num-stages",
type=int,
default=3,
help="Number of pyramid stages for Stage 2.",
)
parser.add_argument(
"--pyramid-num-inference-steps-list",
type=int,
nargs="+",
default=[10, 10, 10],
help="Inference steps per pyramid stage.",
)
# DMD
parser.add_argument(
"--is-amplify-first-chunk",
action="store_true",
help="Enable DMD amplification for the first chunk (Helios-Distilled).",
)
# CFG Zero Star
parser.add_argument(
"--use-cfg-zero-star",
action="store_true",
help="Enable CFG Zero Star guidance (recommended for Helios-Mid).",
)
parser.add_argument(
"--use-zero-init",
action="store_true",
help="Use zero initialization for the first denoising steps with CFG-Zero*.",
)
parser.add_argument(
"--zero-steps",
type=int,
default=1,
help="Number of initial denoising steps using zero prediction (default: 1).",
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
parser.add_argument(
"--quantization",
type=str,
default=None,
help="Quantization method (e.g. 'fp8').",
)
# Memory & parallelism
parser.add_argument("--vae-use-slicing", action="store_true", help="Enable VAE slicing.")
parser.add_argument("--vae-use-tiling", action="store_true", help="Enable VAE tiling.")
parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
parser.add_argument("--enable-cpu-offload", action="store_true", help="Enable CPU offloading.")
parser.add_argument("--enable-layerwise-offload", action="store_true", help="Enable layerwise offloading.")
parser.add_argument("--ulysses-degree", type=int, default=1, help="Ulysses SP degree.")
parser.add_argument("--ring-degree", type=int, default=1, help="Ring SP degree.")
parser.add_argument("--cfg-parallel-size", type=int, default=1, choices=[1, 2], help="CFG parallel size.")
parser.add_argument("--tensor-parallel-size", type=int, default=1, help="Tensor parallelism size.")
return parser.parse_args()
def main():
args = parse_args()
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
parallel_config = DiffusionParallelConfig(
ulysses_degree=args.ulysses_degree,
ring_degree=args.ring_degree,
cfg_parallel_size=args.cfg_parallel_size,
tensor_parallel_size=args.tensor_parallel_size,
)
omni = Omni(
model=args.model,
enable_layerwise_offload=args.enable_layerwise_offload,
vae_use_slicing=args.vae_use_slicing,
vae_use_tiling=args.vae_use_tiling,
enable_cpu_offload=args.enable_cpu_offload,
parallel_config=parallel_config,
enforce_eager=args.enforce_eager,
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
quantization=args.quantization,
)
# Validate I2V / V2V arguments
if args.sample_type == "i2v" and not args.image_path:
raise ValueError("--image-path is required for I2V mode.")
if args.sample_type == "v2v" and not args.video_path:
raise ValueError("--video-path is required for V2V mode.")
# Build extra_args for Helios-specific parameters
extra_args = {}
if args.is_enable_stage2:
extra_args["is_enable_stage2"] = True
extra_args["pyramid_num_stages"] = args.pyramid_num_stages
extra_args["pyramid_num_inference_steps_list"] = args.pyramid_num_inference_steps_list
if args.is_amplify_first_chunk:
extra_args["is_amplify_first_chunk"] = True
if args.use_cfg_zero_star:
extra_args["use_cfg_zero_star"] = True
if args.use_zero_init:
extra_args["use_zero_init"] = True
extra_args["zero_steps"] = args.zero_steps
if args.sample_type == "i2v":
image_tensor = load_image_as_tensor(args.image_path, args.height, args.width)
extra_args["image"] = image_tensor
elif args.sample_type == "v2v":
video_tensor = load_video_as_tensor(args.video_path, args.height, args.width)
extra_args["video"] = video_tensor
# Print generation configuration
print(f"\n{'=' * 60}")
print("Helios Generation Configuration:")
print(f" Model: {args.model}")
print(f" Sample type: {args.sample_type.upper()}")
if args.sample_type == "i2v":
print(f" Image: {args.image_path}")
elif args.sample_type == "v2v":
print(f" Video: {args.video_path}")
print(f" Prompt: {args.prompt[:80]}{'...' if len(args.prompt) > 80 else ''}")
print(f" Video size: {args.width}x{args.height}, {args.num_frames} frames")
print(f" Inference steps: {args.num_inference_steps}")
print(f" Guidance scale: {args.guidance_scale}")
if args.is_enable_stage2:
print(f" Stage 2: enabled (stages={args.pyramid_num_stages}, steps={args.pyramid_num_inference_steps_list})")
if args.is_amplify_first_chunk:
print(" DMD amplify first chunk: enabled")
if args.use_cfg_zero_star:
print(f" CFG Zero Star: enabled (zero_init={args.use_zero_init}, zero_steps={args.zero_steps})")
else:
if args.use_cfg_zero_star:
print(f" CFG Zero Star: enabled (zero_init={args.use_zero_init}, zero_steps={args.zero_steps})")
print(" Stage 2: disabled (Stage 1 only)")
print(f"{'=' * 60}\n")
generation_start = time.perf_counter()
frames = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
},
OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
generator=generator,
guidance_scale=args.guidance_scale,
num_inference_steps=args.num_inference_steps,
num_frames=args.num_frames,
extra_args=extra_args,
),
)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)")
# Extract video frames from OmniRequestOutput
if isinstance(frames, list) and len(frames) > 0:
first_item = frames[0]
if hasattr(first_item, "final_output_type"):
if first_item.final_output_type != "image":
raise ValueError(
f"Unexpected output type '{first_item.final_output_type}', expected 'image' for video generation."
)
if hasattr(first_item, "is_pipeline_output") and first_item.is_pipeline_output:
inner_output = first_item.request_output
if isinstance(inner_output, OmniRequestOutput) and hasattr(inner_output, "images"):
frames = inner_output.images[0] if inner_output.images else None
if frames is None:
raise ValueError("No video frames found in output.")
elif hasattr(first_item, "images") and first_item.images:
frames = first_item.images
else:
raise ValueError("No video frames found in OmniRequestOutput.")
# Unwrap batch list from postprocess_video: [numpy(T,H,W,C)] -> numpy(T,H,W,C)
if isinstance(frames, list) and len(frames) > 0 and isinstance(frames[0], np.ndarray):
frames = frames[0]
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
from diffusers.utils import export_to_video
except ImportError:
raise ImportError("diffusers is required for export_to_video.")
if isinstance(frames, torch.Tensor):
video_tensor = frames.detach().cpu()
if video_tensor.dim() == 5:
if video_tensor.shape[1] in (3, 4):
video_tensor = video_tensor[0].permute(1, 2, 3, 0)
else:
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_array = video_tensor.float().numpy()
elif isinstance(frames, np.ndarray):
video_array = frames
else:
video_array = frames
if isinstance(video_array, np.ndarray) and video_array.ndim == 5:
video_array = video_array[0]
export_to_video(video_array, str(output_path), fps=args.fps)
print(f"Saved generated video to {output_path}")
if __name__ == "__main__":
main()
@@ -1,72 +0,0 @@
# MagiHuman Generation
MagiHuman is an advanced, omni-modality model that generates both high-quality video and lip-synced audio from a text prompt.
Because MagiHuman is a very large model featuring a powerful DiT MoE backbone and a ~9B parameter T5Gemma text encoder, it natively supports **Tensor Parallelism (TP)** in vLLM-Omni to run efficiently across multi-GPU setups, reducing device memory bottlenecks.
## Setup
### Install MagiCompiler (recommended)
MagiHuman relies on [MagiCompiler](https://github.com/SandAI-org/MagiCompiler) for custom-op registration used by the DiT attention kernels. While the pipeline can fall back to stub implementations, installing MagiCompiler is **strongly recommended** for correct behaviour.
```bash
# Clone the repo
git clone https://github.com/SandAI-org/MagiCompiler.git
cd MagiCompiler
# System dependencies (optional, for FX graph visualization; Debian/Ubuntu)
sudo apt update && sudo apt install -y graphviz
# Python dependencies
pip install -r requirements.txt
# Install MagiCompiler
pip install . # end users (recommended)
# pip install -e . # developers (editable install)
```
### Hardware requirements
Ensure your hardware has enough VRAM. For a standard node with 80GB GPUs, running with `--tensor-parallel-size 4` is recommended to shard both the MoE weights and the T5Gemma text encoder across 4 GPUs, reducing the per-GPU peak VRAM overhead significantly (by roughly ~13.5GB per GPU compared to single-device inference).
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) for further details on allocating memory.
## Run Examples
Get into the example folder:
```bash
cd examples/offline_inference/magi_human
```
### End-to-End Generation (Text to Video+Audio)
Generate a video with synchronized speech natively generated by the model.
```bash
python end2end.py \
--model /proj-tango-pvc/users/zhipeng.wang/workspace/models/daVinci-MagiHuman \
--prompt "A young woman with long, wavy golden blonde hair..." \
--tensor-parallel-size 4 \
--output output_magihuman.mp4
```
## Common Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--model` | *(Required)* | Local model path or HuggingFace ID |
| `--prompt` | *(built-in demo prompt)* | Highly detailed text prompt dictating visual look and dialogue text |
| `--tensor-parallel-size` | `4` | Tensor parallelism size (Number of GPUs) |
| `--height` | `256` | Initial resolution height |
| `--width` | `448` | Initial resolution width |
| `--num-inference-steps` | `8` | Denoising steps |
| `--seed` | `52` | Random seed |
| `--output` | `output_magihuman.mp4` | Output video with audio path |
## Example materials
??? abstract "end2end.py"
``````py
--8<-- "examples/offline_inference/magi_human/end2end.py"
``````
@@ -1,122 +0,0 @@
import argparse
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
def parse_args():
parser = argparse.ArgumentParser(description="End-to-end inference script for MagiHuman.")
parser.add_argument("--model", type=str, required=True, help="Path or ID of the MagiHuman model.")
parser.add_argument(
"--prompt",
type=str,
default="",
help="Text prompt containing visual description, dialogue, and background sound.",
)
parser.add_argument(
"--tensor-parallel-size", "-tp", type=int, default=4, help="Tensor parallel size (number of GPUs)."
)
parser.add_argument(
"--output", type=str, default="output_magihuman.mp4", help="Path to save the generated mp4 file."
)
parser.add_argument("--height", type=int, default=256, help="Video height.")
parser.add_argument("--width", type=int, default=448, help="Video width.")
parser.add_argument("--num-inference-steps", type=int, default=8, help="Number of denoising steps.")
parser.add_argument("--seed", type=int, default=52, help="Random seed for generation.")
return parser.parse_args()
def main():
args = parse_args()
print(f"Initializing MagiHuman pipeline with TP={args.tensor_parallel_size}...")
omni = Omni(
model=args.model,
init_timeout=1200,
tensor_parallel_size=args.tensor_parallel_size,
devices=list(range(args.tensor_parallel_size)),
)
prompt = args.prompt
if not prompt:
prompt = (
"A young woman with long, wavy golden blonde hair and bright blue eyes, "
"wearing a fitted ivory silk blouse with a delicate lace collar, sits "
"stationary in front of a softly lit, blurred warm-toned interior. Her "
"overall disposition is warm, composed, and gently confident. The camera "
"holds a static medium close-up, framing her from the shoulders up, "
"with shallow depth of field keeping her face in sharp focus. Soft "
"directional key light falls from the upper left, casting a gentle "
"highlight along her cheekbone and nose bridge. She draws a quiet breath, "
"the levator labii superiors relaxing as her lips part. She speaks in "
"clear, warm, unhurried American English: "
"\"The most beautiful things in life aren't things at all — "
"they're moments, feelings, and the people who make you feel truly alive.\" "
"Her jaw descends smoothly on each stressed syllable; the orbicularis oris "
"shapes each vowel with precision. A faint, genuine smile engages the "
"zygomaticus major, lifting her lip corners fractionally. Her brows rest "
"in a soft, neutral arch throughout. She maintains steady, forward-facing "
"eye contact. Head position remains level; no torso displacement occurs.\n\n"
"Dialogue:\n"
"<Young blonde woman, American English>: "
"\"The most beautiful things in life aren't things at all — "
"they're moments, feelings, and the people who make you feel truly alive.\"\n\n"
"Background Sound:\n"
"<Soft, warm indoor ambience with a faint distant piano melody>"
)
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
seed=args.seed,
extra_args={
"seconds": 5,
"sr_height": 1080,
"sr_width": 1920,
"sr_num_inference_steps": 5,
},
)
print(f"Generating with prompt: {prompt[:80]}...")
outputs = omni.generate(
prompts=[prompt],
sampling_params_list=[sampling_params],
)
print(f"Generation complete. Output type: {type(outputs)}")
if outputs:
first = outputs[0]
if hasattr(first, "images") and first.images:
video_frames = first.images[0]
print(f"Video frames: shape={video_frames.shape}, dtype={video_frames.dtype}")
audio_waveform = None
mm = first.multimodal_output or {}
if mm:
audio_waveform = mm.get("audio")
if audio_waveform is not None:
print(f"Audio waveform: shape={audio_waveform.shape}, dtype={audio_waveform.dtype}")
output_fps = float(mm.get("fps", 25))
output_sr = int(mm.get("audio_sample_rate", 24000))
print(f"Using fps={output_fps}, audio_sample_rate={output_sr} from model output")
video_bytes = mux_video_audio_bytes(
video_frames,
audio_waveform,
fps=output_fps,
audio_sample_rate=output_sr,
)
with open(args.output, "wb") as f:
f.write(video_bytes)
print(f"Saved MP4 ({len(video_bytes)} bytes) to {args.output}")
print("SUCCESS: MagiHuman pipeline generation completed.")
else:
print("WARNING: No outputs returned.")
if __name__ == "__main__":
main()
@@ -369,6 +369,20 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
```
Streaming requires `async_chunk: true` in the stage config.
### Word Timestamps
Generate a WAV offline and a JSON sidecar with word-level timestamps from
`Qwen/Qwen3-ForcedAligner-0.6B`:
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/word_timestamps.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B \
--text "Hello world." \
--output-dir /tmp/qwen3_tts_timestamps
```
The script writes `qwen3_tts_word_timestamps.wav` and
`qwen3_tts_word_timestamps.json`. On machines without a local CUDA toolkit,
set `VLLM_USE_FLASHINFER_SAMPLER=0` to avoid FlashInfer sampler JIT.
### Batched decoding
The Code2Wav stage supports batched decoding through the SpeechTokenizer. Pass multiple prompts via `--txt-prompts` and set `--batch-size` accordingly. To raise `max_num_seqs` on either stage, point `--stage-configs-path` at a stage configs YAML with the desired values (see `vllm_omni/model_executor/stage_configs/` for templates):
```bash
@@ -0,0 +1,172 @@
"""Offline Qwen3-TTS example with word-level timestamps.
This demo runs Qwen3-TTS offline, aligns the synthesized audio in memory with
the same shared forced aligner used by the streaming server path, then writes
the WAV and JSON sidecar:
python examples/offline_inference/text_to_speech/qwen3_tts/word_timestamps.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
On machines without a local CUDA toolkit, set ``VLLM_USE_FLASHINFER_SAMPLER=0``
to avoid FlashInfer sampler JIT during warmup.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
# The in-process offline aligner consumes token_classify outputs. Keeping V1
# multiprocessing off avoids msgspec serialization issues for token-wise logits.
os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
import numpy as np
import soundfile as sf
import torch
from end2end import _estimate_prompt_len
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import Omni
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
from vllm_omni.utils.forced_aligner import align, build_forced_aligner_config
def _default_stage_config() -> str:
repo_root = Path(__file__).resolve().parents[4]
return str(repo_root / "vllm_omni" / "deploy" / "qwen3_tts.yaml")
def _build_custom_voice_input(args: Any) -> dict[str, Any]:
additional_information = {
"task_type": ["CustomVoice"],
"text": [args.text],
"language": [args.language],
"speaker": [args.speaker],
"instruct": [args.instructions],
"max_new_tokens": [args.max_new_tokens],
}
return {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, args.model),
"additional_information": additional_information,
}
def _audio_tensor_and_sample_rate(mm: dict[str, Any]) -> tuple[torch.Tensor, int]:
audio_data = mm["audio"]
sr_raw = mm["sr"]
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
sample_rate = int(sr_val.item()) if hasattr(sr_val, "item") else int(sr_val)
audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
return audio_tensor.float().detach().cpu().flatten(), sample_rate
def _float_audio_to_pcm16_bytes(audio: torch.Tensor) -> bytes:
samples = audio.numpy()
pcm = (np.clip(samples, -1.0, 1.0) * 32767.0).astype("<i2")
return pcm.tobytes()
async def _run_alignment(args: Any, audio: torch.Tensor, sample_rate: int) -> list[dict[str, Any]] | None:
aligner_config = build_forced_aligner_config(args)
if aligner_config is None:
raise ValueError("--forced-aligner or --forced-aligner-config is required")
timestamps = await align(
audio=_float_audio_to_pcm16_bytes(audio),
text=args.text,
sample_rate=sample_rate,
config=aligner_config,
language=args.language,
)
if timestamps is None:
return None
return [
{
"word": item.word,
"start_ms": item.start_ms,
"end_ms": item.end_ms,
}
for item in timestamps
]
def main(args: Any) -> None:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
omni_kwargs = vars(args).copy()
for key in (
"forced_aligner",
"text",
"language",
"speaker",
"instructions",
"max_new_tokens",
"output_dir",
):
omni_kwargs.pop(key, None)
omni_kwargs["stage_configs_path"] = args.stage_configs_path or _default_stage_config()
omni_kwargs["log_stats"] = args.log_stats
omni = Omni(**omni_kwargs)
prompt = _build_custom_voice_input(args)
final_output = None
for stage_outputs in omni.generate([prompt]):
final_output = stage_outputs.request_output
if final_output is None:
raise RuntimeError("Qwen3-TTS did not produce an output.")
mm = final_output.outputs[0].multimodal_output
audio, sample_rate = _audio_tensor_and_sample_rate(mm)
wav_path = output_dir / "qwen3_tts_word_timestamps.wav"
# Align the in-memory tensor, not the saved WAV. The WAV is only a demo
# artifact so users can listen to the same audio referenced by the sidecar.
timestamps = asyncio.run(_run_alignment(args, audio, sample_rate))
sf.write(wav_path, audio.numpy(), samplerate=sample_rate, format="WAV")
sidecar = {
"text": args.text,
"sample_rate": sample_rate,
"audio_path": str(wav_path),
"timestamps": timestamps,
}
json_path = output_dir / "qwen3_tts_word_timestamps.json"
json_path.write_text(json.dumps(sidecar, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Saved audio: {wav_path}")
print(f"Saved timestamps: {json_path}")
print(json.dumps(sidecar["timestamps"], ensure_ascii=False, indent=2))
def parse_args() -> Any:
parser = FlexibleArgumentParser(description="Offline Qwen3-TTS word timestamp example")
parser.add_argument("--model", default="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", help="Qwen3-TTS model path/name")
parser.add_argument(
"--forced-aligner",
default=None,
help="Qwen3 forced aligner model path/name",
)
parser.add_argument(
"--forced-aligner-config",
default=None,
help="Optional YAML file for forced aligner settings (incl. gpu_memory_utilization)",
)
parser.add_argument("--stage-configs-path", default=None, help="Qwen3-TTS deploy YAML")
parser.add_argument("--text", default="Hello world.", help="Text to synthesize and align")
parser.add_argument("--language", default="English", help="Qwen3-TTS language field")
parser.add_argument("--speaker", default="Vivian", help="CustomVoice speaker name")
parser.add_argument("--instructions", default="", help="Optional speaking style instruction")
parser.add_argument("--max-new-tokens", type=int, default=2048, help="TTS max_new_tokens")
parser.add_argument("--output-dir", default="output_audio", help="Directory for WAV and JSON sidecar")
parser.add_argument("--log-stats", action="store_true", default=False, help="Enable vLLM-Omni stats logging")
nullify_stage_engine_defaults(parser)
return parser.parse_args()
if __name__ == "__main__":
main(parse_args())
@@ -9,6 +9,7 @@ A unified script for text-to-video generation. Supports multiple models with mod
| `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB |
| `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 |
| `BestWishYsh/Helios-Base` / `Helios-Mid` / `Helios-Distilled` | 384x640 | 99 | 50 | 5.0 / 5.0 / 1.0 | — |
## Local CLI Usage
@@ -100,6 +101,49 @@ python text_to_video.py \
--output quick_test.mp4
```
### Helios (T2V)
Helios ships three variants. Model-specific knobs (declared in
`vllm_omni/model_extras/helios.py`) are passed via the generic `--extra-body`
JSON flag rather than bespoke per-model flags.
**Helios-Base** (Stage 1 only):
```bash
python text_to_video.py \
--model BestWishYsh/Helios-Base \
--prompt "A dynamic time-lapse of scenery rushing past the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
```
**Helios-Mid** (Stage 2 pyramid + CFG-Zero*):
```bash
python text_to_video.py \
--model BestWishYsh/Helios-Mid \
--prompt "A dynamic time-lapse of scenery rushing past the window of a speeding train." \
--guidance-scale 5.0 \
--extra-body '{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [20, 20, 20], "use_cfg_zero_star": true, "use_zero_init": true, "zero_steps": 1}' \
--output helios_t2v_mid.mp4
```
**Helios-Distilled** (Stage 2 pyramid + DMD, few-step):
```bash
python text_to_video.py \
--model BestWishYsh/Helios-Distilled \
--prompt "A dynamic time-lapse of scenery rushing past the window of a speeding train." \
--num-frames 240 \
--guidance-scale 1.0 \
--extra-body '{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [2, 2, 2], "is_amplify_first_chunk": true}' \
--output helios_t2v_distilled.mp4
```
> Helios image-to-video (I2V) and video-to-video (V2V) require image/video
> conditioning tensors that cannot be passed through the JSON `--extra-body`
> flag; they are out of scope for this text-to-video example.
## Key Arguments
### Common
@@ -112,6 +156,7 @@ python text_to_video.py \
- `--num-inference-steps`: sampling steps. Default depends on model.
- `--fps`: frames per second for the saved MP4.
- `--output`: path to save the generated video.
- `--extra-body`: JSON dict of model-specific knobs (declared in `vllm_omni/model_extras/`), merged into sampling `extra_args`. See the Helios recipes above.
- `--vae-use-slicing`: enable VAE slicing for memory optimization.
- `--vae-use-tiling`: enable VAE tiling for memory optimization.
- `--cfg-parallel-size`: set it to 2 to enable CFG Parallel. See more examples in [`user_guide`](../../../docs/user_guide/diffusion/parallelism_acceleration.md#cfg-parallel).
@@ -35,6 +35,15 @@ _MODEL_PRESETS = {
"fps": 24,
"output": "hunyuan_video_15_output.mp4",
},
"helios": {
"height": 384,
"width": 640,
"num_frames": 99,
"num_inference_steps": 50,
"guidance_scale": 5.0,
"fps": 16,
"output": "helios_output.mp4",
},
}
@@ -42,6 +51,8 @@ def _detect_preset(model: str) -> dict:
model_lower = model.lower()
if "hunyuan" in model_lower:
return _MODEL_PRESETS["hunyuan"]
if "helios" in model_lower:
return _MODEL_PRESETS["helios"]
return _MODEL_PRESETS["wan"]
@@ -55,10 +66,26 @@ def parse_profiler_config(value: str) -> dict[str, Any]:
return config
def parse_extra_body(value: str) -> dict[str, Any]:
"""Parse a JSON object of model-specific extra_body params.
Pipeline-declared knobs (see vllm_omni/model_extras/) are merged into
OmniDiffusionSamplingParams.extra_args, so a single generic example can
drive model-specific behaviour without bespoke per-model flags.
"""
try:
body = json.loads(value)
except json.JSONDecodeError as e:
raise argparse.ArgumentTypeError(f"--extra-body must be valid JSON: {e}") from e
if not isinstance(body, dict):
raise argparse.ArgumentTypeError("--extra-body must be a JSON object")
return body
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a video from a text prompt. "
"Supports Wan2.2, HunyuanVideo-1.5, and other text-to-video models."
"Supports Wan2.2, HunyuanVideo-1.5, Helios, and other text-to-video models."
)
parser.add_argument(
"--model",
@@ -74,6 +101,14 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--prompt", default="A serene lakeside sunrise with mist over the water.", help="Text prompt.")
parser.add_argument("--negative-prompt", default="", help="Negative prompt.")
parser.add_argument(
"--extra-body",
type=parse_extra_body,
default=None,
help="JSON dict of model-specific extra_body params (declared in vllm_omni/model_extras/), "
"merged into sampling extra_args. Example (Helios-Distilled): "
'\'{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [2, 2, 2], "is_amplify_first_chunk": true}\'.',
)
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
parser.add_argument("--guidance-scale", type=float, default=None, help="CFG scale. Default: model-specific.")
parser.add_argument(
@@ -357,6 +392,9 @@ def main():
)
if args.guidance_scale_high is not None:
sampling_kwargs["guidance_scale_2"] = args.guidance_scale_high
if args.extra_body:
# Model-specific knobs (declared in vllm_omni/model_extras/) routed via extra_args.
sampling_kwargs["extra_args"] = dict(args.extra_body)
generation_start = time.perf_counter()
frames = omni.generate(
@@ -87,6 +87,63 @@ python x_to_video_audio.py \
```
## MagiHuman (`--model-type magi-human`)
MagiHuman is a text → video+audio model with a DiT MoE backbone and a ~9B-param
T5Gemma text encoder. A detailed text prompt is the only required input; an optional
image and/or audio file may be supplied for conditioning. Natively supports Tensor
Parallelism. For an 80GB node, `--tensor-parallel-size 4` is recommended to shard
the MoE weights and the text encoder.
> Install [MagiCompiler](https://github.com/SandAI-org/MagiCompiler) for correct
> attention-kernel behaviour (the pipeline otherwise falls back to stubs).
Text-only generation:
```bash
python x_to_video_audio.py \
--model-type magi-human \
--model /path/to/daVinci-MagiHuman \
--prompt "A young woman with long, wavy golden blonde hair... <dialogue and background sound>" \
--tensor-parallel-size 4 \
--height 256 --width 448 \
--num-inference-steps 8 \
--seed 52 \
--extra-body '{"seconds": 5, "sr_height": 1080, "sr_width": 1920, "sr_num_inference_steps": 5}' \
--output output_magihuman.mp4
```
With optional image and audio conditioning:
```bash
python x_to_video_audio.py \
--model-type magi-human \
--model /path/to/daVinci-MagiHuman \
--prompt "A young woman..." \
--tensor-parallel-size 4 \
--height 256 --width 448 \
--num-inference-steps 8 \
--seed 52 \
--extra-body '{"image_path": "/path/to/ref.jpg", "audio_path": "/path/to/ref.wav", "sr_height": 1080, "sr_width": 1920, "sr_num_inference_steps": 5}' \
--output output_magihuman.mp4
```
MagiHuman-specific arguments are passed as a JSON dict via `--extra-body` (declared
in `vllm_omni/model_extras/magi_human.py`, routed via `extra_args`):
- `seconds`: output duration in seconds (default 10; ignored when `audio_path` is set,
because the audio length then determines the number of frames).
- `audio_path`: path to an audio file for audio-to-video conditioning. When provided,
the audio drives the frame count and is encoded as a latent condition into the DiT
(`is_a2v` mode). Omit for pure text-to-video+audio generation.
- `image_path`: path to an image file for visual conditioning. Applied at both the
base-resolution (BR) and super-resolution (SR) stages when SR is enabled.
- `sr_height` / `sr_width`: super-resolution output resolution. SR stage is skipped
when these are omitted.
- `sr_num_inference_steps`: denoising steps for the SR stage.
## DreamID-Omni arguments
Key arguments:
- `--prompt`: text description (string).
- `--model`: path to the model local directory.
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse
import json
import re
import time
from typing import Any
@@ -20,14 +21,26 @@ AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Offline inference for DreamID-Omni (video + audio).")
parser.add_argument("--model", required=True, help="DreamID ckpt root directory.")
parser.add_argument("--model-type", default="dreamid-omni", help="Model type.")
parser = argparse.ArgumentParser(description="Offline inference for X -> video+audio models.")
parser.add_argument("--model", required=True, help="Model ckpt root directory.")
parser.add_argument(
"--model-type",
default="dreamid-omni",
choices=["dreamid-omni", "magi-human"],
help="Model type.",
)
parser.add_argument("--prompt", default=None, help="Text prompt.")
parser.add_argument("--image-path", type=str, nargs="+", help="list of image-path")
parser.add_argument("--audio-path", type=str, nargs="+", help="list of audio-path")
parser.add_argument("--prompt-file", type=str, default=None, help="Text prompt in json format.")
parser.add_argument(
"--extra-body",
type=str,
default=None,
help="[magi-human] JSON dict of model-specific extra params (declared in vllm_omni/model_extras/), "
'merged into sampling extra_args. Example: \'{"image_path": "/path/to/img.jpg", "seconds": 5}\'.',
)
parser.add_argument("--height", type=int, default=704, help="Video height.")
parser.add_argument("--width", type=int, default=1280, help="Video width.")
@@ -142,8 +155,6 @@ def main() -> None:
text_prompt = args.prompt
if args.prompt_file:
import json
with open(args.prompt_file) as f:
text_prompt = json.load(f)
text_prompt = re.sub(
@@ -155,25 +166,36 @@ def main() -> None:
text_prompt = re.sub(r"\[[A-Z_]+\]", "", text_prompt)
text_prompt = re.sub(r"\n\s*\n", "\n", text_prompt).strip()
image, audio = load_image_and_audio(args.image_path, args.audio_path)
if args.model_type == "magi-human":
prompt = text_prompt
extra_args = json.loads(args.extra_body) if args.extra_body else {}
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
seed=args.seed,
extra_args=extra_args,
)
else:
image, audio = load_image_and_audio(args.image_path, args.audio_path)
prompt = {
"prompt": text_prompt,
"video_negative_prompt": args.video_negative_prompt,
"audio_negative_prompt": args.audio_negative_prompt,
"multi_modal_data": {"image": image, "audio": audio},
}
prompt = {
"prompt": text_prompt,
"video_negative_prompt": args.video_negative_prompt,
"audio_negative_prompt": args.audio_negative_prompt,
"multi_modal_data": {"image": image, "audio": audio},
}
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
seed=args.seed,
extra_args={
"solver_name": args.solver_name,
"shift": args.shift,
},
)
sampling_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_inference_steps=args.num_inference_steps,
seed=args.seed,
extra_args={
"solver_name": args.solver_name,
"shift": args.shift,
},
)
parallel_config = DiffusionParallelConfig(
cfg_parallel_size=args.cfg_parallel_size,
@@ -215,22 +237,28 @@ def main() -> None:
elapsed = time.perf_counter() - start
if not outputs:
raise RuntimeError("No output returned from DreamID-Omni.")
raise RuntimeError("No output returned from the model.")
result = outputs[0]
if not result.images:
raise RuntimeError("No video frames found in DreamID-Omni output.")
raise RuntimeError("No video frames found in model output.")
generated_video = result.images[0]
mm = result.multimodal_output or {}
generated_audio = mm.get("audio")
fps = int(mm.get("fps", 24))
sample_rate = int(mm.get("audio_sample_rate", 16000))
# DreamID-Omni returns video as (C, F, H, W) float32 in [-1, 1].
# mux_video_audio_bytes expects (F, H, W, C) uint8.
if not isinstance(generated_video, np.ndarray) or generated_video.ndim != 4:
raise RuntimeError(f"Unexpected video shape: {getattr(generated_video, 'shape', None)}")
frames = generated_video.transpose(1, 2, 3, 0)
frames = (np.clip((frames + 1.0) / 2.0, 0.0, 1.0) * 255.0).round().astype(np.uint8)
if args.model_type == "magi-human":
# MagiHuman returns frames already as (F, H, W, C) uint8, ready to mux.
fps = float(mm.get("fps", 25))
sample_rate = int(mm.get("audio_sample_rate", 24000))
frames = generated_video
else:
fps = float(mm.get("fps", 24))
sample_rate = int(mm.get("audio_sample_rate", 16000))
# DreamID-Omni returns video as (C, F, H, W) float32 in [-1, 1].
# mux_video_audio_bytes expects (F, H, W, C) uint8.
if not isinstance(generated_video, np.ndarray) or generated_video.ndim != 4:
raise RuntimeError(f"Unexpected video shape: {getattr(generated_video, 'shape', None)}")
frames = generated_video.transpose(1, 2, 3, 0)
frames = (np.clip((frames + 1.0) / 2.0, 0.0, 1.0) * 255.0).round().astype(np.uint8)
audio_np = None
if generated_audio is not None:
+146
View File
@@ -0,0 +1,146 @@
# AURA Omni Native Pipeline
`aura_omni` serves AURA as a native multi-stage vLLM-Omni pipeline:
```text
Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Qwen3-TTS Code2Wav
```
The pipeline has three semantic modules, but four engine stages because the
existing Qwen3-TTS implementation is natively split into Talker and Code2Wav.
Start the server with the deploy profile:
```bash
vllm serve aurateam/AURA \
--omni \
--port 8091 \
--deploy-config vllm_omni/deploy/aura_omni.yaml \
--served-model-name aurateam/AURA \
--trust-remote-code
```
The deploy file sets per-stage model repos:
- Stage 0 ASR: `Qwen/Qwen3-ASR-1.7B`
- Stage 1 AURA: `aurateam/AURA`
- Stage 2/3 TTS: `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
For local weights, edit the `model` value on each stage in
`vllm_omni/deploy/aura_omni.yaml`. The deploy profile includes
`pipeline: aura_omni`, so the server uses this four-stage topology even when
the command-line model path points at one component checkpoint.
Expected request shape:
- Send microphone audio as the Stage 0 multimodal audio input.
- Include video frames in the original request `multi_modal_data`; the
`asr2aura` processor carries them forward to AURA.
- Optional `additional_information` keys:
- `aura_system_prompt`
- `tts_task_type`
- `tts_language`
- `tts_speaker`
- `tts_instruct`
- `tts_ref_audio`
- `tts_ref_text`
- `tts_x_vector_only_mode`
- `tts_pass_token_ids`
If AURA emits `<|silent|>`, the `aura2tts` processor returns no TTS request, so
the TTS stages are skipped for that turn.
## GPU Utilization Recommendation
Tune `gpu_memory_utilization` per stage in `vllm_omni/deploy/aura_omni.yaml`.
Recommended baseline on one GPU for H200
- Stage 0 (ASR): `0.10`
- Stage 1 (AURA): `0.4`
- Stage 2 (Qwen3-TTS Talker): `0.20`
- Stage 3 (Qwen3-TTS Code2Wav): `0.20`
## Python Client
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--host localhost \
--port 8091 \
--model aurateam/AURA \
--modalities text,audio
```
Use local media:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--audio-path /path/to/input.wav \
--video-path /path/to/video.mp4 \
--output-dir output_aura_omni_online
```
Base voice clone mode (default, recommended as x-vector while debugging ICL):
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-task-type Base \
--tts-ref-audio vllm-omni/tests/assets/qwen3_tts/clone_2.wav \
--tts-ref-text "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
```
Enable AURA token-id passthrough explicitly:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-pass-token-ids
```
CustomVoice mode requires stages 2 and 3 in `aura_omni.yaml` to point at a
Qwen3-TTS CustomVoice checkpoint:
```bash
python examples/online_serving/aura_omni/openai_chat_completion_client.py \
--tts-task-type CustomVoice \
--tts-speaker Vivian
```
By default, AURA responses are passed to Qwen3-TTS as text. Set
`tts_pass_token_ids=true` to pass AURA-generated assistant token ids directly
to Qwen3-TTS instead. The processor still uses AURA token ids, when available,
to estimate the Talker prompt length in the default text path.
## Curl
```bash
cd examples/online_serving/aura_omni
bash run_curl_multimodal_generation.sh
```
Set `PORT`, `MODEL`, or `OUTPUT_DIR` to override defaults:
```bash
PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh
TTS_PASS_TOKEN_IDS=true PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh
```
## Gradio
Launch the server and Gradio UI together:
```bash
cd examples/online_serving/aura_omni
bash run_gradio_demo.sh
```
If the server is already running:
```bash
python examples/online_serving/aura_omni/gradio_demo.py \
--model aurateam/AURA \
--api-base http://localhost:8091/v1
```
## Offline
For offline inference, see
[`examples/offline_inference/aura_omni`](../../offline_inference/aura_omni/).
@@ -0,0 +1,305 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Gradio demo for AURA Omni online serving."""
from __future__ import annotations
import argparse
import base64
import io
from pathlib import Path
from typing import Any
try:
import gradio as gr
except ImportError:
raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None
import numpy as np
import soundfile as sf
from openai import OpenAI
from vllm_omni.model_executor.stage_input_processors.aura_omni import (
DEFAULT_QWEN3_TTS_REF_TEXT,
default_qwen3_tts_ref_audio_path,
)
SEED = 42
DEFAULT_TTS_REF_AUDIO = default_qwen3_tts_ref_audio_path()
def parse_args():
parser = argparse.ArgumentParser(description="Gradio demo for AURA Omni online inference.")
parser.add_argument("--model", default="aurateam/AURA", help="Model name served by vLLM.")
parser.add_argument("--api-base", default="http://localhost:8091/v1", help="OpenAI-compatible API base.")
parser.add_argument("--ip", default="127.0.0.1", help="Gradio host.")
parser.add_argument("--port", type=int, default=7862, help="Gradio port.")
parser.add_argument("--share", action="store_true", help="Share the Gradio demo publicly.")
return parser.parse_args()
def _audio_to_data_url(audio_file: Any | None) -> str | None:
if audio_file is None:
return None
def _path_to_data_url(path_str: str) -> str | None:
if not path_str:
return None
path = Path(path_str)
if not path.exists():
return None
with open(path, "rb") as f:
return f"data:audio/wav;base64,{base64.b64encode(f.read()).decode('utf-8')}"
if isinstance(audio_file, str):
return _path_to_data_url(audio_file)
# Gradio >=5 may return dict payloads with local temp path.
if isinstance(audio_file, dict):
path_val = audio_file.get("path") or audio_file.get("name")
if isinstance(path_val, str):
return _path_to_data_url(path_val)
return None
if isinstance(audio_file, tuple):
# Common shape: (sample_rate, np.ndarray)
if len(audio_file) == 2 and isinstance(audio_file[0], (int, float)):
sample_rate, audio_np = audio_file
audio_np = np.asarray(audio_np)
if audio_np.ndim > 1:
audio_np = audio_np[:, 0]
if audio_np.dtype != np.int16:
audio_np = np.clip(audio_np.astype(np.float32), -1.0, 1.0)
buf = io.BytesIO()
sf.write(buf, audio_np, int(sample_rate), format="WAV")
return f"data:audio/wav;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
# Compatibility shape from some gradio builds: (filepath, (sr, np.ndarray))
if len(audio_file) >= 1 and isinstance(audio_file[0], str):
path_url = _path_to_data_url(audio_file[0])
if path_url is not None:
return path_url
return None
def _video_to_data_url(video_file: str | None) -> str | None:
if not video_file:
return None
path = Path(video_file)
mime = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
}.get(path.suffix.lower(), "video/mp4")
with open(path, "rb") as f:
return f"data:{mime};base64,{base64.b64encode(f.read()).decode('utf-8')}"
def _sampling_params_list() -> list[dict[str, Any]]:
return [
{"temperature": 0.0, "top_p": 1.0, "top_k": -1, "max_tokens": 256, "seed": SEED},
{
"temperature": 0.5,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 256,
"seed": SEED,
"repetition_penalty": 1.0,
},
{
"temperature": 0.9,
"top_k": 50,
"max_tokens": 4096,
"seed": SEED,
"detokenize": False,
"repetition_penalty": 1.05,
"stop_token_ids": [2150],
},
{
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 65536,
"seed": SEED,
"repetition_penalty": 1.0,
},
]
def _decode_audio_choice(audio_data: str | None) -> tuple[int, np.ndarray] | None:
if not audio_data:
return None
wav_bytes = base64.b64decode(audio_data)
audio_np, sample_rate = sf.read(io.BytesIO(wav_bytes))
if audio_np.ndim > 1:
audio_np = audio_np[:, 0]
return int(sample_rate), audio_np.astype(np.float32)
def build_interface(client: OpenAI, model: str):
def run(
audio_file,
video_file,
prompt: str,
aura_system_prompt: str,
tts_task_type: str,
tts_language: str,
tts_speaker: str,
tts_instruct: str,
tts_ref_audio: str,
tts_ref_text: str,
tts_x_vector_only_mode: bool,
tts_pass_token_ids: bool,
):
audio_url = _audio_to_data_url(audio_file)
video_url = _video_to_data_url(video_file)
if not audio_url:
return "Please provide audio input for the ASR stage.", None
if tts_task_type == "Base" and (not (tts_ref_audio or "").strip() or not (tts_ref_text or "").strip()):
return "Base TTS requires both reference audio and reference transcript.", None
content: list[dict[str, Any]] = [{"type": "audio_url", "audio_url": {"url": audio_url}}]
if video_url:
content.append({"type": "video_url", "video_url": {"url": video_url}})
content.append(
{"type": "text", "text": prompt or "Use the audio and video together to decide whether a reply is needed."}
)
additional_information: dict[str, Any] = {
"aura_system_prompt": aura_system_prompt,
"tts_task_type": tts_task_type,
"tts_instruct": tts_instruct,
"tts_pass_token_ids": bool(tts_pass_token_ids),
}
if tts_task_type == "CustomVoice":
additional_information.update(
{
"tts_language": tts_language or "English",
"tts_speaker": tts_speaker or "Vivian",
}
)
else:
additional_information.update(
{
"tts_ref_audio": tts_ref_audio.strip() if tts_ref_audio else None,
"tts_ref_text": tts_ref_text.strip() if tts_ref_text else None,
"tts_x_vector_only_mode": bool(tts_x_vector_only_mode),
}
)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": content}],
modalities=["text", "audio"],
extra_body={
"sampling_params_list": _sampling_params_list(),
"additional_information": additional_information,
},
timeout=600.0,
)
text_parts: list[str] = []
audio_output = None
for choice in response.choices:
message = choice.message
if message.content:
text_parts.append(str(message.content))
if getattr(message, "audio", None):
audio_output = _decode_audio_choice(message.audio.data)
return "\n\n".join(text_parts) or "No text output.", audio_output
default_system = (
"You are receiving a live video stream where the final frame is the present moment. "
"Respond only when a response is needed. Otherwise output '<|silent|>'. Respond in English."
)
def _toggle_tts_advanced(task_type: str):
is_base = task_type == "Base"
return (
gr.update(visible=not is_base),
gr.update(visible=is_base),
)
with gr.Blocks() as demo:
gr.Markdown("# AURA Omni")
gr.Markdown("ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Code2Wav")
with gr.Row():
audio_input = gr.Audio(label="Audio input", sources=["upload", "microphone"], type="numpy")
video_input = gr.Video(label="Video input (optional)", sources=["upload"])
prompt = gr.Textbox(
label="Instruction",
value="Use the audio and video together to decide whether a reply is needed. If needed, respond briefly in English.",
lines=2,
)
with gr.Accordion("Advanced", open=False):
aura_system_prompt = gr.Textbox(label="AURA system prompt", value=default_system, lines=4)
tts_task_type = gr.Radio(choices=["Base", "CustomVoice"], value="Base", label="TTS task type")
tts_instruct = gr.Textbox(label="TTS instruction", value="")
tts_pass_token_ids = gr.Checkbox(
label="Pass AURA token ids directly to TTS",
value=False,
)
with gr.Group(visible=False) as customvoice_config_group:
tts_language = gr.Dropdown(
choices=["Chinese", "English", "Japanese", "Korean", "Cantonese"],
value="Chinese",
label="CustomVoice language",
)
tts_speaker = gr.Textbox(label="CustomVoice speaker", value="Vivian")
with gr.Group(visible=True) as base_config_group:
tts_ref_audio = gr.Textbox(
label="Base reference audio path/URL",
value=DEFAULT_TTS_REF_AUDIO,
)
tts_ref_text = gr.Textbox(
label="Base reference transcript",
value=DEFAULT_QWEN3_TTS_REF_TEXT,
lines=3,
)
tts_x_vector_only_mode = gr.Checkbox(
label="Base x-vector only mode (disable ICL)",
value=False,
)
tts_task_type.change(
_toggle_tts_advanced,
inputs=[tts_task_type],
outputs=[customvoice_config_group, base_config_group],
)
button = gr.Button("Generate", variant="primary")
with gr.Row():
text_output = gr.Textbox(label="Text output", lines=8)
audio_output = gr.Audio(label="Audio output", interactive=False)
button.click(
run,
inputs=[
audio_input,
video_input,
prompt,
aura_system_prompt,
tts_task_type,
tts_language,
tts_speaker,
tts_instruct,
tts_ref_audio,
tts_ref_text,
tts_x_vector_only_mode,
tts_pass_token_ids,
],
outputs=[text_output, audio_output],
)
demo.queue()
return demo
def main() -> None:
args = parse_args()
client = OpenAI(api_key="EMPTY", base_url=args.api_base)
demo = build_interface(client, args.model)
demo.launch(server_name=args.ip, server_port=args.port, share=args.share)
if __name__ == "__main__":
main()
@@ -0,0 +1,199 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""OpenAI-compatible client for the AURA Omni pipeline."""
from __future__ import annotations
import base64
import io
import os
import soundfile as sf
from openai import OpenAI
from vllm.assets.audio import AudioAsset
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni.model_executor.stage_input_processors.aura_omni import (
DEFAULT_QWEN3_TTS_REF_TEXT,
default_qwen3_tts_ref_audio_path,
)
SEED = 42
DEFAULT_MODEL = "aurateam/AURA"
DEFAULT_VIDEO_URL = "https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4"
def _encode_file(path: str) -> str:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _data_url(path: str, default_mime: str) -> str:
suffix = os.path.splitext(path)[1].lower()
mime_by_suffix = {
".wav": "audio/wav",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".flac": "audio/flac",
".m4a": "audio/mp4",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
}
return f"data:{mime_by_suffix.get(suffix, default_mime)};base64,{_encode_file(path)}"
def media_url(path_or_url: str | None, *, kind: str) -> str:
if path_or_url:
if path_or_url.startswith(("http://", "https://", "data:")):
return path_or_url
if not os.path.exists(path_or_url):
raise FileNotFoundError(f"{kind} file not found: {path_or_url}")
return _data_url(path_or_url, "audio/wav" if kind == "audio" else "video/mp4")
if kind == "audio":
return AudioAsset("mary_had_lamb").url
return DEFAULT_VIDEO_URL
def sampling_params_list() -> list[dict]:
return [
{"temperature": 0.0, "top_p": 1.0, "top_k": -1, "max_tokens": 256, "seed": SEED},
{
"temperature": 0.5,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 256,
"seed": SEED,
"repetition_penalty": 1.0,
},
{
"temperature": 0.9,
"top_k": 50,
"max_tokens": 4096,
"seed": SEED,
"detokenize": False,
"repetition_penalty": 1.05,
"stop_token_ids": [2150],
},
{
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 65536,
"seed": SEED,
"repetition_penalty": 1.0,
},
]
def parse_modalities(value: str | None) -> list[str] | None:
if not value:
return None
return [item.strip() for item in value.split(",") if item.strip()]
def save_response(response, output_dir: str) -> None:
os.makedirs(output_dir, exist_ok=True)
for idx, choice in enumerate(response.choices):
message = choice.message
if message.content:
out_txt = os.path.join(output_dir, f"choice_{idx}.txt")
with open(out_txt, "w", encoding="utf-8") as f:
f.write(str(message.content).strip() + "\n")
print(f"Text saved to {out_txt}")
print(message.content)
if getattr(message, "audio", None):
audio_bytes = base64.b64decode(message.audio.data)
audio_np, sample_rate = sf.read(io.BytesIO(audio_bytes))
out_wav = os.path.join(output_dir, f"choice_{idx}.wav")
sf.write(out_wav, audio_np, int(sample_rate), format="WAV")
print(f"Audio saved to {out_wav}")
def main(args) -> None:
client = OpenAI(base_url=f"http://{args.host}:{args.port}/v1", api_key="EMPTY")
messages = [
{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": media_url(args.audio_path, kind="audio")}},
{"type": "video_url", "video_url": {"url": media_url(args.video_path, kind="video")}},
{"type": "text", "text": args.prompt},
],
}
]
response = client.chat.completions.create(
model=args.model,
messages=messages,
modalities=parse_modalities(args.modalities),
extra_body={
"sampling_params_list": sampling_params_list(),
"additional_information": {
"aura_system_prompt": args.aura_system_prompt,
"tts_task_type": args.tts_task_type,
"tts_language": args.tts_language,
"tts_speaker": args.tts_speaker,
"tts_instruct": args.tts_instruct,
"tts_ref_audio": args.tts_ref_audio,
"tts_ref_text": args.tts_ref_text,
"tts_x_vector_only_mode": args.tts_x_vector_only_mode,
"tts_pass_token_ids": args.tts_pass_token_ids,
},
},
timeout=args.timeout,
)
save_response(response, args.output_dir)
def parse_args():
parser = FlexibleArgumentParser(description="AURA Omni online serving client")
parser.add_argument("--host", default="localhost")
parser.add_argument("--port", type=int, default=8091)
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--audio-path", default=None, help="Audio file, URL, or data URL.")
parser.add_argument("--video-path", default=None, help="Video file, URL, or data URL.")
parser.add_argument(
"--prompt",
default="Use the audio and video together to decide whether a reply is needed. If needed, respond briefly in English.",
)
parser.add_argument("--modalities", default="text,audio")
parser.add_argument("--output-dir", default="output_aura_omni_online")
parser.add_argument(
"--aura-system-prompt",
default=(
"You are receiving a live video stream where the final frame is the present moment. "
"Respond only when a response is needed. Otherwise output '<|silent|>'. Respond in English."
),
)
parser.add_argument("--tts-task-type", default="Base", choices=["Base", "CustomVoice"])
parser.add_argument("--tts-language", default="English")
parser.add_argument("--tts-speaker", default="Vivian")
parser.add_argument("--tts-instruct", default="")
parser.add_argument(
"--tts-ref-audio",
default=default_qwen3_tts_ref_audio_path(),
help="Base-mode reference audio path/URL visible to server.",
)
parser.add_argument(
"--tts-ref-text",
default=DEFAULT_QWEN3_TTS_REF_TEXT,
help="Base-mode reference audio transcript.",
)
parser.add_argument(
"--tts-x-vector-only-mode",
action="store_true",
help="Use speaker embedding only for Base mode (disable ICL ref_text conditioning).",
)
parser.add_argument(
"--tts-pass-token-ids",
action="store_true",
help="Pass AURA-generated assistant token ids directly to Qwen3-TTS. Defaults to sending text.",
)
parser.add_argument("--timeout", type=float, default=600.0)
return parser.parse_args()
if __name__ == "__main__":
main(parse_args())
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${PORT:-8091}"
MODEL="${MODEL:-aurateam/AURA}"
OUTPUT_DIR="${OUTPUT_DIR:-output_aura_omni_online}"
TTS_PASS_TOKEN_IDS="${TTS_PASS_TOKEN_IDS:-false}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VLLM_OMNI_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
CLONE_REF_AUDIO="${VLLM_OMNI_ROOT}/tests/assets/qwen3_tts/clone_2.wav"
CLONE_REF_TEXT="Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
mkdir -p "$OUTPUT_DIR"
MARY_HAD_LAMB_AUDIO_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/mary_had_lamb.ogg"
SAMPLE_VIDEO_URL="https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4"
request_body=$(cat <<EOF
{
"model": "$MODEL",
"modalities": ["text", "audio"],
"sampling_params_list": [
{"temperature": 0.0, "top_p": 1.0, "top_k": -1, "max_tokens": 256, "seed": 42},
{"temperature": 0.5, "top_p": 1.0, "top_k": -1, "max_tokens": 256, "seed": 42, "repetition_penalty": 1.0},
{"temperature": 0.9, "top_k": 50, "max_tokens": 4096, "seed": 42, "detokenize": false, "repetition_penalty": 1.05, "stop_token_ids": [2150]},
{"temperature": 0.0, "top_p": 1.0, "top_k": -1, "max_tokens": 65536, "seed": 42, "repetition_penalty": 1.0}
],
"additional_information": {
"aura_system_prompt": "You are receiving a live video stream where the final frame is the present moment. Respond only when a response is needed. Otherwise output '<|silent|>'. Respond in English.",
"tts_task_type": "Base",
"tts_ref_audio": "file://${CLONE_REF_AUDIO}",
"tts_ref_text": "${CLONE_REF_TEXT}",
"tts_language": "English",
"tts_speaker": "Vivian",
"tts_instruct": "",
"tts_pass_token_ids": ${TTS_PASS_TOKEN_IDS}
},
"messages": [{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": "$MARY_HAD_LAMB_AUDIO_URL"}},
{"type": "video_url", "video_url": {"url": "$SAMPLE_VIDEO_URL"}},
{"type": "text", "text": "Use the audio and video together to decide whether a reply is needed. If needed, respond briefly in English."}
]
}]
}
EOF
)
response=$(curl -sS --retry 3 --retry-delay 3 --retry-connrefused \
-X POST "http://localhost:${PORT}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$request_body")
echo "$response" | jq '.choices[].message.content'
audio_b64=$(echo "$response" | jq -r '.choices[]?.message.audio.data // empty' | head -n 1)
if [[ -n "$audio_b64" ]]; then
echo "$audio_b64" | base64 -d > "${OUTPUT_DIR}/aura_omni_output.wav"
echo "Audio saved to ${OUTPUT_DIR}/aura_omni_output.wav"
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL="aurateam/AURA"
SERVER_MODEL="aurateam/AURA"
DEPLOY_CONFIG="/data/yrr/vllm-omni/vllm_omni/deploy/aura_omni.yaml"
SERVER_PORT=8091
GRADIO_PORT=7862
SERVER_HOST="0.0.0.0"
GRADIO_IP="127.0.0.1"
GRADIO_SHARE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--server-model) SERVER_MODEL="$2"; shift 2 ;;
--deploy-config) DEPLOY_CONFIG="$2"; shift 2 ;;
--server-port) SERVER_PORT="$2"; shift 2 ;;
--gradio-port) GRADIO_PORT="$2"; shift 2 ;;
--server-host) SERVER_HOST="$2"; shift 2 ;;
--gradio-ip) GRADIO_IP="$2"; shift 2 ;;
--share) GRADIO_SHARE=true; shift ;;
--help)
echo "Usage: $0 [--model SERVED_MODEL_NAME] [--server-model MODEL_PATH] [--deploy-config YAML] [--server-port PORT] [--gradio-port PORT] [--share]"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API_BASE="http://localhost:${SERVER_PORT}/v1"
LOG_FILE="/tmp/aura_omni_vllm_${SERVER_PORT}.log"
cleanup() {
echo "Shutting down..."
[[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true
[[ -n "${GRADIO_PID:-}" ]] && kill "$GRADIO_PID" 2>/dev/null || true
}
trap cleanup SIGINT SIGTERM EXIT
vllm serve "$SERVER_MODEL" \
--omni \
--host "$SERVER_HOST" \
--port "$SERVER_PORT" \
--deploy-config "$DEPLOY_CONFIG" \
--served-model-name "$MODEL" \
--trust-remote-code 2>&1 | tee "$LOG_FILE" &
SERVER_PID=$!
echo "Waiting for server startup..."
for _ in $(seq 1 600); do
if grep -q "Application startup complete" "$LOG_FILE" 2>/dev/null; then
break
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "vLLM server exited before startup completed"
wait "$SERVER_PID" || true
exit 1
fi
sleep 1
done
# cd "$SCRIPT_DIR"
GRADIO_CMD=(python gradio_demo.py --model "$MODEL" --api-base "$API_BASE" --ip "$GRADIO_IP" --port "$GRADIO_PORT")
if [[ "$GRADIO_SHARE" == "true" ]]; then
GRADIO_CMD+=(--share)
fi
"${GRADIO_CMD[@]}" &
GRADIO_PID=$!
echo "vLLM server: http://${SERVER_HOST}:${SERVER_PORT}"
echo "Gradio demo: http://${GRADIO_IP}:${GRADIO_PORT}"
if [[ -n "${SERVER_PID:-}" ]]; then
wait "$SERVER_PID" "$GRADIO_PID"
else
wait "$GRADIO_PID"
fi
-175
View File
@@ -1,175 +0,0 @@
# Helios Video Generation via Online API
This example demonstrates how to use the `/v1/videos` API with Helios models using the generic `extra_params` field.
## Overview
The `/v1/videos` API now supports model-specific parameters through the `extra_params` field, which accepts a JSON object containing any model-specific configuration. This allows supporting new models like Helios without modifying the API.
## Helios Model Variants
- **Helios-Base**: Basic T2V/I2V/V2V generation (Stage 1 only)
- **Helios-Mid**: Advanced generation with CFG-Zero* (Stage 2)
- **Helios-Distilled**: Fast generation with DMD (Stage 2)
## API Usage Examples
### 1. T2V (Text-to-Video) - Helios-Base
Basic text-to-video generation:
```bash
curl -X POST http://localhost:8000/v1/videos \
-F "prompt=A serene lakeside sunrise with mist over the water." \
-F "model=BestWishYsh/Helios-Base" \
-F "width=640" \
-F "height=384" \
-F "num_frames=99" \
-F "num_inference_steps=50" \
-F "guidance_scale=5.0" \
-F "seed=42"
```
### 2. I2V (Image-to-Video) - Helios-Base
Generate video from an input image:
```bash
curl -X POST http://localhost:8000/v1/videos \
-F "prompt=The lake water gently ripples as morning mist rises." \
-F "model=BestWishYsh/Helios-Base" \
-F "input_reference=@/path/to/image.jpg" \
-F "width=640" \
-F "height=384" \
-F "num_frames=99" \
-F "guidance_scale=5.0"
```
### 3. Helios-Mid with Stage 2 + CFG-Zero*
Advanced generation with pyramid multi-stage denoising:
```bash
curl -X POST http://localhost:8000/v1/videos \
-F "prompt=A serene lakeside sunrise with mist over the water." \
-F "model=BestWishYsh/Helios-Mid" \
-F "width=640" \
-F "height=384" \
-F "guidance_scale=5.0" \
-F 'extra_params={
"is_enable_stage2": true,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [20, 20, 20],
"use_cfg_zero_star": true,
"use_zero_init": true,
"zero_steps": 1
}'
```
### 4. Helios-Distilled with DMD
Fast generation with Distribution Matching Distillation:
```bash
curl -X POST http://localhost:8000/v1/videos \
-F "prompt=A serene lakeside sunrise with mist over the water." \
-F "model=BestWishYsh/Helios-Distilled" \
-F "width=640" \
-F "height=384" \
-F "guidance_scale=1.0" \
-F 'extra_params={
"is_enable_stage2": true,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [2, 2, 2],
"is_amplify_first_chunk": true
}'
```
## Model-Specific Parameters
The `extra_params` field accepts a JSON object with model-specific parameters. For Helios models, supported parameters include:
### Stage 2 Parameters
- `is_enable_stage2` (bool): Enable pyramid multi-stage denoising
- `pyramid_num_stages` (int): Number of pyramid stages (default: 3)
- `pyramid_num_inference_steps_list` (array): Steps per stage, e.g., `[20, 20, 20]`
### CFG Zero Star Parameters (Helios-Mid)
- `use_cfg_zero_star` (bool): Enable CFG Zero Star guidance
- `use_zero_init` (bool): Use zero initialization for first steps
- `zero_steps` (int): Number of initial zero prediction steps (default: 1)
### DMD Parameters (Helios-Distilled)
- `is_amplify_first_chunk` (bool): Enable DMD amplification for first chunk
### Video Input (V2V mode)
For video-to-video generation, upload a video file via `input_reference`:
```bash
curl -X POST http://localhost:8000/v1/videos \
-F "prompt=Transform the video into a watercolor painting style." \
-F "model=BestWishYsh/Helios-Base" \
-F "input_reference=@/path/to/video.mp4" \
-F "guidance_scale=5.0"
```
## Python Example
```python
import requests
import json
url = "http://localhost:8000/v1/videos"
# Helios-Mid with Stage 2
data = {
"prompt": "A serene lakeside sunrise with mist over the water.",
"model": "BestWishYsh/Helios-Mid",
"width": 640,
"height": 384,
"guidance_scale": 5.0,
"extra_params": json.dumps({
"is_enable_stage2": True,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [20, 20, 20],
"use_cfg_zero_star": True,
"use_zero_init": True,
"zero_steps": 1
})
}
response = requests.post(url, data=data)
video_job = response.json()
print(f"Video job created: {video_job['id']}")
# Poll for completion
import time
while True:
status_response = requests.get(f"{url}/{video_job['id']}")
status = status_response.json()
if status['status'] == 'completed':
print(f"Video generated: {status['file_name']}")
break
elif status['status'] == 'failed':
print(f"Generation failed: {status.get('error')}")
break
print(f"Progress: {status['progress']}%")
time.sleep(2)
```
## Benefits of This Approach
1. **No API Changes**: New models can be supported without modifying the API
2. **Backward Compatible**: Existing clients continue to work
3. **Flexible**: Any model-specific parameter can be passed
4. **Type-Safe**: Parameters are validated at the model level
5. **Future-Proof**: Supports models that don't exist yet
## Notes
- The `extra_params` field must be a valid JSON object
- Parameters are passed directly to the model's `extra_args`
- Invalid parameters will be caught by the model implementation
- Tensor inputs (images/videos) should use `input_reference`, not `extra_params`
@@ -1,212 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Helios video generation client example using the /v1/videos API.
This example demonstrates how to use the generic extra_params field
to pass Helios-specific parameters without modifying the API.
"""
import argparse
import json
import time
from pathlib import Path
import requests
def create_video_job(
api_url: str,
prompt: str,
model: str,
width: int = 640,
height: int = 384,
num_frames: int = 99,
guidance_scale: float = 5.0,
seed: int = 42,
extra_params: dict | None = None,
input_image: Path | None = None,
) -> dict:
"""Create a video generation job."""
data = {
"prompt": prompt,
"model": model,
"width": width,
"height": height,
"num_frames": num_frames,
"guidance_scale": guidance_scale,
"seed": seed,
}
files = {}
if input_image:
files["input_reference"] = open(input_image, "rb")
if extra_params:
data["extra_params"] = json.dumps(extra_params)
response = requests.post(f"{api_url}/v1/videos", data=data, files=files)
response.raise_for_status()
if files:
files["input_reference"].close()
return response.json()
def poll_video_status(api_url: str, video_id: str, poll_interval: int = 2) -> dict:
"""Poll video generation status until completion."""
print(f"Polling video job: {video_id}")
while True:
response = requests.get(f"{api_url}/v1/videos/{video_id}")
response.raise_for_status()
status_data = response.json()
status = status_data["status"]
progress = status_data.get("progress", 0)
print(f"Status: {status}, Progress: {progress}%")
if status == "completed":
print("Video generation completed!")
return status_data
elif status == "failed":
error = status_data.get("error", {})
raise RuntimeError(f"Video generation failed: {error}")
time.sleep(poll_interval)
def main():
parser = argparse.ArgumentParser(description="Helios video generation client")
parser.add_argument(
"--api-url",
default="http://localhost:8000",
help="API server URL",
)
parser.add_argument(
"--model",
default="BestWishYsh/Helios-Base",
help="Model name (Helios-Base, Helios-Mid, Helios-Distilled)",
)
parser.add_argument(
"--prompt",
default="A serene lakeside sunrise with mist over the water.",
help="Text prompt",
)
parser.add_argument(
"--width",
type=int,
default=640,
help="Video width",
)
parser.add_argument(
"--height",
type=int,
default=384,
help="Video height",
)
parser.add_argument(
"--num-frames",
type=int,
default=99,
help="Number of frames",
)
parser.add_argument(
"--guidance-scale",
type=float,
default=5.0,
help="Guidance scale",
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Random seed",
)
parser.add_argument(
"--input-image",
type=Path,
help="Input image for I2V mode",
)
parser.add_argument(
"--preset",
choices=["base", "mid-stage2", "distilled"],
default="base",
help="Helios preset configuration",
)
args = parser.parse_args()
# Define preset configurations
presets = {
"base": None, # No extra params for base model
"mid-stage2": {
"is_enable_stage2": True,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [20, 20, 20],
"use_cfg_zero_star": True,
"use_zero_init": True,
"zero_steps": 1,
},
"distilled": {
"is_enable_stage2": True,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [2, 2, 2],
"is_amplify_first_chunk": True,
},
}
extra_params = presets[args.preset]
print("=" * 50)
print("Helios Video Generation")
print("=" * 50)
print(f"API URL: {args.api_url}")
print(f"Model: {args.model}")
print(f"Preset: {args.preset}")
print(f"Prompt: {args.prompt}")
print(f"Size: {args.width}x{args.height}")
print(f"Frames: {args.num_frames}")
print(f"Guidance Scale: {args.guidance_scale}")
if extra_params:
print(f"Extra Params: {json.dumps(extra_params, indent=2)}")
print()
# Create video job
print("Creating video generation job...")
job = create_video_job(
api_url=args.api_url,
prompt=args.prompt,
model=args.model,
width=args.width,
height=args.height,
num_frames=args.num_frames,
guidance_scale=args.guidance_scale,
seed=args.seed,
extra_params=extra_params,
input_image=args.input_image,
)
video_id = job["id"]
print(f"Video job created: {video_id}")
print()
# Poll for completion
result = poll_video_status(args.api_url, video_id)
print()
print("=" * 50)
print("Generation Complete")
print("=" * 50)
print(f"Video ID: {result['id']}")
print(f"File: {result.get('file_name', 'N/A')}")
print(f"Inference Time: {result.get('inference_time_s', 'N/A')}s")
print()
if __name__ == "__main__":
main()
@@ -1,79 +0,0 @@
#!/bin/bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Helios-Distilled with DMD Example
# Fast generation with Distribution Matching Distillation
API_URL="${API_URL:-http://localhost:8000}"
MODEL="${MODEL:-BestWishYsh/Helios-Distilled}"
PROMPT="${PROMPT:-A serene lakeside sunrise with mist over the water.}"
echo "==================================="
echo "Helios-Distilled with DMD"
echo "==================================="
echo "API URL: $API_URL"
echo "Model: $MODEL"
echo "Prompt: $PROMPT"
echo ""
# Model-specific parameters for Helios-Distilled
extra_params='{
"is_enable_stage2": true,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [2, 2, 2],
"is_amplify_first_chunk": true
}'
echo "Model extra params: $extra_params"
echo ""
# Create video generation job
echo "Creating video generation job..."
RESPONSE=$(curl -s -X POST "$API_URL/v1/videos" \
-F "prompt=$PROMPT" \
-F "model=$MODEL" \
-F "width=640" \
-F "height=384" \
-F "guidance_scale=1.0" \
-F "seed=42" \
-F "extra_params=$extra_params")
echo "Response: $RESPONSE"
echo ""
# Extract video ID
VIDEO_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -z "$VIDEO_ID" ]; then
echo "Error: Failed to create video job"
exit 1
fi
echo "Video job created: $VIDEO_ID"
echo ""
# Poll for completion
echo "Polling for completion..."
while true; do
STATUS_RESPONSE=$(curl -s "$API_URL/v1/videos/$VIDEO_ID")
STATUS=$(echo "$STATUS_RESPONSE" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
PROGRESS=$(echo "$STATUS_RESPONSE" | grep -o '"progress":[0-9]*' | cut -d':' -f2)
echo "Status: $STATUS, Progress: $PROGRESS%"
if [ "$STATUS" = "completed" ]; then
echo ""
echo "Video generation completed!"
echo "Full response:"
echo "$STATUS_RESPONSE" | jq '.'
break
elif [ "$STATUS" = "failed" ]; then
echo ""
echo "Video generation failed!"
echo "$STATUS_RESPONSE" | jq '.'
exit 1
fi
sleep 2
done
@@ -1,81 +0,0 @@
#!/bin/bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Helios-Mid with Stage 2 + CFG-Zero* Example
# This demonstrates advanced generation with pyramid multi-stage denoising
API_URL="${API_URL:-http://localhost:8000}"
MODEL="${MODEL:-BestWishYsh/Helios-Mid}"
PROMPT="${PROMPT:-A serene lakeside sunrise with mist over the water.}"
echo "==================================="
echo "Helios-Mid Stage 2 + CFG-Zero*"
echo "==================================="
echo "API URL: $API_URL"
echo "Model: $MODEL"
echo "Prompt: $PROMPT"
echo ""
# Model-specific parameters for Helios-Mid
extra_params='{
"is_enable_stage2": true,
"pyramid_num_stages": 3,
"pyramid_num_inference_steps_list": [20, 20, 20],
"use_cfg_zero_star": true,
"use_zero_init": true,
"zero_steps": 1
}'
echo "Model extra params: $extra_params"
echo ""
# Create video generation job
echo "Creating video generation job..."
RESPONSE=$(curl -s -X POST "$API_URL/v1/videos" \
-F "prompt=$PROMPT" \
-F "model=$MODEL" \
-F "width=640" \
-F "height=384" \
-F "guidance_scale=5.0" \
-F "seed=42" \
-F "extra_params=$extra_params")
echo "Response: $RESPONSE"
echo ""
# Extract video ID
VIDEO_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -z "$VIDEO_ID" ]; then
echo "Error: Failed to create video job"
exit 1
fi
echo "Video job created: $VIDEO_ID"
echo ""
# Poll for completion
echo "Polling for completion..."
while true; do
STATUS_RESPONSE=$(curl -s "$API_URL/v1/videos/$VIDEO_ID")
STATUS=$(echo "$STATUS_RESPONSE" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
PROGRESS=$(echo "$STATUS_RESPONSE" | grep -o '"progress":[0-9]*' | cut -d':' -f2)
echo "Status: $STATUS, Progress: $PROGRESS%"
if [ "$STATUS" = "completed" ]; then
echo ""
echo "Video generation completed!"
echo "Full response:"
echo "$STATUS_RESPONSE" | jq '.'
break
elif [ "$STATUS" = "failed" ]; then
echo ""
echo "Video generation failed!"
echo "$STATUS_RESPONSE" | jq '.'
exit 1
fi
sleep 2
done
@@ -1,69 +0,0 @@
#!/bin/bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Helios Text-to-Video Generation Example
# This script demonstrates how to use the /v1/videos API with Helios models
API_URL="${API_URL:-http://localhost:8000}"
MODEL="${MODEL:-BestWishYsh/Helios-Base}"
PROMPT="${PROMPT:-A serene lakeside sunrise with mist over the water.}"
echo "==================================="
echo "Helios T2V Generation"
echo "==================================="
echo "API URL: $API_URL"
echo "Model: $MODEL"
echo "Prompt: $PROMPT"
echo ""
# Create video generation job
echo "Creating video generation job..."
RESPONSE=$(curl -s -X POST "$API_URL/v1/videos" \
-F "prompt=$PROMPT" \
-F "model=$MODEL" \
-F "width=640" \
-F "height=384" \
-F "num_frames=99" \
-F "num_inference_steps=50" \
-F "guidance_scale=5.0" \
-F "seed=42")
echo "Response: $RESPONSE"
echo ""
# Extract video ID
VIDEO_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -z "$VIDEO_ID" ]; then
echo "Error: Failed to create video job"
exit 1
fi
echo "Video job created: $VIDEO_ID"
echo ""
# Poll for completion
echo "Polling for completion..."
while true; do
STATUS_RESPONSE=$(curl -s "$API_URL/v1/videos/$VIDEO_ID")
STATUS=$(echo "$STATUS_RESPONSE" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
PROGRESS=$(echo "$STATUS_RESPONSE" | grep -o '"progress":[0-9]*' | cut -d':' -f2)
echo "Status: $STATUS, Progress: $PROGRESS%"
if [ "$STATUS" = "completed" ]; then
echo ""
echo "Video generation completed!"
echo "Full response:"
echo "$STATUS_RESPONSE" | jq '.'
break
elif [ "$STATUS" = "failed" ]; then
echo ""
echo "Video generation failed!"
echo "$STATUS_RESPONSE" | jq '.'
exit 1
fi
sleep 2
done
@@ -0,0 +1,131 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import argparse
import base64
import io
import json
import struct
import wave
import aiohttp
from aiohttp import web
_HEADER = struct.Struct(">iii")
_SAMPLE_RATE = 16000
_DEFAULT_PROMPT = "Transcribe the audio."
_ASR_TEXT_TAG = "<asr_text>"
def _clean_transcript(raw: str) -> str:
"""Qwen3-ASR emits ``language <Lang><asr_text><transcript>``; keep the transcript."""
text = raw or ""
marker = text.rfind(_ASR_TEXT_TAG)
if marker != -1:
text = text[marker + len(_ASR_TEXT_TAG) :]
return text.replace("</asr_text>", "").strip()
def _pcm_to_wav_b64(pcm: bytes, sample_rate: int) -> str:
buf = io.BytesIO()
with wave.open(buf, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm)
return base64.b64encode(buf.getvalue()).decode()
def _result(text: str, *, final: bool) -> dict:
return {
"asr_response": {
"event_type": "IS_FINAL" if final else "IS_PARTIAL",
"recognition_result": {"hypothesis": [{"text": text, "confidence": None}]},
},
"mid": "",
"code": 0,
"msg": "ok",
}
async def _transcribe(cfg: dict, pcm: bytes) -> str:
if not pcm:
return ""
wav_b64 = _pcm_to_wav_b64(pcm, cfg["sample_rate"])
body = {
"model": cfg["model"],
"messages": [
{
"role": "user",
"content": [
{"type": "input_audio", "input_audio": {"data": wav_b64, "format": "wav"}},
{"type": "text", "text": cfg["prompt"]},
],
}
],
"temperature": 0.0,
"max_tokens": cfg["max_tokens"],
}
url = cfg["backend_url"].rstrip("/") + "/v1/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body) as resp:
payload = await resp.json()
choices = payload.get("choices") or []
if not choices:
return ""
return _clean_transcript(choices[0].get("message", {}).get("content") or "")
async def _handle(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(max_msg_size=0)
await ws.prepare(request)
cfg = request.app["cfg"]
pcm = bytearray()
try:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.BINARY and len(msg.data) >= _HEADER.size:
seqid, _, _ = _HEADER.unpack(msg.data[: _HEADER.size])
pcm.extend(msg.data[_HEADER.size :])
if seqid < 0:
text = await _transcribe(cfg, bytes(pcm))
await ws.send_str(json.dumps(_result(text, final=True), ensure_ascii=False))
pcm.clear()
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
finally:
if not ws.closed:
await ws.close()
return ws
def create_app(backend_url: str, model: str, sample_rate: int, prompt: str, max_tokens: int) -> web.Application:
app = web.Application()
app["cfg"] = {
"backend_url": backend_url,
"model": model,
"sample_rate": sample_rate,
"prompt": prompt,
"max_tokens": max_tokens,
}
app.router.add_get("/v1/asr", _handle)
return app
def main() -> None:
parser = argparse.ArgumentParser(description="JoyVL webui <-> Qwen3-ASR (chat input_audio) bridge")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8093)
parser.add_argument("--backend-url", default="http://127.0.0.1:8094")
parser.add_argument("--model", default="Qwen/Qwen3-ASR-1.7B")
parser.add_argument("--sample-rate", type=int, default=_SAMPLE_RATE)
parser.add_argument("--prompt", default=_DEFAULT_PROMPT)
parser.add_argument("--max-tokens", type=int, default=256)
args = parser.parse_args()
app = create_app(args.backend_url, args.model, args.sample_rate, args.prompt, args.max_tokens)
web.run_app(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import aiohttp
from aiohttp import web
try:
import cn2an # optional: Arabic-digit -> Chinese number normalization
except ImportError:
cn2an = None
logger = logging.getLogger("tts_bridge")
def _normalize(text: str) -> str:
"""Spell out Arabic digits in Chinese so short counting outputs synthesize
intelligibly: bare "1次" garbles on Qwen3-TTS (and others), while "一次"
is clean. ``cn2an.transform`` rewrites digits in place ("第3次"->"第三次")
and is pure-Python (~microseconds). No-op if cn2an is unavailable."""
if not cn2an or not text:
return text
try:
return cn2an.transform(text, "an2cn")
except Exception as err: # never let TN break synthesis
logger.debug("cn2an normalize skipped: %s", err)
return text
async def _pump_backend_to_front(back: aiohttp.ClientWebSocketResponse, front: web.WebSocketResponse) -> None:
async for msg in back:
if msg.type == aiohttp.WSMsgType.BINARY:
await front.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.TEXT:
event = json.loads(msg.data).get("type")
if event == "session.done":
await front.send_str(json.dumps({"type": "response.done"}))
return
if event == "error":
await front.send_str(json.dumps({"type": "error", "error": "tts backend error"}))
return
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
return
async def _handle(request: web.Request) -> web.WebSocketResponse:
front = web.WebSocketResponse(heartbeat=20, max_msg_size=0)
await front.prepare(request)
cfg = request.app["cfg"]
session = aiohttp.ClientSession()
back: aiohttp.ClientWebSocketResponse | None = None
pump: asyncio.Task | None = None
parts: list[str] = []
try:
async for msg in front:
if msg.type != web.WSMsgType.TEXT:
continue
data = json.loads(msg.data)
if "config" in data and back is None:
voice = (data["config"] or {}).get("voice") or cfg["voice"]
back = await session.ws_connect(cfg["backend_url"], max_msg_size=0)
await back.send_str(
json.dumps({"type": "session.config", "response_format": cfg["response_format"], "voice": voice})
)
pump = asyncio.create_task(_pump_backend_to_front(back, front))
elif data.get("type") == "input_text.append" and back is not None:
parts.append(data.get("text", ""))
elif data.get("type") == "input_text.commit" and back is not None:
# Normalize the full utterance once (digits may span chunks), then send.
await back.send_str(json.dumps({"type": "input.text", "text": _normalize("".join(parts))}))
parts = []
await back.send_str(json.dumps({"type": "input.done"}))
if pump is not None:
await pump
break
except Exception as err:
logger.warning("tts bridge error: %s", err)
finally:
if pump is not None and not pump.done():
pump.cancel()
if back is not None and not back.closed:
await back.close()
await session.close()
if not front.closed:
await front.close()
return front
def create_app(backend_url: str, voice: str, response_format: str) -> web.Application:
app = web.Application()
app["cfg"] = {"backend_url": backend_url, "voice": voice, "response_format": response_format}
app.router.add_get("/v1/tts", _handle)
return app
def main() -> None:
parser = argparse.ArgumentParser(description="JoyVL webui <-> Qwen3-TTS websocket bridge")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8092)
parser.add_argument("--backend-url", default="ws://127.0.0.1:8091/v1/audio/speech/stream")
parser.add_argument("--voice", default="vivian")
parser.add_argument("--response-format", default="pcm")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
web.run_app(create_app(args.backend_url, args.voice, args.response_format), host=args.host, port=args.port)
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import argparse
from stream_client import stream
ICON = {"response": "SPEAK ", "silence": "silence ", "delegate": "DELEGATE"}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("video")
parser.add_argument("--server", default="http://127.0.0.1:8070")
parser.add_argument("--query", default=None)
parser.add_argument("--fps", type=float, default=1.0)
parser.add_argument("--session-id", default="cli")
args = parser.parse_args()
for tick in stream(args.video, args.server, args.session_id, args.query, args.fps):
if tick.action == "silence":
continue
line = f"[{tick.t:6.1f}s] {ICON.get(tick.action)} {tick.text}"
if tick.delegation:
line += f" (-> {tick.delegation.get('question', '')})"
print(line, flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import base64
import time
from collections.abc import Iterator
from dataclasses import dataclass
import cv2
import requests
@dataclass
class Tick:
index: int
t: float
action: str
text: str
skipped: bool
latency_ms: float
delegation: dict | None
def iter_frames(video_path: str, fps: float) -> Iterator[tuple[float, bytes]]:
cap = cv2.VideoCapture(video_path)
src_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
step = max(1, round(src_fps / fps))
idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
if idx % step == 0:
ok, buf = cv2.imencode(".jpg", frame)
if ok:
yield idx / src_fps, buf.tobytes()
idx += 1
cap.release()
def stream(
video_path: str,
server: str,
session_id: str,
query: str | None = None,
fps: float = 1.0,
realtime: bool = False,
) -> Iterator[Tick]:
"""Send each sampled frame as one chat completion; yield the parsed action.
``query`` is attached to the first tick to arm the session; inject later
queries by posting another frame turn with a new text part.
"""
url = server.rstrip("/") + "/v1/chat/completions"
requests.post(server.rstrip("/") + "/reset", json={"session_id": session_id}, timeout=10)
for i, (t, jpeg) in enumerate(iter_frames(video_path, fps)):
data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg).decode()
content = [{"type": "image_url", "image_url": {"url": data_url}}]
if i == 0 and query:
content.insert(0, {"type": "text", "text": query})
resp = requests.post(
url,
json={"session_id": session_id, "messages": [{"role": "user", "content": content}]},
timeout=120,
).json()
info = resp.get("interaction", {})
yield Tick(
index=info.get("frame_index", i + 1),
t=t,
action=info.get("action", "?"),
text=resp["choices"][0]["message"]["content"],
skipped=info.get("inference_skipped", False),
latency_ms=info.get("latency_ms", 0.0),
delegation=info.get("delegation"),
)
if realtime:
time.sleep(1.0 / fps)
@@ -0,0 +1,46 @@
# RTSP local streaming
Simulate an RTSP camera from a local video file so the WebUI (or any RTSP client) can
test the RTSP input path without a physical IP camera. RTSP is a **webui-side input** —
the browser pulls the stream and feeds frames to the orchestrator over the normal API;
no serving-layer code is involved.
Needs [`ffmpeg`](https://ffmpeg.org/) and [MediaMTX](https://github.com/bluenviron/mediamtx/releases)
(download the binary + `mediamtx.yml` and keep them next to these scripts, or point
`MEDIAMTX_BIN` / `MEDIAMTX_CONFIG` at them).
## 1. Start the RTSP server
```bash
bash ./mediamtx.sh # MediaMTX, listens on :8554
```
## 2. Push a local video as an RTSP stream
```bash
bash ./rtsp.sh ./videos/example.mp4 rtsp://127.0.0.1:8554/fire1
```
`rtsp.sh` takes `[video-path] [rtsp-url]` (or set `VIDEO_PATH` / `RTSP_URL`). To stream a
whole directory of clips, one stream per file:
```bash
bash ./rtsp_all.sh ./videos rtsp://127.0.0.1:8554
```
## 3. Use it in the WebUI
Enter the URL in the WebUI RTSP box:
```
rtsp://127.0.0.1:8554/fire1
```
If the WebUI runs on another machine, replace `127.0.0.1` with the MediaMTX host's IP.
## Notes
- `rtsp.sh` transcodes audio to AAC; replace `-c:a aac -b:a 128k -ar 44100` with `-an`
if the source clip has no audio track and your `ffmpeg` errors on it.
- If a client cannot connect, check that port `8554` is reachable and not blocked by a
firewall, container network, or security group.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Start a local MediaMTX RTSP server (listens on :8554 by default).
# Download the binary + mediamtx.yml from https://github.com/bluenviron/mediamtx/releases
# and either keep them next to this script or point the vars below at them.
MEDIAMTX_BIN="${MEDIAMTX_BIN:-./mediamtx}"
MEDIAMTX_CONFIG="${MEDIAMTX_CONFIG:-./mediamtx.yml}"
exec "$MEDIAMTX_BIN" "$MEDIAMTX_CONFIG"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# Loop a local video file and push it to an RTSP URL, simulating an RTSP camera.
# bash ./rtsp.sh [video-path] [rtsp-output-url]
# Defaults stream ./videos/example.mp4 to rtsp://127.0.0.1:8554/fire1
VIDEO_PATH="${1:-${VIDEO_PATH:-./videos/example.mp4}}"
RTSP_URL="${2:-${RTSP_URL:-rtsp://127.0.0.1:8554/fire1}}"
if [[ ! -f "$VIDEO_PATH" ]]; then
echo "Video file not found: $VIDEO_PATH" >&2
exit 1
fi
echo "Input video: $VIDEO_PATH"
echo "RTSP output: $RTSP_URL"
# Drop "-c:a aac -b:a 128k -ar 44100" for "-an" if the source has no audio track.
exec ffmpeg \
-hide_banner \
-loglevel info \
-re \
-stream_loop -1 \
-i "$VIDEO_PATH" \
-vf "scale='min(1280,iw)':-2" \
-c:v libx264 \
-preset veryfast \
-tune zerolatency \
-b:v 2500k \
-maxrate 2500k \
-bufsize 5000k \
-c:a aac \
-b:a 128k \
-ar 44100 \
-f rtsp \
-rtsp_transport udp \
"$RTSP_URL"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
# Push every .mp4 in a directory as its own RTSP stream (one ffmpeg per file),
# named rtsp://<base>/<filename-without-extension>.
# bash ./rtsp_all.sh [video-dir] [rtsp-base-url]
VIDEO_DIR="${1:-./videos}"
RTSP_BASE="${2:-rtsp://127.0.0.1:8554}"
if [[ ! -d "$VIDEO_DIR" ]]; then
echo "Video directory not found: $VIDEO_DIR" >&2
exit 1
fi
shopt -s nullglob
VIDEO_FILES=("$VIDEO_DIR"/*.mp4)
shopt -u nullglob
if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
echo "No video files found in: $VIDEO_DIR" >&2
exit 1
fi
echo "Found ${#VIDEO_FILES[@]} video(s) in $VIDEO_DIR"
PIDS=()
cleanup() {
echo "Stopping all streams..."
for pid in "${PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null
}
trap cleanup EXIT INT TERM
for video in "${VIDEO_FILES[@]}"; do
stream_name="$(basename "${video%.*}")"
rtsp_url="${RTSP_BASE}/${stream_name}"
echo "Starting stream: $rtsp_url <- $(basename "$video")"
ffmpeg \
-hide_banner \
-loglevel error \
-re \
-stream_loop -1 \
-i "$video" \
-vf "format=yuv420p,scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2" \
-c:v libx264 \
-preset veryfast \
-tune zerolatency \
-b:v 4000k \
-maxrate 4500k \
-bufsize 9000k \
-c:a aac \
-b:a 128k \
-ar 44100 \
-f rtsp \
-rtsp_transport tcp \
"$rtsp_url" &
PIDS+=($!)
done
echo "All ${#VIDEO_FILES[@]} streams started. Press Ctrl+C to stop."
wait
@@ -0,0 +1,108 @@
#!/bin/bash
# One-shot JoyVL demo: model -> interaction orchestrator -> JD webui.
#
# The model is the only fixed component; every external module is pluggable and
# configured here via env (unset = disabled), matching JoyVL's design:
#
# TTS_URL voice out (point at our tts_bridge, or any OpenAI-Realtime-style TTS WS)
# ASR_URL voice in (point at our asr_bridge, or any compatible ASR WS)
# DELEGATION_BACKEND_URL orchestrator background brain for </delegation> (OpenAI-compatible; DELEGATION_MODEL/DELEGATION_API_KEY pick model+key)
# DELEGATION_KIND=chat (default) text/VL brain answers; =image a text-to-image model generates a picture
# chat+Claude: DELEGATION_BACKEND_URL=https://api.anthropic.com/v1/ DELEGATION_MODEL=claude-... DELEGATION_API_KEY=sk-ant-...
# image+Qwen-Image: DELEGATION_KIND=image DELEGATION_BACKEND_URL=http://127.0.0.1:8091/v1
# BACKGROUND_CODEX_API_URL webui-side background agent
# ENABLE_MEMORY (default 1) 3-tier memory; SUMMARIZER_BACKEND_URL/MODEL pick the summarizer (default: reuse main)
#
# Usage: bash start_all.sh
# TTS_URL=ws://host/v1/tts ASR_URL=ws://host/v1/asr GPU=0 bash start_all.sh
set -o pipefail
CODE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
VENV="${VENV:-$CODE/.venv}"
WEBUI_DIR="${WEBUI_DIR:-$CODE/joyvl-interaction/joyvl-interaction-webui}"
MODEL="${MODEL:-jdopensource/JoyAI-VL-Interaction-Preview}"
SERVED_NAME="${SERVED_NAME:-JoyAI-VL-Interaction-Preview}"
GPU="${GPU:-0}"
MODEL_PORT="${MODEL_PORT:-8061}"
ORCH_PORT="${ORCH_PORT:-8070}"
WEBUI_PORT="${WEBUI_PORT:-8999}"
WEBUI_USERNAME="${WEBUI_USERNAME:-admin}"
WEBUI_PASSWORD="${WEBUI_PASSWORD:-changeme}" # override via env for any exposed deployment
ENABLE_MEMORY="${ENABLE_MEMORY:-1}"
RESPONSE_DEDUP="${RESPONSE_DEDUP:-0.85}" # demo drops near-duplicate narration; set 1.0 for reference (exact-only)
TTS_URL="${TTS_URL:-}"
ASR_URL="${ASR_URL:-}"
BACKGROUND_CODEX_API_URL="${BACKGROUND_CODEX_API_URL:-}"
LOG=/tmp/joyvl_demo
mkdir -p "$LOG"
[ -d "$WEBUI_DIR" ] || { echo "! WEBUI_DIR not found: $WEBUI_DIR (set WEBUI_DIR=...)"; exit 1; }
kill_port() {
local p
p=$(ss -tlnp 2>/dev/null | grep ":$1 " | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
[ -n "${p:-}" ] && kill "$p" 2>/dev/null
return 0
}
wait_http() {
for _ in $(seq 1 150); do
curl -sk -m2 "$1" -o /dev/null 2>/dev/null && return 0
sleep 2
done
echo " ! timeout waiting for $1"
return 1
}
echo "Resetting ports ${MODEL_PORT} ${ORCH_PORT} ${WEBUI_PORT}…"
for p in "$MODEL_PORT" "$ORCH_PORT" "$WEBUI_PORT"; do kill_port "$p"; done
sleep 3
[ -f "$VENV/bin/activate" ] && source "$VENV/bin/activate"
# detach into a new session so servers outlive this launcher
spawn() { local log="$1"; shift; setsid "$@" > "$log" 2>&1 < /dev/null & }
echo "[1/3] model on GPU ${GPU}…"
spawn "$LOG/model.log" env HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 CUDA_VISIBLE_DEVICES="$GPU" \
vllm serve "$MODEL" --served-model-name "$SERVED_NAME" --port "$MODEL_PORT" \
--gpu-memory-utilization 0.85 --max-model-len 131072 --enable-prefix-caching \
--limit-mm-per-prompt '{"image":256,"video":1}'
wait_http "http://127.0.0.1:${MODEL_PORT}/health" && echo " up :${MODEL_PORT}"
echo "[2/3] orchestrator (memory=${ENABLE_MEMORY})…"
ORCH_ARGS=(--port "$ORCH_PORT" --main-backend-url "http://127.0.0.1:${MODEL_PORT}/v1"
--main-model "$SERVED_NAME"
--response-dedup-threshold "$RESPONSE_DEDUP")
# delegation: point at a stronger background brain to enable it; otherwise keep it off
if [ -n "${DELEGATION_BACKEND_URL:-}" ]; then
ORCH_ARGS+=(--delegation-backend-url "$DELEGATION_BACKEND_URL")
ORCH_ARGS+=(--delegation-kind "${DELEGATION_KIND:-chat}")
[ -n "${DELEGATION_MODEL:-}" ] && ORCH_ARGS+=(--delegation-model "$DELEGATION_MODEL")
[ -n "${DELEGATION_API_KEY:-}" ] && ORCH_ARGS+=(--delegation-api-key "$DELEGATION_API_KEY")
else
ORCH_ARGS+=(--no-delegation)
fi
[ "$ENABLE_MEMORY" = "0" ] && ORCH_ARGS+=(--no-memory)
[ -n "${SUMMARIZER_BACKEND_URL:-}" ] && ORCH_ARGS+=(--summarizer-backend-url "$SUMMARIZER_BACKEND_URL")
[ -n "${SUMMARIZER_MODEL:-}" ] && ORCH_ARGS+=(--summarizer-model "$SUMMARIZER_MODEL")
spawn "$LOG/orch.log" env PYTHONPATH="$CODE" python -m vllm_omni.experimental.fullduplex.joyvl.serving.server "${ORCH_ARGS[@]}"
wait_http "http://127.0.0.1:${ORCH_PORT}/health" && echo " up :${ORCH_PORT}"
echo "[3/3] JD webui…"
spawn "$LOG/webui.log" env WEBUI_USERNAME="$WEBUI_USERNAME" WEBUI_PASSWORD="$WEBUI_PASSWORD" \
TTS_URL="$TTS_URL" ASR_URL="$ASR_URL" BACKGROUND_CODEX_API_URL="$BACKGROUND_CODEX_API_URL" \
bash -c "cd '$WEBUI_DIR' && exec bash scripts/start_server.sh --api-base 'http://127.0.0.1:${ORCH_PORT}/v1'"
webui_up=0
for _ in $(seq 1 60); do grep -q "Server startup complete" "$LOG/webui.log" 2>/dev/null && { webui_up=1; break; }; sleep 1; done
[ "$webui_up" = 1 ] || { echo "! webui did not start — see $LOG/webui.log"; exit 1; }
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo
echo "=== JoyVL demo ready ==="
echo " webui : https://${IP}:${WEBUI_PORT} (user ${WEBUI_USERNAME} / pass ${WEBUI_PASSWORD})"
echo " memory : $([ "$ENABLE_MEMORY" = 0 ] && echo off || echo on)"
echo " TTS : ${TTS_URL:-<unset>}"
echo " ASR : ${ASR_URL:-<unset>}"
echo " agent : ${BACKGROUND_CODEX_API_URL:-<unset>}"
echo " logs : ${LOG}/{model,orch,webui}.log"
@@ -0,0 +1,24 @@
#!/bin/bash
# Serve Qwen3-ASR (the same audio-LLM AURA uses as its ASR stage) and run the
# webui<->ASR bridge. Then start the webui with:
# ASR_URL=ws://127.0.0.1:8093/v1/asr bash scripts/start_server.sh
set -euo pipefail
ASR_MODEL="${ASR_MODEL:-Qwen/Qwen3-ASR-1.7B}"
ASR_PORT="${ASR_PORT:-8094}"
BRIDGE_PORT="${BRIDGE_PORT:-8093}"
GPU="${GPU:-2}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Qwen3-ASR is audio-in / text-out; served as a normal multimodal chat model.
CUDA_VISIBLE_DEVICES="${GPU}" vllm serve "${ASR_MODEL}" \
--host 0.0.0.0 --port "${ASR_PORT}" \
--gpu-memory-utilization 0.3 --trust-remote-code &
ASR_PID=$!
trap 'kill ${ASR_PID} 2>/dev/null || true' EXIT
until curl -s "http://127.0.0.1:${ASR_PORT}/health" >/dev/null 2>&1; do sleep 2; done
python "${SCRIPT_DIR}/../bridges/asr_bridge.py" \
--port "${BRIDGE_PORT}" \
--backend-url "http://127.0.0.1:${ASR_PORT}" \
--model "${ASR_MODEL}"
@@ -0,0 +1,23 @@
#!/bin/bash
# Launch the interaction orchestrator in front of a running model server.
set -euo pipefail
PORT="${PORT:-8070}"
MAIN_BACKEND_URL="${MAIN_BACKEND_URL:-http://127.0.0.1:8061/v1}"
MAIN_MODEL="${MAIN_MODEL:-JoyAI-VL-Interaction-Preview}"
PERSONA="${PERSONA:-default}"
# By default memory reuses the main model as its own summarizer. Set
# SUMMARIZER_BACKEND_URL/SUMMARIZER_MODEL to a dedicated Qwen3-VL-4B server,
# or pass --no-memory for the lightest setup.
EXTRA=()
[[ -n "${SUMMARIZER_BACKEND_URL:-}" ]] && EXTRA+=(--summarizer-backend-url "${SUMMARIZER_BACKEND_URL}")
[[ -n "${SUMMARIZER_MODEL:-}" ]] && EXTRA+=(--summarizer-model "${SUMMARIZER_MODEL}")
[[ "${NO_MEMORY:-0}" == "1" ]] && EXTRA+=(--no-memory)
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server \
--port "${PORT}" \
--main-backend-url "${MAIN_BACKEND_URL}" \
--main-model "${MAIN_MODEL}" \
--persona "${PERSONA}" \
"${EXTRA[@]}"
@@ -0,0 +1,22 @@
#!/bin/bash
# Serve the JoyVL interaction model as a plain VLM (NOT --omni: it is a
# standard Qwen3-VL autoregressive model, not an omni/diffusion model).
set -euo pipefail
MODEL="${MODEL:-jdopensource/JoyAI-VL-Interaction-Preview}"
SERVED_NAME="${SERVED_NAME:-JoyAI-VL-Interaction-Preview}"
PORT="${PORT:-8061}"
GPU="${GPU:-0}"
# Covers the default short-term window (chunk_frames=100) + key_frames=0 (all frames
# summarized). Lower MAX_MODEL_LEN / IMAGE_LIMIT (and --chunk-frames) together for ~24GB.
MAX_MODEL_LEN="${MAX_MODEL_LEN:-131072}"
IMAGE_LIMIT="${IMAGE_LIMIT:-256}"
CUDA_VISIBLE_DEVICES="${GPU}" vllm serve "${MODEL}" \
--served-model-name "${SERVED_NAME}" \
--port "${PORT}" \
--max-model-len "${MAX_MODEL_LEN}" \
--gpu-memory-utilization 0.85 \
--enable-prefix-caching \
--enable-chunked-prefill \
--limit-mm-per-prompt "{\"image\":${IMAGE_LIMIT},\"video\":1}"
@@ -0,0 +1,25 @@
#!/bin/bash
# Serve Qwen3-TTS (vLLM-Omni) and run the webui<->TTS bridge.
# Then start the webui with: TTS_URL=ws://127.0.0.1:8092/v1/tts bash scripts/start_server.sh
set -euo pipefail
TTS_MODEL="${TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}"
TTS_PORT="${TTS_PORT:-8091}"
BRIDGE_PORT="${BRIDGE_PORT:-8092}"
GPU="${GPU:-1}"
VOICE="${VOICE:-vivian}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CUDA_VISIBLE_DEVICES="${GPU}" vllm-omni serve "${TTS_MODEL}" \
--deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--host 0.0.0.0 --port "${TTS_PORT}" \
--gpu-memory-utilization 0.9 --trust-remote-code --omni &
TTS_PID=$!
trap 'kill ${TTS_PID} 2>/dev/null || true' EXIT
# wait for the TTS server, then run the bridge in the foreground
until curl -s "http://127.0.0.1:${TTS_PORT}/health" >/dev/null 2>&1; do sleep 2; done
python "${SCRIPT_DIR}/../bridges/tts_bridge.py" \
--port "${BRIDGE_PORT}" \
--backend-url "ws://127.0.0.1:${TTS_PORT}/v1/audio/speech/stream" \
--voice "${VOICE}"
@@ -504,6 +504,35 @@ python qwen3_tts/streaming_speech_client.py --text "Hello world. How are you? I
python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-delay 0.1
```
To receive word-level timestamps, launch the server with a forced aligner:
```bash
vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--omni \
--deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--trust-remote-code \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
```
Then request PCM JSON sidecar chunks:
```bash
python qwen3_tts/streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps
```
The client writes one PCM file per sentence and a matching
`sentence_XXX_timestamps.json` sidecar.
To *see* the alignment instead of reading a JSON sidecar, run the
word-timestamp Gradio demo (server must be launched with `--forced-aligner`):
```bash
python qwen3_tts/word_timestamps_demo.py --api-base http://localhost:8091
```
Each sentence's audio plays in an `<audio>` element while its text is rendered
as inline word spans; the current word highlights as `audio.currentTime`
crosses each `start_ms`. The **Stop (barge-in)** button cuts playback and
reports the last-spoken word, useful for the voice-agent barge-in case.
### Gradio demos
```bash
./qwen3_tts/run_gradio_demo.sh # CustomVoice (default)
@@ -18,6 +18,21 @@ Usage:
--text "Hello world. How are you? I am fine." \
--simulate-stt --stt-delay 0.1
# Receive JSON sidecar chunks with word-level timestamps
python streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps
# Also save every received audio.chunk frame as individual PCM/WAV files
python streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps \
--save-chunks
# VoiceDesign task
python streaming_speech_client.py \
--text "Today is a great day. The weather is nice." \
@@ -37,8 +52,10 @@ Requirements:
import argparse
import asyncio
import base64
import json
import os
import wave
try:
import websockets
@@ -47,6 +64,37 @@ except ImportError:
raise SystemExit(1)
def save_audio_file(output_dir: str, sentence_index: int, response_format: str, chunks: list[bytes]) -> str:
audio_bytes = b"".join(chunks)
if response_format == "pcm":
raw_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.pcm")
with open(raw_path, "wb") as f:
f.write(audio_bytes)
wav_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.wav")
save_pcm_wav(wav_path, audio_bytes)
return wav_path
filename = os.path.join(output_dir, f"sentence_{sentence_index:03d}.{response_format}")
with open(filename, "wb") as f:
f.write(audio_bytes)
return filename
def save_pcm_wav(path: str, audio_bytes: bytes, sample_rate: int = 24000) -> None:
with wave.open(path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(audio_bytes)
def timestamp_words_text(timestamps: list[dict] | None) -> str:
if not timestamps:
return ""
return " ".join(str(item.get("word", "")) for item in timestamps if item.get("word"))
async def stream_tts(
url: str,
text: str,
@@ -54,6 +102,7 @@ async def stream_tts(
output_dir: str,
simulate_stt: bool = False,
stt_delay: float = 0.1,
save_chunks: bool = False,
) -> None:
"""Connect to the streaming TTS endpoint and process audio responses."""
os.makedirs(output_dir, exist_ok=True)
@@ -99,8 +148,14 @@ async def stream_tts(
sender_task = asyncio.create_task(send_text())
response_format = config.get("response_format", "wav")
word_timestamps = bool(config.get("word_timestamps", False))
current_sentence_index = 0
current_sentence_text = ""
current_chunks: list[bytes] = []
current_timestamps: list[dict] = []
# Distinguish `null` (aligner failed) from `[]` (silence / no tokens):
# the sentence is a failure only if no frame ever carried timestamps.
alignment_frame_seen = False
try:
while True:
@@ -116,22 +171,94 @@ async def stream_tts(
if msg_type == "audio.start":
current_sentence_index = msg["sentence_index"]
current_sentence_text = msg.get("sentence_text", "")
current_chunks = []
print(f" [sentence {msg['sentence_index']}] Generating: {msg['sentence_text']!r}")
current_timestamps = []
alignment_frame_seen = False
print(f" [sentence {msg['sentence_index']}] Generating: {current_sentence_text!r}")
elif msg_type == "audio.chunk":
audio = base64.b64decode(msg["audio_b64"])
current_chunks.append(audio)
chunk_timestamps = msg.get("timestamps")
# Sentence-level alignment sends a trailing timestamp-only
# frame with empty audio; don't write an empty chunk file.
if save_chunks and audio:
chunk_base = os.path.join(
output_dir,
f"sentence_{msg['sentence_index']:03d}_chunk_{msg['chunk_id']:03d}",
)
chunk_pcm = f"{chunk_base}.pcm"
chunk_wav = f"{chunk_base}.wav"
chunk_json = f"{chunk_base}_timestamps.json"
with open(chunk_pcm, "wb") as f:
f.write(audio)
save_pcm_wav(chunk_wav, audio, int(msg.get("sample_rate", 24000)))
with open(chunk_json, "w", encoding="utf-8") as f:
json.dump(
{
"sentence_index": msg["sentence_index"],
"chunk_id": msg["chunk_id"],
"chunk_start_ms": msg.get("chunk_start_ms"),
"chunk_end_ms": msg.get("chunk_end_ms"),
"sentence_text": current_sentence_text,
"covered_text": timestamp_words_text(chunk_timestamps),
"timestamps": chunk_timestamps,
},
f,
ensure_ascii=False,
indent=2,
)
if chunk_timestamps is None:
print(
f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: "
f"{len(audio)} bytes, timestamps unavailable"
)
else:
alignment_frame_seen = True
current_timestamps.extend(chunk_timestamps)
print(
f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: "
f"{len(audio)} bytes, {len(chunk_timestamps)} timestamp(s)"
)
if save_chunks and audio:
print(f" saved chunk -> {chunk_wav}")
elif msg_type == "audio.done":
filename = os.path.join(
filename = save_audio_file(
output_dir,
f"sentence_{msg['sentence_index']:03d}.{response_format}",
msg["sentence_index"],
response_format,
current_chunks,
)
with open(filename, "wb") as f:
f.write(b"".join(current_chunks))
if word_timestamps:
# `null` on failure, else the accumulated list (`[]` for silence).
sentence_timestamps = current_timestamps if alignment_frame_seen else None
ts_filename = os.path.join(
output_dir,
f"sentence_{msg['sentence_index']:03d}_timestamps.json",
)
with open(ts_filename, "w", encoding="utf-8") as f:
json.dump(
{
"sentence_index": msg["sentence_index"],
"sentence_text": current_sentence_text,
"covered_text": timestamp_words_text(sentence_timestamps),
"timestamps": sentence_timestamps,
},
f,
ensure_ascii=False,
indent=2,
)
print(
f" [sentence {msg['sentence_index']}] Done"
f" bytes={msg.get('total_bytes', len(b''.join(current_chunks)))}"
f" error={msg.get('error', False)}"
f" -> {filename}"
)
if word_timestamps:
print(f" [sentence {msg['sentence_index']}] Timestamps -> {ts_filename}")
current_chunks = []
current_timestamps = []
alignment_frame_seen = False
elif msg_type == "session.done":
print(f"\nSession complete: {msg['total_sentences']} sentence(s) generated")
break
@@ -189,6 +316,16 @@ def main():
action="store_true",
help="Receive one or more PCM chunks per sentence (requires --response-format pcm)",
)
parser.add_argument(
"--word-timestamps",
action="store_true",
help="Receive JSON sidecar chunks with word-level timestamps (requires --stream-audio --response-format pcm)",
)
parser.add_argument(
"--save-chunks",
action="store_true",
help="Save each audio.chunk frame as sentence_XXX_chunk_YYY.{pcm,wav,json}",
)
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed (0.25-4.0)")
parser.add_argument("--max-new-tokens", type=int, default=None, help="Max tokens")
@@ -238,6 +375,10 @@ def main():
config[key] = val
if args.stream_audio:
config["stream_audio"] = True
if args.word_timestamps:
config["word_timestamps"] = True
config["stream_audio"] = True
config["response_format"] = "pcm"
if args.x_vector_only_mode:
config["x_vector_only_mode"] = True
@@ -249,6 +390,7 @@ def main():
output_dir=args.output_dir,
simulate_stt=args.simulate_stt,
stt_delay=args.stt_delay,
save_chunks=args.save_chunks,
)
)
@@ -0,0 +1,457 @@
"""Gradio demo for streaming TTS word-level timestamps.
Connects to the WebSocket endpoint ``/v1/audio/speech/stream`` with
``word_timestamps: true`` and visualizes the alignment: each sentence's
audio plays in a native ``<audio>`` element while its text is rendered as
inline word spans, the current word highlighting as ``audio.currentTime``
crosses each ``start_ms``.
A "Stop (barge-in)" button cuts playback and reports the last-spoken word,
demonstrating the voice-agent barge-in use case.
Timestamps are sentence-level: audio streams in real time, then the forced
aligner runs once per sentence and the complete word timestamps arrive in a
trailing frame. The demo therefore plays one sentence block at a time.
Usage:
# Launch the server with a forced aligner first, e.g.:
# vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni \
# --deploy-config vllm_omni/deploy/qwen3_tts.yaml --trust-remote-code \
# --forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
# Then:
python word_timestamps_demo.py --api-base http://localhost:8000
"""
import argparse
import asyncio
import json
import logging
try:
import gradio as gr
except ImportError:
raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None
try:
import websockets
except ImportError:
raise ImportError("websockets is required to run this demo. Install it with: pip install websockets") from None
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from tts_common import (
PCM_SAMPLE_RATE,
SUPPORTED_LANGUAGES,
TASK_TYPES,
add_common_args,
fetch_voices,
)
logger = logging.getLogger(__name__)
def _upstream_ws_url(api_base: str) -> str:
"""Map an http(s) API base to its WebSocket streaming endpoint."""
base = api_base.rstrip("/")
if base.startswith("https://"):
base = "wss://" + base[len("https://") :]
elif base.startswith("http://"):
base = "ws://" + base[len("http://") :]
else:
base = "ws://" + base
return base + "/v1/audio/speech/stream"
# ── Front-end ────────────────────────────────────────────────────────
PLAYER_HTML = """
<div id="wt-root">
<div style="display:flex; align-items:center; gap:10px;">
<div id="wt-dot" style="width:10px;height:10px;border-radius:50%;background:#ccc;flex-shrink:0;"></div>
<span id="wt-status" style="font-weight:600;font-size:1.05em;">Ready</span>
<button id="wt-stop" onclick="window.wtStop()"
style="display:none; margin-left:auto; padding:5px 16px; border-radius:6px; border:1px solid #EF5552;
background:#fff; color:#EF5552; cursor:pointer; font-size:0.85em;">Stop (barge-in)</button>
</div>
<div id="wt-barge" style="display:none; margin-top:10px; padding:8px 12px; border-radius:6px;
background:#fff5f5; border:1px solid #f3c2c2; color:#b02a2a; font-size:0.9em;"></div>
<div id="wt-sentences" style="margin-top:12px; display:flex; flex-direction:column; gap:14px;"></div>
</div>
"""
def _build_head_js(sample_rate: int) -> str:
"""Browser-side player: WebSocket -> per-sentence <audio> + word highlight."""
return f"""
<script>
const WT_SR = {sample_rate};
let wtWS = null, wtQueue = [], wtPlaying = false, wtCur = null, wtLastWord = "";
function wtSet(text, color) {{
const s = document.getElementById('wt-status');
const d = document.getElementById('wt-dot');
if (s) s.textContent = text;
if (d) d.style.background = color || '#ccc';
}}
function wtStopBtn(show) {{
const b = document.getElementById('wt-stop');
if (b) b.style.display = show ? 'inline-block' : 'none';
}}
// Build a WAV Blob (16-bit mono PCM) from concatenated Int16 samples.
function wtPcmToWav(int16, sr) {{
const n = int16.length;
const buf = new ArrayBuffer(44 + n * 2);
const v = new DataView(buf);
const w = (off, s) => {{ for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); }};
w(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); w(8, 'WAVE');
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
w(36, 'data'); v.setUint32(40, n * 2, true);
let off = 44;
for (let i = 0; i < n; i++) {{ v.setInt16(off, int16[i], true); off += 2; }}
return new Blob([buf], {{ type: 'audio/wav' }});
}}
function wtConcat(chunks) {{
let total = 0;
for (const c of chunks) total += c.length;
const out = new Int16Array(total);
let off = 0;
for (const c of chunks) {{ out.set(c, off); off += c.length; }}
return out;
}}
// Render one finished sentence: <audio> + inline word spans.
function wtRenderSentence(sentence) {{
const wrap = document.getElementById('wt-sentences');
if (!wrap) return;
const block = document.createElement('div');
block.style.cssText = 'border:1px solid #e3e8ef;border-radius:8px;padding:10px 12px;';
const head = document.createElement('div');
head.style.cssText = 'font-size:0.75em;color:#888;margin-bottom:6px;';
head.textContent = 'Sentence ' + sentence.index +
(sentence.timestamps ? ' (' + sentence.timestamps.length + ' words)' : ' (no timestamps)');
block.appendChild(head);
const textEl = document.createElement('div');
textEl.style.cssText = 'font-size:1.15em;line-height:1.8;margin-bottom:8px;';
if (sentence.timestamps && sentence.timestamps.length) {{
sentence.spans = [];
for (const ts of sentence.timestamps) {{
const span = document.createElement('span');
span.textContent = ts.word;
span.dataset.start = ts.start_ms;
span.dataset.end = ts.end_ms;
span.style.cssText = 'padding:1px 2px;border-radius:3px;transition:background 0.1s;';
textEl.appendChild(span);
// Keep spacing for spaced languages; CJK chars sit flush.
textEl.appendChild(document.createTextNode(' '));
sentence.spans.push(span);
}}
}} else {{
textEl.textContent = sentence.text || '(timestamps unavailable)';
}}
block.appendChild(textEl);
const audio = document.createElement('audio');
audio.controls = true;
audio.style.cssText = 'width:100%;';
audio.src = URL.createObjectURL(wtPcmToWav(wtConcat(sentence.chunks), WT_SR));
block.appendChild(audio);
sentence.audio = audio;
// Highlight the current word. Driven by requestAnimationFrame (~60fps)
// rather than the <audio> 'timeupdate' event, which only fires ~4x/sec
// and is too coarse for short words. We light the latest word whose
// start_ms has passed, so there is always exactly one active word (no
// inter-word flicker) and wtLastWord stays well-defined for barge-in.
let raf = null;
function highlight() {{
if (!sentence.spans) return;
const t = audio.currentTime * 1000;
let idx = -1;
for (let i = 0; i < sentence.spans.length; i++) {{
if (t >= +sentence.spans[i].dataset.start) idx = i;
else break;
}}
sentence.spans.forEach((sp, i) => {{
const on = i === idx;
sp.style.background = on ? '#ffe680' : 'transparent';
sp.style.fontWeight = on ? '700' : '400';
}});
if (idx >= 0) wtLastWord = sentence.spans[idx].textContent;
}}
function loop() {{
highlight();
if (!audio.paused && !audio.ended) raf = requestAnimationFrame(loop);
}}
audio.addEventListener('play', () => {{
wtCur = audio;
wtSet('Playing sentence ' + sentence.index + '...', '#64dd17');
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
}});
audio.addEventListener('pause', () => cancelAnimationFrame(raf));
audio.addEventListener('seeked', highlight);
audio.addEventListener('ended', () => {{
cancelAnimationFrame(raf);
highlight();
wtPlaying = false;
wtPlayNext();
}});
wrap.appendChild(block);
wtQueue.push(audio);
wtPlayNext();
}}
function wtPlayNext() {{
if (wtPlaying) return;
const next = wtQueue.shift();
if (!next) {{
if (wtWS === null) {{ wtSet('Done', '#64dd17'); wtStopBtn(false); }}
return;
}}
wtPlaying = true;
next.play().catch(() => {{ wtPlaying = false; }});
}}
window.wtStop = function() {{
// Barge-in: cut playback, stop generation, report the last-spoken word.
if (wtCur) {{ try {{ wtCur.pause(); }} catch (e) {{}} }}
wtQueue = [];
wtPlaying = false;
if (wtWS) {{ try {{ wtWS.close(); }} catch (e) {{}} wtWS = null; }}
wtSet('Stopped (barge-in)', '#999');
wtStopBtn(false);
const b = document.getElementById('wt-barge');
if (b) {{
b.style.display = 'block';
b.textContent = wtLastWord
? 'Barge-in at word: "' + wtLastWord + '" (a voice agent would resume the conversation from here)'
: 'Barge-in before any word was spoken.';
}}
}};
window.wtGenerate = function(payload) {{
if (wtWS) {{ try {{ wtWS.close(); }} catch (e) {{}} }}
wtQueue = []; wtPlaying = false; wtCur = null; wtLastWord = "";
document.getElementById('wt-sentences').innerHTML = '';
const barge = document.getElementById('wt-barge');
if (barge) {{ barge.style.display = 'none'; barge.textContent = ''; }}
wtStopBtn(true);
wtSet('Connecting...', '#4A90D9');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host + '/proxy/stream');
wtWS = ws;
const sentences = {{}};
ws.onopen = () => {{
wtSet('Streaming...', '#4A90D9');
ws.send(JSON.stringify(Object.assign({{ type: 'session.config' }}, payload.config)));
ws.send(JSON.stringify({{ type: 'input.text', text: payload.text }}));
ws.send(JSON.stringify({{ type: 'input.done' }}));
}};
ws.onmessage = (ev) => {{
let msg;
try {{ msg = JSON.parse(ev.data); }} catch (e) {{ return; }}
if (msg.type === 'audio.start') {{
sentences[msg.sentence_index] = {{
index: msg.sentence_index, text: msg.sentence_text || '',
chunks: [], timestamps: null
}};
}} else if (msg.type === 'audio.chunk') {{
const s = sentences[msg.sentence_index];
if (!s) return;
if (msg.audio_b64) {{
const bin = atob(msg.audio_b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const usable = bytes.length - (bytes.length % 2);
if (usable > 0) s.chunks.push(new Int16Array(bytes.buffer, 0, usable / 2));
}}
if (msg.timestamps != null) s.timestamps = msg.timestamps;
}} else if (msg.type === 'audio.done') {{
const s = sentences[msg.sentence_index];
if (s) wtRenderSentence(s);
}} else if (msg.type === 'session.done') {{
wtWS = null;
try {{ ws.close(); }} catch (e) {{}}
wtPlayNext();
}} else if (msg.type === 'error') {{
wtSet('Error: ' + (msg.message || 'unknown'), '#EF5552');
}}
}};
ws.onerror = () => wtSet('WebSocket error', '#EF5552');
ws.onclose = () => {{ if (wtWS === ws) wtWS = null; }};
}};
</script>
"""
def create_app(api_base: str) -> FastAPI:
"""FastAPI app with a same-origin WebSocket proxy + Gradio UI."""
fastapi_app = FastAPI()
upstream_url = _upstream_ws_url(api_base)
@fastapi_app.websocket("/proxy/stream")
async def proxy_stream(client_ws: WebSocket):
await client_ws.accept()
try:
async with websockets.connect(upstream_url, max_size=None) as upstream:
async def client_to_upstream():
try:
while True:
await upstream.send(await client_ws.receive_text())
except (WebSocketDisconnect, Exception):
await upstream.close()
async def upstream_to_client():
try:
async for msg in upstream:
if isinstance(msg, bytes):
await client_ws.send_bytes(msg)
else:
await client_ws.send_text(msg)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())
except Exception as exc:
logger.exception("WS proxy error")
try:
await client_ws.send_text(json.dumps({"type": "error", "message": f"proxy: {exc}"}))
await client_ws.close()
except Exception:
pass
voices = fetch_voices(api_base)
with gr.Blocks(title="Qwen3-TTS Word Timestamps") as demo:
gr.HTML(f"""
<div style="margin-bottom:8px;">
<h1 style="margin:0; font-size:1.5em;">Qwen3-TTS Streaming Word Timestamps</h1>
<span style="font-size:0.85em; color:#666;">
Served by <a href="https://github.com/vllm-project/vllm-omni" target="_blank">vLLM-Omni</a>
&nbsp;&middot;&nbsp; <code>{api_base}</code> &nbsp;&middot;&nbsp;
launch the server with <code>--forced-aligner</code>.
</span>
</div>
""")
with gr.Row():
with gr.Column(scale=3):
text_input = gr.Textbox(
label="Text to Synthesize",
value="Hello world. How are you today? I am doing great.",
lines=3,
)
with gr.Row():
task_type = gr.Radio(choices=TASK_TYPES, value="CustomVoice", label="Task Type", scale=2)
language = gr.Dropdown(choices=SUPPORTED_LANGUAGES, value="Auto", label="Language", scale=1)
voice = gr.Dropdown(
choices=voices,
value=voices[0] if voices else None,
label="Speaker",
allow_custom_value=True,
)
instructions = gr.Textbox(
label="Instructions",
placeholder="Optional style/emotion (required for VoiceDesign)",
lines=1,
)
with gr.Column(visible=False) as ref_group:
ref_audio_url = gr.Textbox(
label="Reference Audio URL (Base task)",
placeholder="https://example.com/reference.wav",
lines=1,
)
ref_text = gr.Textbox(label="Reference Transcript", lines=1)
generate_btn = gr.Button("Generate + Align", variant="primary", size="lg")
with gr.Column(scale=2):
gr.HTML(value=PLAYER_HTML)
hidden_payload = gr.Textbox(visible=False, elem_id="wt-payload")
def on_task_change(tt):
return gr.update(visible=(tt == "Base"))
task_type.change(fn=on_task_change, inputs=[task_type], outputs=[ref_group])
def on_generate(text, tt, voice_v, lang_v, instr_v, ref_url, ref_t):
if not text or not text.strip():
raise gr.Error("Please enter text to synthesize.")
config: dict = {
"task_type": tt,
"word_timestamps": True,
"stream_audio": True,
"response_format": "pcm",
}
if lang_v:
config["language"] = lang_v
if tt == "CustomVoice":
if voice_v:
config["voice"] = voice_v
if instr_v and instr_v.strip():
config["instructions"] = instr_v.strip()
elif tt == "VoiceDesign":
if not instr_v or not instr_v.strip():
raise gr.Error("VoiceDesign requires voice style instructions.")
config["instructions"] = instr_v.strip()
elif tt == "Base":
if not ref_url or not ref_url.strip():
raise gr.Error("Base (voice clone) needs a reference audio URL.")
config["ref_audio"] = ref_url.strip()
if ref_t and ref_t.strip():
config["ref_text"] = ref_t.strip()
return json.dumps({"config": config, "text": text.strip()})
generate_btn.click(
fn=on_generate,
inputs=[text_input, task_type, voice, language, instructions, ref_audio_url, ref_text],
outputs=[hidden_payload],
).then(
fn=lambda p: p,
inputs=[hidden_payload],
outputs=[hidden_payload],
js="(p) => { if (p && p.trim()) window.wtGenerate(JSON.parse(p)); return p; }",
)
demo.queue()
return gr.mount_gradio_app(
fastapi_app,
demo,
path="/",
head=_build_head_js(PCM_SAMPLE_RATE),
)
def parse_args():
parser = argparse.ArgumentParser(description="Gradio demo for streaming TTS word-level timestamps.")
add_common_args(parser)
return parser.parse_args()
def main():
args = parse_args()
logging.basicConfig(level=logging.INFO)
print(f"Connecting to vLLM server at: {args.api_base}")
app = create_app(args.api_base)
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
@@ -10,6 +10,7 @@ This example demonstrates how to deploy text-to-video models for online video ge
| Wan2.1 T2V (14B) | `Wan-AI/Wan2.1-T2V-14B-Diffusers` |
| Wan2.2 T2V | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` |
| LTX-2 | `Lightricks/LTX-2` |
| Helios (Base / Mid / Distilled) | `BestWishYsh/Helios-Base`, `Helios-Mid`, `Helios-Distilled` |
## Wan2.2 T2V
@@ -323,3 +324,45 @@ curl -sS -X POST http://localhost:8098/v1/videos \
-F "guidance_scale=3.0" \
-F "seed=42"
```
## Helios
Helios ships three variants (`Helios-Base`, `Helios-Mid`, `Helios-Distilled`) that
share the same server launch. Variant-specific knobs (declared in
`vllm_omni/model_extras/helios.py`) are sent per request through the generic
`extra_params` JSON form field — no per-model server flags required.
### Start Server
```bash
vllm serve BestWishYsh/Helios-Base --omni --port 8098
# or: MODEL=BestWishYsh/Helios-Mid bash run_server_helios.sh
```
### Send Requests (curl)
```bash
# Helios-Base (Stage 1 only)
bash run_curl_helios.sh
# Helios-Mid (Stage 2 pyramid + CFG-Zero*)
PRESET=mid-stage2 MODEL=BestWishYsh/Helios-Mid bash run_curl_helios.sh
# Helios-Distilled (Stage 2 pyramid + DMD, few-step)
PRESET=distilled MODEL=BestWishYsh/Helios-Distilled bash run_curl_helios.sh
```
The `mid-stage2` and `distilled` presets attach an `extra_params` field, e.g. for Helios-Distilled:
```bash
curl -sS -X POST http://localhost:8098/v1/videos \
-H "Accept: application/json" \
-F "prompt=A dynamic time-lapse of scenery rushing past the window of a speeding train." \
-F "model=BestWishYsh/Helios-Distilled" \
-F "size=640x384" \
-F "num_frames=99" \
-F "fps=16" \
-F "guidance_scale=1.0" \
-F "seed=42" \
-F 'extra_params={"is_enable_stage2": true, "pyramid_num_inference_steps_list": [2, 2, 2], "is_amplify_first_chunk": true}'
```
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# Helios text-to-video curl example using the async video job API.
#
# Helios-specific knobs (declared in vllm_omni/model_extras/helios.py) are passed
# through the generic `extra_params` JSON form field. Select a variant via PRESET:
# PRESET=base -> Helios-Base, Stage 1 only (default)
# PRESET=mid-stage2 -> Helios-Mid, Stage 2 pyramid + CFG-Zero*
# PRESET=distilled -> Helios-Distilled, Stage 2 pyramid + DMD (few-step)
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8098}"
MODEL="${MODEL:-BestWishYsh/Helios-Base}"
PROMPT="${PROMPT:-A dynamic time-lapse of scenery rushing past the window of a speeding train.}"
PRESET="${PRESET:-base}"
OUTPUT_PATH="${OUTPUT_PATH:-helios_t2v_${PRESET}.mp4}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
case "${PRESET}" in
base)
EXTRA_PARAMS=""
GUIDANCE_SCALE="5.0"
;;
mid-stage2)
EXTRA_PARAMS='{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [20, 20, 20], "use_cfg_zero_star": true, "use_zero_init": true, "zero_steps": 1}'
GUIDANCE_SCALE="5.0"
;;
distilled)
EXTRA_PARAMS='{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [2, 2, 2], "is_amplify_first_chunk": true}'
GUIDANCE_SCALE="1.0"
;;
*)
echo "Unknown PRESET '${PRESET}' (expected base|mid-stage2|distilled)"
exit 1
;;
esac
create_args=(
-sS -X POST "${BASE_URL}/v1/videos"
-H "Accept: application/json"
-F "prompt=${PROMPT}"
-F "model=${MODEL}"
-F "size=640x384"
-F "num_frames=99"
-F "fps=16"
-F "num_inference_steps=50"
-F "guidance_scale=${GUIDANCE_SCALE}"
-F "seed=42"
)
if [ -n "${EXTRA_PARAMS}" ]; then
create_args+=(-F "extra_params=${EXTRA_PARAMS}")
fi
create_response=$(curl "${create_args[@]}")
video_id="$(echo "${create_response}" | jq -r '.id')"
if [ -z "${video_id}" ] || [ "${video_id}" = "null" ]; then
echo "Failed to create video job:"
echo "${create_response}" | jq .
exit 1
fi
echo "Created video job ${video_id} (preset=${PRESET})"
echo "${create_response}" | jq .
while true; do
status_response="$(curl -sS "${BASE_URL}/v1/videos/${video_id}")"
status="$(echo "${status_response}" | jq -r '.status')"
case "${status}" in
queued|in_progress)
echo "Video job ${video_id} status: ${status}"
sleep "${POLL_INTERVAL}"
;;
completed)
echo "${status_response}" | jq .
break
;;
failed)
echo "Video generation failed:"
echo "${status_response}" | jq .
exit 1
;;
*)
echo "Unexpected status response:"
echo "${status_response}" | jq .
exit 1
;;
esac
done
curl -sS -L "${BASE_URL}/v1/videos/${video_id}/content" -o "${OUTPUT_PATH}"
echo "Saved video to ${OUTPUT_PATH}"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Helios online serving startup script.
# All three variants (Helios-Base / Helios-Mid / Helios-Distilled) share the same
# server launch; variant-specific knobs are sent per-request via `extra_params`
# (see run_curl_helios.sh).
MODEL="${MODEL:-BestWishYsh/Helios-Base}"
PORT="${PORT:-8098}"
echo "Starting Helios server..."
echo "Model: $MODEL"
echo "Port: $PORT"
vllm serve "$MODEL" --omni \
--port "$PORT"
+1
View File
@@ -102,6 +102,7 @@ plugins:
- "vllm_omni.entrypoints.async_diffusion" # avoid importing vllm in mkdocs building
- "vllm_omni.entrypoints.openai" # avoid importing vllm in mkdocs building
- "vllm_omni.entrypoints.openai.protocol" # avoid importing vllm in mkdocs building
- "vllm_omni.experimental" # avoid importing optional serving deps (openai, fastapi, uvicorn, httpx) in mkdocs building
- mkdocstrings:
handlers:
python:
+11 -1
View File
@@ -71,7 +71,13 @@ dev = [
]
demo = [
"gradio>=6.7.0",
"opencv-python>=4.12.0.88", # cli/stream_client frame reading
"requests>=2.28.0", # cli/stream_client HTTP
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
]
forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
]
# Local-test-only extras
@@ -181,6 +187,7 @@ ignore = [
# These files keep upstream conventions (star imports, long lines, math-symbol
# argument names) so they stay easy to diff against upstream releases.
"vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py" = ["E402", "E501", "F403", "F405", "N803"]
"vllm_omni/experimental/fullduplex/joyvl/decision/prompts.py" = ["E501"] # System/summarizer prompts are long prose lines
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"librosa".msg = "The librosa module is banned, use vllm.multimodal helpers instead"
@@ -301,3 +308,6 @@ CANN = "CANN"
thi = "thi"
ND = "ND"
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"
+18 -25
View File
@@ -4,27 +4,27 @@
- Vendor: Helios
- Model: `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled`
- Task: Text-to-video, image-to-video, and video-to-video generation
- Mode: Offline inference with the bundled Helios example
- Task: Text-to-video generation
- Mode: Offline inference with the shared text_to_video example
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a known-good starting point for running Helios
video generation with vLLM-Omni. The concrete command below focuses on
`BestWishYsh/Helios-Base` text-to-video generation on one NVIDIA H20 GPU. The
same example script also supports Helios-Mid, Helios-Distilled, image-to-video,
and video-to-video; see the linked example docs for those model-specific flags.
`BestWishYsh/Helios-Base` text-to-video generation on one NVIDIA H20 GPU via the
shared `text_to_video.py` example. The same example also supports Helios-Mid and
Helios-Distilled through the generic `--extra-body` flag (see below).
Image-to-video and video-to-video require image/video conditioning inputs and
are not covered by the text-to-video example.
## References
- Upstream repository: <https://github.com/PKU-YuanGroup/Helios>
- Model weights:
<https://huggingface.co/BestWishYsh/Helios-Base>
- Upstream or canonical docs:
[`docs/user_guide/examples/offline_inference/helios.md`](../../docs/user_guide/examples/offline_inference/helios.md)
- Related example under `examples/`:
[`examples/offline_inference/helios/README.md`](../../examples/offline_inference/helios/README.md)
[`examples/offline_inference/text_to_video/text_to_video.md`](../../examples/offline_inference/text_to_video/text_to_video.md)
## Hardware Support
@@ -46,11 +46,10 @@ and video-to-video; see the linked example docs for those model-specific flags.
Run the baseline Helios-Base text-to-video example from the repository root:
```bash
cd examples/offline_inference/helios
cd examples/offline_inference/text_to_video
python end2end.py \
python text_to_video.py \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
@@ -59,13 +58,12 @@ python end2end.py \
To use cache-dit acceleration, run on a vLLM-Omni checkout that includes Helios cache-dit support and enable the cache backend:
```bash
cd examples/offline_inference/helios
cd examples/offline_inference/text_to_video
python end2end.py \
python text_to_video.py \
--cache-backend cache_dit \
--enable-cache-dit-summary \
--model BestWishYsh/Helios-Base \
--sample-type t2v \
--prompt "A dynamic time-lapse video showing the rapidly moving scenery from the window of a speeding train." \
--guidance-scale 5.0 \
--output helios_t2v_base.mp4
@@ -85,22 +83,17 @@ Saved generated video to helios_t2v_base.mp4
- `--model BestWishYsh/Helios-Base` selects the base Helios checkpoint used by
this recipe.
- `--sample-type t2v` selects text-to-video generation. Use `i2v` with
`--image-path` for image-to-video, or `v2v` with `--video-path` for
video-to-video.
- `--guidance-scale 5.0` matches the Helios-Base recommendation. Use
`--guidance-scale 1.0` for Helios-Distilled.
- `--cache-backend cache_dit` enables the cache-dit acceleration path.
- `--enable-cache-dit-summary` prints cache-dit summary information after
diffusion forward passes.
- No separate stage config is required for this offline recipe; the bundled
Helios example configures the pipeline through `end2end.py` arguments.
- For Helios-Mid, add `--is-enable-stage2`,
`--pyramid-num-inference-steps-list 20 20 20`, `--use-cfg-zero-star`,
`--use-zero-init`, and `--zero-steps 1`.
- For Helios-Distilled, add `--is-enable-stage2`,
`--pyramid-num-inference-steps-list 2 2 2`, and
`--is-amplify-first-chunk`.
- No separate stage config is required for this offline recipe; the shared
`text_to_video.py` example configures the pipeline through its arguments.
- Helios-specific knobs (declared in `vllm_omni/model_extras/helios.py`) are
passed via the generic `--extra-body` JSON flag:
- Helios-Mid: `--extra-body '{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [20, 20, 20], "use_cfg_zero_star": true, "use_zero_init": true, "zero_steps": 1}'`
- Helios-Distilled: `--extra-body '{"is_enable_stage2": true, "pyramid_num_inference_steps_list": [2, 2, 2], "is_amplify_first_chunk": true}'`
#### Known limitations
+192
View File
@@ -0,0 +1,192 @@
# JoyAI-VL-Interaction
> Real-time streaming video-language interaction (proactive speak / silence / delegate)
## Summary
- Vendor: JD (Joy Future Academy)
- Model: [`jdopensource/JoyAI-VL-Interaction-Preview`](https://huggingface.co/jdopensource/JoyAI-VL-Interaction-Preview) (8B, vanilla Qwen3-VL weights)
- Task: Per-tick proactive interaction over a live video stream — the model decides
on its own each second to speak, stay silent, or delegate a hard question
- Mode: Online serving — an OpenAI-compatible interaction orchestrator in front of
a plain `vllm serve` backend
- Maintainer: Community
## When to use this recipe
Use this to stand up the streaming-interaction serving layer (`vllm_omni/experimental/fullduplex/`).
The model is served unchanged by `vllm serve`; this layer adds session state, 3-tier
summary memory, the per-tick decision, and pluggable ASR / TTS / delegation. For the
framework internals and how to add another full-duplex model, see
[`vllm_omni/experimental/fullduplex/README.md`](../../vllm_omni/experimental/fullduplex/README.md).
## Environment
- OS: Linux
- Python: 3.10+
- Hardware: 1x GPU. The default `T_s=100` frame window + `--max-model-len 131072` wants
≈48GB+; for ~24GB, lower `--chunk-frames`, `--max-model-len`, and the image limit together
- vLLM / vLLM-Omni: versions from your current checkout
## Start server
From repository root:
```bash
# 1. Serve the model (plain `vllm serve`, NOT --omni; it is vanilla Qwen3-VL).
# The image limit must cover the short-term frame window (chunk_frames, default 100 = T_s);
# prefix caching keeps the accumulating window cheap. Lower both for smaller GPUs.
vllm serve jdopensource/JoyAI-VL-Interaction-Preview \
--served-model-name JoyAI-VL-Interaction-Preview --port 8061 \
--max-model-len 131072 --enable-prefix-caching \
--limit-mm-per-prompt '{"image":256,"video":1}'
# 2. Interaction orchestrator (OpenAI-compatible, :8070)
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server --port 8070 \
--main-backend-url http://127.0.0.1:8061/v1 --main-model JoyAI-VL-Interaction-Preview
```
Optional one-shot launch (model + orchestrator + JD webui + ASR/TTS/background,
all env-configurable). The JD webui frontend is external — set `WEBUI_DIR` to point at it:
```bash
bash examples/online_serving/joyvl_interaction/scripts/start_all.sh
```
## Using the model
The orchestrator is OpenAI-compatible: send **one user turn per video frame** (~1 fps) to
`/v1/chat/completions` with an `x-session-id` header, and attach an optional **standing
instruction** as a text part. Each reply carries an `interaction` block — `action` is
`silence` / `response` / `delegate` and `text` is what to say:
```bash
curl -s http://127.0.0.1:8070/v1/chat/completions \
-H 'x-session-id: s1' -H 'content-type: application/json' -d '{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Alert me if a fire breaks out"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
]}]}' | jq .interaction
```
Send the standing instruction once (it persists for the session); subsequent turns send
just the frame. Ready-made headless client: `cli/run_cli_demo.py`. Reset a session with
`POST /reset {"session_id": "..."}`.
**What to ask it** — give a standing task and let it act on its own each second:
- **Proactive alerting** — "tell me when someone enters", "alert me if a fire breaks out"
- **Streaming Q&A** — ask about what's on screen; it answers once it has the evidence
- **Time & memory** — "how many people have walked past?", "what did you see earlier?"
(the 3-tier summary memory lets it recall beyond the live frame window)
- **Read & translate** — "translate the on-screen text as it appears"
- **Delegate hard questions** — handed to a background brain (see below)
- **Live commentary** — `--persona talkative` for continuous, danmaku-style narration
**Personas** (`--persona`): `default` (speak on meaningful events or to answer), `silent`
(answer only when asked, never delegate), `talkative` (proactively narrate).
**Tuning:** `--chunk-frames` (short-term window `T_s`), `--response-dedup-threshold`
(lower drops more near-duplicate narration), and `--no-memory` to disable the 3-tier
summaries. The model stays silent until the first instruction arrives
(`force_silence_before_query`, on by default).
## Delegation (background brain)
When the model judges a question too hard, it emits `</delegation> <question>` and the
orchestrator hands it to a **background brain** — any OpenAI-compatible endpoint you
self-host. Enable it by pointing the orchestrator at one:
```bash
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server --port 8070 \
--main-backend-url http://127.0.0.1:8061/v1 --main-model JoyAI-VL-Interaction-Preview \
--delegation-backend-url <brain-endpoint>/v1 \
--delegation-model <brain-model> --delegation-kind chat
```
`--delegation-kind` picks the bridge:
- `chat` — a stronger text/VL model answers (`/chat/completions`)
- `image` — a text-to-image model generates a picture (`/images/generations`, e.g. Qwen-Image)
- `edit` — an image-edit model restyles the current frame (e.g. Qwen-Image-Edit)
- `router` — dispatch each request to chat / image / edit by inspecting it (set
`--delegation-image-url` / `--delegation-edit-url` for the latter two)
- `stub` — canned answers for tests/demos only (no backend needed)
`chat`/`image`/`edit`/`router` each need a backend URL — **without one, delegation stays
off** (the model's delegate note is still spoken, but nothing is folded back). The brain is
**bring-your-own**: a larger vLLM you serve, or any OpenAI-compatible API (e.g.
`--delegation-backend-url https://api.anthropic.com/v1/ --delegation-model claude-...
--delegation-api-key …`). The reference deployment instead drives the `codex` CLI as the
brain via a separate background-agent service; that agent runs with its own credentials
and bypasses its sandbox, so it is **not bundled here** — self-host a plain
OpenAI-compatible endpoint instead.
## Host the WebUI demo
For the full browser experience — live webcam / RTSP input, voice (ASR/TTS), and the
per-tick decision stream — run JD's official WebUI in front of the orchestrator. Clone the
model repo and start its WebUI pointed at the orchestrator (`:8070`):
```bash
git clone https://github.com/jd-opensource/JoyAI-VL-Interaction.git
cd JoyAI-VL-Interaction/services/webui
uv venv && uv pip install -e .
bash scripts/start_server.sh --api-base http://127.0.0.1:8070/v1
```
Open the printed HTTPS URL, allow the camera (or enter an RTSP URL), and give a standing
instruction. `examples/online_serving/joyvl_interaction/scripts/start_all.sh` can launch
the model + orchestrator + WebUI together — point `WEBUI_DIR` at your clone.
## Verification
```bash
# headless: stream a clip and print the per-tick decision timeline
# (the CLI reads video frames via OpenCV: `uv pip install opencv-python`)
python examples/online_serving/joyvl_interaction/cli/run_cli_demo.py \
path/to/video.mp4 --query "Alert me if a fire breaks out"
pytest tests/fullduplex # framework + JoyVL unit tests
```
## Testing with an RTSP stream (optional)
RTSP is a **webui-side input** — the browser pulls the stream and feeds frames to the
orchestrator over the normal API; no serving-layer code is involved. To simulate an RTSP
camera from a local video file (no physical IP camera needed), use the helper scripts in
[`examples/online_serving/joyvl_interaction/rtsp/`](../../examples/online_serving/joyvl_interaction/rtsp/),
which wrap [MediaMTX](https://github.com/bluenviron/mediamtx/releases) + `ffmpeg`:
```bash
cd examples/online_serving/joyvl_interaction/rtsp
# 1. Local RTSP server (MediaMTX, listens on :8554)
bash ./mediamtx.sh
# 2. Push a local video file as an RTSP stream (another terminal)
bash ./rtsp.sh ./videos/example.mp4 rtsp://127.0.0.1:8554/fire1
# 3. In the WebUI RTSP box, enter: rtsp://127.0.0.1:8554/fire1
# (replace 127.0.0.1 with the MediaMTX host IP if the webui runs on another machine)
```
See the directory's `README.md` for streaming a whole video folder (`rtsp_all.sh`) and
the audio-track caveat.
## Notes
- `--omni` is **not** used: the model is standard Qwen3-VL, so stock `vllm serve`
runs the forward pass; this recipe only adds the interaction/serving layer.
- On a host without `nvcc` / `ninja`, `vllm serve` of the 8B can crash engine-core in the
FlashInfer sampler JIT (`FileNotFoundError: 'ninja'`) during `profile_run`. Set
`VLLM_USE_FLASHINFER_SAMPLER=0` (or install `ninja`) to work around it.
- `force_silence_before_query` is on by default — the model stays silent until an
instruction arrives; give a standing task (e.g. "translate the on-screen text")
to arm proactive output.
- Speech is external and pluggable: point `ASR_URL` / `TTS_URL` at the bridges in
`examples/online_serving/joyvl_interaction/bridges/` or any compatible service.
- The decision prompts, sampling, and 3-tier summary memory (`T_s=100`, mid→long every
5 chunks, `key_frames=0` = summarize all chunk frames) are aligned to the JoyVL
reference adapter so per-tick behavior matches the released model; the framework only
supplies the serving structure.
+1
View File
@@ -64,6 +64,7 @@ recipes/
| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB |
| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB |
| [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB |
| [`JD/JoyAI-VL-Interaction.md`](./JD/JoyAI-VL-Interaction.md) | Real-time streaming video-language interaction (proactive speak/silence/delegate) | 1x GPU 24GB+ |
Within a single recipe file, include different hardware support sections such
as `GPU`, `ROCm`, and `NPU`, and add concrete tested configurations like
+27 -1
View File
@@ -6,7 +6,7 @@
- Vendor: NVIDIA
- Model: `nvidia/Cosmos3-Nano`
- Task: Text-to-image (T2I), text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) generation, with optional synchronized audio (video + sound), action policy
- Task: Text-to-image (T2I), text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) generation, with optional transfer controls, synchronized audio (video + sound), action policy
- Mode: Online serving with the OpenAI-compatible image/video APIs, plus offline generation via the `Omni` API
- Maintainer: Community
@@ -25,6 +25,9 @@ mode is selected per request:
reference-video latent frames; use `extra_params.condition_frame_indexes_vision`
and `extra_params.condition_video_keep` to choose which prefix/tail frames guide
generation.
- **Transfer V2V** — pass one or more transfer hints in `extra_params`
(`edge`, `blur`, `depth`, `seg`, `wsm`) to guide generation with control
frames. Transfer mode is video-only and cannot be combined with sound or action.
- **T2VS / I2VS** — add `generate_sound=true` (and optional `sound_duration`) to a
T2V/I2V `/v1/videos/sync` request to also generate synchronized audio, muxed into
the mp4 as AAC 48 kHz stereo. See the official model card's "Video + Audio" examples.
@@ -190,6 +193,18 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \
-F 'video_reference={"video_url":"https://example.com/reference.mp4"}' \
-o cosmos3_v2v_from_url.mp4
# Transfer V2V with a precomputed depth control video. `control_path` can point
# to a local image/video; edge and blur can also be computed from `input_reference`
# by passing `"edge":true` or `"blur":true`.
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H "Accept: video/mp4" \
-F "model=nvidia/Cosmos3-Nano" \
-F "prompt=Generate a realistic scene following the provided control video." \
-F "size=1280x720" -F "num_frames=121" \
-F "num_inference_steps=50" -F "seed=125" \
-F 'extra_params={"depth":{"control_path":"/path/to/depth_control.mp4"},"max_frames":121,"resolution":"720","num_video_frames_per_chunk":121}' \
-o cosmos3_transfer_depth.mp4
# Text-to-video-with-sound
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H "Accept: video/mp4" \
@@ -312,6 +327,17 @@ vllm serve nvidia/Cosmos3-Nano-Policy-DROID \
For V2V, `condition_frame_indexes_vision` selects the clean conditioned latent
frame indexes (default `[0, 1]`), and `condition_video_keep` selects whether the
API decodes the first or last needed reference frames (`"first"` by default).
- **Transfer controls:** `extra_params` may include `edge`, `blur`, `depth`,
`seg`, or `wsm`. Each hint accepts `true`, a path string, or an object such as
`{"control_path": "/path/to/control.mp4"}`; `edge` also accepts
`preset_edge_threshold` and `blur` accepts `preset_blur_strength`.
Transfer-level options include `control_guidance`,
`control_guidance_interval`, `num_video_frames_per_chunk` (default `93`,
`101` for WSM), `num_conditional_frames` (default `1`),
`num_first_chunk_conditional_frames`, `max_frames`,
`show_control_condition`, `show_input`, and
`share_vision_temporal_positions`. Non-WSM transfer preserves the input video
fps when available; WSM defaults to 10 fps unless `fps` is supplied.
- **DROID OpenPI observations:** include a string `prompt`, either
`observation/image` or the three-view DROID camera keys
(`observation/wrist_image_left`, `observation/exterior_image_1_left`,
+4 -1
View File
@@ -57,7 +57,10 @@ vllm serve nvidia/Cosmos3-Super \
Guardrails are on by default (gated `nvidia/Cosmos-1.0-Guardrail` — `pip install
cosmos-guardrail`, accept the license, set `HF_TOKEN`); add `--no-guardrails` to
disable. `--enable-layerwise-offload` reduces VRAM on smaller GPUs.
disable. `--enable-layerwise-offload` reduces VRAM on smaller GPUs;
`--quantization fp8` (online, no calibration) cuts peak VRAM for 720p video
generation from ~83 GB to ~55 GB per GPU (2-GPU) with BF16-level quality (T2V
composition can shift at the same seed).
#### Verification
+130 -14
View File
@@ -92,6 +92,18 @@ def _metadata(
)()
def _decode_kv_cache(
*,
num_blocks: int = 8,
block_size: int = 16,
num_kv_heads: int = 8,
head_size: int = 128,
dtype: torch.dtype = torch.float16,
) -> torch.Tensor:
# vLLM >=0.23.0 KV cache layout: key/value live on dim 1.
return torch.zeros((num_blocks, 2, block_size, num_kv_heads, head_size), dtype=dtype)
def test_fish_kvcache_enabled_by_default(monkeypatch):
monkeypatch.delenv("VLLM_OMNI_FISH_KVCACHE_ATTN", raising=False)
@@ -182,7 +194,7 @@ def test_fish_kvcache_backend_wraps_only_model_instance(monkeypatch):
metadata = _metadata()
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
@@ -201,6 +213,110 @@ def test_fish_kvcache_backend_wraps_only_model_instance(monkeypatch):
assert fish_kvcache_backend.get_fish_kvcache_attn_stats()["small_hit_count"] == 1
def _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size):
# Source of truth for the paged KV cache layout the backend must unpack.
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
return FlashAttentionBackend.get_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size)
def test_fish_kvcache_backend_unbinds_kv_on_vllm_cache_layout(monkeypatch):
# vLLM >=0.23.0 lays the KV cache out as (num_blocks, 2, block_size,
# num_kv_heads, head_size). The backend must unbind on dim 1 so each of
# key/value comes back as the 4-D (num_blocks, block_size, num_kv_heads,
# head_size) tensor the decode kernel expects.
monkeypatch.setenv("VLLM_OMNI_FISH_KVCACHE_ATTN", "1")
monkeypatch.setattr(fish_kvcache_backend, "is_available", lambda: True)
monkeypatch.setattr(fish_kvcache_backend, "load_error", lambda: None)
monkeypatch.setattr(fish_kvcache_backend, "can_use_fish_kvcache_attn", lambda **_: True)
num_blocks, block_size, num_kv_heads, head_size = 8, 16, 8, 128
cache_shape = _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size)
assert cache_shape == (num_blocks, 2, block_size, num_kv_heads, head_size)
captured = {}
def fake_decode(query, key_cache, value_cache, block_table, seq_lens, out, *, scale, max_seq_len):
del query, block_table, seq_lens, scale, max_seq_len
captured["key"] = tuple(key_cache.shape)
captured["value"] = tuple(value_cache.shape)
out.fill_(1)
return out
monkeypatch.setattr(fish_kvcache_backend, "fish_decode_kvcache_attn", fake_decode)
model = _FakeModel()
assert fish_kvcache_backend.install_fish_kvcache_attn_backend(model) == 1
query = torch.zeros((2, 32, head_size), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros(cache_shape, dtype=torch.float16)
model.layers[0].self_attn.attn.impl.forward(None, query, None, None, kv_cache, _metadata(), output)
expected = (num_blocks, block_size, num_kv_heads, head_size)
assert captured["key"] == expected
assert captured["value"] == expected
def test_fish_kvcache_vllm_cache_unbinds_then_falls_back_until_contiguous(monkeypatch):
# End-to-end with the real vLLM cache layout and the real (unmocked) guard.
#
# The point of the unbind(1) fix: unbind(0) raised "too many values to
# unpack" on the 5-D vLLM 0.23.0 layout, so the request crashed outright.
# unbind(1) makes the request *work* again -- it unpacks cleanly and the
# backend gracefully falls back to the original attention with correct
# output. This test guards that fix and must NOT depend on the fast path
# firing.
#
# It also documents a known limitation (tracked separately): unbind(1) on
# the interleaved (num_blocks, 2, ...) layout yields *non-contiguous*
# key/value views, which the guard rejects on the is_contiguous() check.
# So the dimensions are compatible (4-D, block_size=16, head_size=128) --
# the only thing keeping the fast path from engaging is contiguity, not
# shape. Making the fast path actually fire would require a .contiguous()
# copy and is out of scope here.
fish_kvcache_backend.reset_fish_kvcache_attn_stats()
monkeypatch.setenv("VLLM_OMNI_FISH_KVCACHE_ATTN", "1")
monkeypatch.setattr(fish_kvcache_backend, "is_available", lambda: True)
monkeypatch.setattr(fish_kvcache_backend, "load_error", lambda: None)
monkeypatch.setattr(fish_kvcache_attn, "is_available", lambda: True)
num_blocks, block_size, num_kv_heads, head_size = 8, 16, 8, 128
cache_shape = _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size)
decode_calls = []
def fake_decode(*args, **kwargs):
del args, kwargs
decode_calls.append(True)
monkeypatch.setattr(fish_kvcache_backend, "fish_decode_kvcache_attn", fake_decode)
model = _FakeModel()
assert fish_kvcache_backend.install_fish_kvcache_attn_backend(model) == 1
query = torch.zeros((2, 32, head_size), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros(cache_shape, dtype=torch.float16)
# Root cause record: unbind(1) gives the right 4-D shape but a non-contiguous view.
key_cache, value_cache = kv_cache.unbind(1)
assert tuple(key_cache.shape) == (num_blocks, block_size, num_kv_heads, head_size)
assert not key_cache.is_contiguous()
# unbind(1) no longer crashes (unbind(0) would have raised here) and the
# request completes via the correct fallback path.
result = model.layers[0].self_attn.attn.impl.forward(None, query, None, None, kv_cache, _metadata(), output)
assert result is output
assert decode_calls == []
assert model.layers[0].self_attn.attn.impl.original_calls == 1
assert torch.equal(output, torch.full_like(output, 2))
stats = fish_kvcache_backend.get_fish_kvcache_attn_stats()
assert stats["fallback_count_by_reason"] == {"guard_miss": 1}
def test_fish_kvcache_backend_install_is_idempotent(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_FISH_KVCACHE_ATTN", "1")
monkeypatch.setattr(fish_kvcache_backend, "is_available", lambda: True)
@@ -215,7 +331,7 @@ def test_fish_kvcache_backend_install_is_idempotent(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
model.layers[0].self_attn.attn.impl.forward(
None,
@@ -250,7 +366,7 @@ def test_fish_kvcache_backend_falls_back_to_original_forward_on_guard_miss(monke
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
@@ -297,7 +413,7 @@ def test_fish_kvcache_backend_uses_decode_seq_lens_upper_bound(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
model.layers[0].self_attn.attn.impl.forward(
None,
@@ -340,7 +456,7 @@ def test_fish_kvcache_backend_uses_cpu_upper_bound_above_metadata_max(monkeypatc
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
model.layers[0].self_attn.attn.impl.forward(
None,
@@ -383,7 +499,7 @@ def test_fish_kvcache_backend_slices_upper_bound_by_active_batch(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
model.layers[0].self_attn.attn.impl.forward(
None,
@@ -428,7 +544,7 @@ def test_fish_kvcache_backend_falls_back_on_short_upper_bound_shape(monkeypatch)
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
@@ -475,7 +591,7 @@ def test_fish_kvcache_backend_falls_back_on_non_cpu_upper_bound(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
@@ -509,7 +625,7 @@ def test_fish_kvcache_backend_required_rejects_non_cpu_upper_bound(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
with pytest.raises(RuntimeError, match="must be a CPU tensor"):
model.layers[0].self_attn.attn.impl.forward(
@@ -539,7 +655,7 @@ def test_fish_kvcache_backend_required_rejects_underestimated_upper_bound(monkey
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
with pytest.raises(RuntimeError, match="underestimates"):
model.layers[0].self_attn.attn.impl.forward(
@@ -569,7 +685,7 @@ def test_fish_kvcache_backend_required_rejects_missing_upper_bound(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
with pytest.raises(RuntimeError, match="requires seq_lens_cpu_upper_bound"):
model.layers[0].self_attn.attn.impl.forward(
@@ -603,7 +719,7 @@ def test_fish_kvcache_backend_required_raises_on_guard_miss(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
with pytest.raises(RuntimeError, match="required"):
model.layers[0].self_attn.attn.impl.forward(
@@ -629,7 +745,7 @@ def test_fish_kvcache_backend_required_allows_prefill_fallback(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
@@ -675,7 +791,7 @@ def test_fish_kvcache_backend_falls_back_without_cpu_upper_bound(monkeypatch):
query = torch.zeros((2, 32, 128), dtype=torch.float16)
output = torch.zeros_like(query)
kv_cache = torch.zeros((2, 8, 16, 8, 128), dtype=torch.float16)
kv_cache = _decode_kv_cache()
result = model.layers[0].self_attn.attn.impl.forward(
None,
+16
View File
@@ -30,6 +30,7 @@ class TestCentralRegistryDeclarations:
assert "qwen2_5_omni" in _OMNI_PIPELINES
assert "qwen2_5_omni_thinker_only" in _OMNI_PIPELINES
assert "qwen3_omni_moe" in _OMNI_PIPELINES
assert "aura_omni" in _OMNI_PIPELINES
assert "qwen3_tts" in _OMNI_PIPELINES
@@ -45,6 +46,20 @@ class TestLazyLoading:
assert pipeline.model_type == "qwen3_omni_moe"
assert pipeline.model_arch == "Qwen3OmniMoeForConditionalGeneration"
def test_getitem_loads_aura_omni_pipeline(self):
pipeline = _PIPELINE_REGISTRY["aura_omni"]
assert pipeline.model_type == "aura_omni"
assert [stage.model_stage for stage in pipeline.stages] == [
"asr",
"aura",
"qwen3_tts",
"code2wav",
]
assert pipeline.stages[0].model_arch == "Qwen3ASRForConditionalGeneration"
assert pipeline.stages[1].model_arch == "AuraQwen3VLForConditionalGeneration"
assert pipeline.stages[2].model_arch == "Qwen3TTSTalkerForConditionalGeneration"
assert pipeline.stages[3].model_arch == "Qwen3TTSCode2Wav"
def test_unknown_model_type_returns_none_via_get(self):
assert _PIPELINE_REGISTRY.get("not_a_real_pipeline") is None
@@ -73,6 +88,7 @@ class TestLazyLoading:
"attr_fail": ("vllm_omni.config.stage_config", "NON_EXISTENT_VAR"),
},
)
monkeypatch.setattr(_PIPELINE_REGISTRY, "_loaded", {})
# Assert that list(_PIPELINE_REGISTRY.values()) and dict(_PIPELINE_REGISTRY.items())
# both yield only healthy entries.
@@ -122,9 +122,6 @@ def test_speech_missing_required_fields(omni_server: OmniServer, openai_client:
{"response_format": "mpeg"}, ("response_format", "literal_error", "wav"), id="response_format_invalid"
),
pytest.param({"stream_format": 1}, ("stream_format", "literal_error", "audio"), id="stream_format_wrong_type"),
pytest.param(
{"stream_format": "sse"}, ("stream_format", "value_error", "audio"), id="stream_format_sse_blocked"
),
pytest.param({"stream": "wrong_type"}, ("stream", "bool_parsing", "validation error"), id="stream_wrong_type"),
pytest.param(
{
@@ -0,0 +1,223 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for NaiveCache merge/split logic used in batched CFG."""
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.models.bagel.bagel_transformer import NaiveCache
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
NUM_LAYERS = 2
NUM_KV_HEADS = 4
HEAD_DIM = 8
def _make_cache(num_layers, seq_len, num_kv_heads=NUM_KV_HEADS, head_dim=HEAD_DIM, seed=0):
"""Create a NaiveCache with deterministic random data. seq_len=0 returns an empty cache."""
gen = torch.Generator().manual_seed(seed)
cache = NaiveCache(num_layers)
if seq_len == 0:
return cache
for layer in range(num_layers):
cache.key_cache[layer] = torch.randn(seq_len, num_kv_heads, head_dim, generator=gen)
cache.value_cache[layer] = torch.randn(seq_len, num_kv_heads, head_dim, generator=gen)
return cache
def test_init_creates_none_entries():
"""Ensure the NaiveCache is initialized with None values per layer."""
cache = NaiveCache(NUM_LAYERS)
assert cache.num_layers == NUM_LAYERS
for layer in range(NUM_LAYERS):
assert cache.key_cache[layer] is None
assert cache.value_cache[layer] is None
@pytest.mark.parametrize("seq_len", [0, 10])
def test_seq_lens_empty(seq_len):
"""Ensure that by default, we have 0 seq lens."""
cache = _make_cache(NUM_LAYERS, seq_len=seq_len)
assert cache.seq_lens == seq_len
assert cache.num_layers == NUM_LAYERS
### Merge tests
def test_merge_two_equal_length():
"""Ensure that we can merge two NaiveCaches that are identically shaped."""
c0 = _make_cache(NUM_LAYERS, seq_len=5, seed=0)
c1 = _make_cache(NUM_LAYERS, seq_len=5, seed=1)
merged = NaiveCache.merge([c0, c1])
assert merged.key_values_lens == [5, 5]
for layer in range(NUM_LAYERS):
assert merged.key_cache[layer].shape[0] == 10
# the merged cache will just have keys and values per layer concatenated
assert torch.equal(merged.key_cache[layer][:5], c0.key_cache[layer])
assert torch.equal(merged.key_cache[layer][5:], c1.key_cache[layer])
assert torch.equal(merged.value_cache[layer][:5], c0.value_cache[layer])
assert torch.equal(merged.value_cache[layer][5:], c1.value_cache[layer])
def test_merge_three_zero_len_cache():
"""Ensure we handle zero length cache correctly in merge."""
# NOTE: This is relevant for text_cfg in Bagel, which has a len of 0 by default
gen_cache = _make_cache(NUM_LAYERS, seq_len=10, seed=0)
text_cfg_cache = _make_cache(NUM_LAYERS, seq_len=0)
img_cfg_cache = _make_cache(NUM_LAYERS, seq_len=7, seed=2)
merged = NaiveCache.merge([gen_cache, text_cfg_cache, img_cfg_cache])
assert merged.key_values_lens == [10, 0, 7]
for layer in range(NUM_LAYERS):
assert merged.key_cache[layer].shape[0] == 17
assert torch.equal(merged.key_cache[layer][:10], gen_cache.key_cache[layer])
assert torch.equal(merged.key_cache[layer][10:], img_cfg_cache.key_cache[layer])
def test_merge_all_empty():
"""Ensure that merging empty caches is well defined."""
caches = [_make_cache(NUM_LAYERS, seq_len=0) for _ in range(3)]
merged = NaiveCache.merge(caches)
assert merged.key_values_lens == [0, 0, 0]
for layer in range(NUM_LAYERS):
assert merged.key_cache[layer] is None
assert merged.value_cache[layer] is None
def test_merge_single_cache():
"""Ensure merging one cache returns an identical cache."""
c = _make_cache(NUM_LAYERS, seq_len=8, seed=42)
merged = NaiveCache.merge([c])
assert merged.key_values_lens == [8]
for layer in range(NUM_LAYERS):
assert torch.equal(merged.key_cache[layer], c.key_cache[layer])
def test_merge_preserves_dtype():
"""Ensure merging doesn't modify dtypes."""
cache = NaiveCache(NUM_LAYERS)
for layer in range(NUM_LAYERS):
cache.key_cache[layer] = torch.randn(5, NUM_KV_HEADS, HEAD_DIM, dtype=torch.bfloat16)
cache.value_cache[layer] = torch.randn(5, NUM_KV_HEADS, HEAD_DIM, dtype=torch.bfloat16)
merged = NaiveCache.merge([cache])
assert merged.key_cache[0].dtype == torch.bfloat16
assert merged.value_cache[0].dtype == torch.bfloat16
### Split tests
def test_split_all_nonzero():
"""Ensure NaiveCache splits in the simple case (all lens nonzero)."""
t = torch.randn(15, NUM_KV_HEADS, HEAD_DIM)
parts = NaiveCache.split_with_zeros(t, [5, 4, 6])
assert len(parts) == 3
assert all(p is not None for p in parts)
assert parts[0].shape[0] == 5
assert parts[1].shape[0] == 4
assert parts[2].shape[0] == 6
assert torch.equal(torch.cat(parts), t)
def test_split_with_zero():
"""Ensure NaiveCache split handles zero length correctly (used in Bagel)."""
t = torch.randn(17, NUM_KV_HEADS, HEAD_DIM)
parts = NaiveCache.split_with_zeros(t, [10, 0, 7])
assert parts[0].shape[0] == 10
assert parts[1] is None
assert parts[2].shape[0] == 7
assert torch.equal(torch.cat([parts[0], parts[2]]), t)
def test_split_wrong_sum_raises():
"""Ensure NaiveCache raises if splits don't match the sum of dims on axis 0."""
t = torch.randn(10, NUM_KV_HEADS, HEAD_DIM)
with pytest.raises(ValueError, match="dim 0"):
NaiveCache.split_with_zeros(t, [5, 3])
def test_split_negative_length_raises():
"""Ensure NaiveCache raises if splits have any negative values."""
t = torch.randn(10, NUM_KV_HEADS, HEAD_DIM)
with pytest.raises(ValueError, match="greater than or equal to zero"):
NaiveCache.split_with_zeros(t, [5, -1, 6])
def test_split_preserves_dtype():
"""Ensure NaiveCache split preserves dtype."""
t = torch.randn(10, NUM_KV_HEADS, HEAD_DIM, dtype=torch.bfloat16)
parts = NaiveCache.split_with_zeros(t, [4, 6])
assert parts[0].dtype == torch.bfloat16
assert parts[1].dtype == torch.bfloat16
### from_object tests (for kv cache transfer)
def test_from_object_passthrough():
"""Ensure a NaiveCache input is returned as is."""
cache = _make_cache(NUM_LAYERS, seq_len=5)
assert NaiveCache.from_object(cache) is cache
def test_from_object_converts_simple_namespace():
"""Ensure SimpleNamespace with list-based caches converts to NaiveCache."""
keys = [torch.randn(5, NUM_KV_HEADS, HEAD_DIM) for _ in range(NUM_LAYERS)]
values = [torch.randn(5, NUM_KV_HEADS, HEAD_DIM) for _ in range(NUM_LAYERS)]
ns = SimpleNamespace(key_cache=keys, value_cache=values)
cache = NaiveCache.from_object(ns)
assert isinstance(cache, NaiveCache)
assert cache.num_layers == NUM_LAYERS
for i in range(NUM_LAYERS):
assert torch.equal(cache.key_cache[i], keys[i])
assert torch.equal(cache.value_cache[i], values[i])
def test_from_object_mismatched_lengths_raises():
"""Ensure mismatched key/value cache lengths raise due to strict=True in zip."""
keys = [torch.randn(5, NUM_KV_HEADS, HEAD_DIM) for _ in range(2)]
values = [torch.randn(5, NUM_KV_HEADS, HEAD_DIM) for _ in range(3)]
ns = SimpleNamespace(key_cache=keys, value_cache=values)
with pytest.raises(ValueError):
NaiveCache.from_object(ns)
### End to end test for split / merge
def test_round_trip_two_populated():
"""Roundtrip test for merging and resplitting two simple caches."""
c0 = _make_cache(NUM_LAYERS, seq_len=5, seed=0)
c1 = _make_cache(NUM_LAYERS, seq_len=8, seed=1)
merged = NaiveCache.merge([c0, c1])
for layer in range(NUM_LAYERS):
k_parts = NaiveCache.split_with_zeros(merged.key_cache[layer], merged.key_values_lens)
v_parts = NaiveCache.split_with_zeros(merged.value_cache[layer], merged.key_values_lens)
assert torch.equal(k_parts[0], c0.key_cache[layer])
assert torch.equal(k_parts[1], c1.key_cache[layer])
assert torch.equal(v_parts[0], c0.value_cache[layer])
assert torch.equal(v_parts[1], c1.value_cache[layer])
def test_round_trip_three_branches_with_zero_cfg():
"""Roundtrip test with a zero entry (i.e., same as Bagel's gen/text_cfg/img_cfg case)."""
gen_cache = _make_cache(NUM_LAYERS, seq_len=10, seed=0)
text_cfg_cache = _make_cache(NUM_LAYERS, seq_len=0)
img_cfg_cache = _make_cache(NUM_LAYERS, seq_len=7, seed=2)
merged = NaiveCache.merge([gen_cache, text_cfg_cache, img_cfg_cache])
for layer in range(NUM_LAYERS):
k_parts = NaiveCache.split_with_zeros(merged.key_cache[layer], merged.key_values_lens)
v_parts = NaiveCache.split_with_zeros(merged.value_cache[layer], merged.key_values_lens)
assert torch.equal(k_parts[0], gen_cache.key_cache[layer])
assert k_parts[1] is None
assert torch.equal(k_parts[2], img_cfg_cache.key_cache[layer])
assert torch.equal(v_parts[0], gen_cache.value_cache[layer])
assert v_parts[1] is None
assert torch.equal(v_parts[2], img_cfg_cache.value_cache[layer])
@@ -9,6 +9,7 @@ from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import numpy as np
import pytest
import torch
from PIL import Image
@@ -53,8 +54,10 @@ class StubCosmos3VAE:
latents_mean=[0.0] * z_dim,
latents_std=[1.0] * z_dim,
)
self.encode_input_shapes: list[tuple[int, ...]] = []
def encode(self, video: torch.Tensor):
self.encode_input_shapes.append(tuple(video.shape))
latent_frames = (video.shape[2] - 1) // self.config.scale_factor_temporal + 1
latent_height = video.shape[-2] // self.config.scale_factor_spatial
latent_width = video.shape[-1] // self.config.scale_factor_spatial
@@ -131,9 +134,12 @@ class StubCosmos3Transformer(nn.Module):
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
token = int(text_ids.reshape(-1)[0].item()) if text_ids.numel() else 0
sound_latents = kwargs.get("sound_latents")
control_latents = kwargs.get("control_latents")
control_bonus = 100 if control_latents is not None else 0
self.calls.append(
{
"token": token,
"has_control": control_latents is not None,
"timestep": timestep.clone(),
"text_mask": text_mask.clone(),
"cache_before": self.cached_kv,
@@ -145,7 +151,7 @@ class StubCosmos3Transformer(nn.Module):
self.cached_kv = [(marker, marker + 100)]
self.cached_freqs_gen = (marker + 200, marker + 300)
action_latents = kwargs.get("action_latents")
outputs: list[torch.Tensor] = [torch.full_like(hidden_states, float(token))]
outputs: list[torch.Tensor] = [torch.full_like(hidden_states, float(token + control_bonus))]
if action_latents is not None:
outputs.append(torch.full_like(action_latents, float(token + 20)))
if sound_latents is not None:
@@ -190,6 +196,7 @@ def make_cosmos3_pipeline():
pipeline._current_flow_shift = 1.0
pipeline._guidance_scale = None
pipeline._num_timesteps = None
pipeline._cosmos3_branch_caches = None
pipeline._cache_dit_requires_paired_cfg = False
pipeline._sound_tokenizer = None
pipeline.progress_bar = passthrough_progress_bar
@@ -198,6 +205,13 @@ def make_cosmos3_pipeline():
return _make
@pytest.fixture
def sequential_cfg_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.distributed import cfg_parallel
monkeypatch.setattr(cfg_parallel, "get_classifier_free_guidance_world_size", lambda: 1)
def make_sampling_params(**overrides: Any) -> SimpleNamespace:
values = {
"height": None,
@@ -276,12 +290,20 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch):
return self
class _StubDiffusersScheduler:
config = SimpleNamespace(flow_shift=1.0)
from_config_calls: list[dict[str, Any]] = []
def __init__(self, *, flow_shift: float = 1.0) -> None:
self.config = SimpleNamespace(flow_shift=flow_shift)
@classmethod
def from_pretrained(cls, *args, **kwargs):
return cls()
@classmethod
def from_config(cls, config, **kwargs):
cls.from_config_calls.append({"config": config, "kwargs": dict(kwargs)})
return cls(flow_shift=kwargs.get("flow_shift", getattr(config, "flow_shift", 1.0)))
class _StubVideoProcessor:
def __init__(self, *args, **kwargs) -> None:
pass
@@ -291,6 +313,7 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(pipeline_cosmos3, "UniPCMultistepScheduler", _StubDiffusersScheduler)
monkeypatch.setattr(pipeline_cosmos3, "VideoProcessor", _StubVideoProcessor)
monkeypatch.setattr(pipeline_cosmos3, "get_local_device", lambda: torch.device("cpu"))
return _StubDiffusersScheduler
def _make_od_config(*, sound_gen: bool) -> SimpleNamespace:
@@ -332,6 +355,21 @@ def test_pipeline_init_skips_tokenizer_when_sound_disabled(stub_real_pipeline_in
assert not hasattr(pipeline.transformer, "audio_proj_out")
def test_pipeline_init_rebuilds_scheduler_with_cosmos3_defaults(stub_real_pipeline_init) -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
od_config = _make_od_config(sound_gen=False)
od_config.flow_shift = 2.5
pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config)
assert stub_real_pipeline_init.from_config_calls
call = stub_real_pipeline_init.from_config_calls[-1]
assert getattr(call["config"], "flow_shift") == 1.0
assert call["kwargs"] == {"flow_shift": 2.5, "use_karras_sigmas": False}
assert pipeline._engine_init_flow_shift == 2.5
def test_pipeline_init_passes_tokenizer_attrs_into_transformer(
stub_real_pipeline_init,
monkeypatch: pytest.MonkeyPatch,
@@ -395,6 +433,160 @@ def test_preprocess_i2v_image_and_action_video_inputs() -> None:
assert additional["condition_frame_indexes_vision"] == [0, 1]
def test_transfer_config_media_helpers_and_preprocess_budget(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transfer
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import (
Cosmos3OmniDiffusersPipeline,
get_cosmos3_pre_process_func,
)
cfg = transfer.resolve_transfer_config(make_sampling_params(extra_args={"edge": True}))
assert cfg is not None
assert list(cfg.hints) == ["edge"]
assert cfg.guidance_scale == 3.0
assert cfg.control_guidance == 1.5
assert cfg.flow_shift == 10.0
assert cfg.num_video_frames_per_chunk == 93
assert cfg.share_vision_temporal_positions is True
# fps omitted (no fps/frame_rate on the sampling params) -> wsm preset default (10) applies.
defaulted_fps_cfg = transfer.resolve_transfer_config(make_sampling_params(extra_args={"wsm": True}))
assert defaulted_fps_cfg is not None
assert defaulted_fps_cfg.fps == 10
# fps provided (frame_rate set) -> the user value wins over the preset default.
explicit_fps_cfg = transfer.resolve_transfer_config(make_sampling_params(frame_rate=24.0, extra_args={"wsm": True}))
assert explicit_fps_cfg is not None
assert explicit_fps_cfg.fps == 24.0
assert (
Cosmos3OmniDiffusersPipeline.reference_video_decode_spec(extra_args={"edge": True, "max_frames": 4}).max_frames
== 4
)
frames_for_pad = torch.arange(3 * 3, dtype=torch.uint8).reshape(1, 3, 1, 3)
assert transfer.pad_temporal_frames(frames_for_pad, 5)[0, :, 0, 0].tolist() == [0, 3, 6, 6, 3]
real_import_module = transfer.importlib.import_module
def raise_missing_cv2(name: str, *args: Any, **kwargs: Any):
if name == "cv2":
raise ImportError("missing cv2")
return real_import_module(name, *args, **kwargs)
monkeypatch.setattr(transfer.importlib, "import_module", raise_missing_cv2)
with pytest.raises(ImportError, match="opencv-python"):
transfer.load_or_compute_control_frames(
cfg.hints["edge"],
height=8,
width=8,
max_frames=1,
input_frames=torch.zeros(3, 1, 8, 8, dtype=torch.uint8),
)
precomputed = torch.zeros(3, 2, 8, 8, dtype=torch.uint8)
precomputed_cfg = transfer.resolve_transfer_config(
make_sampling_params(extra_args={"edge": {"control": precomputed}})
)
assert precomputed_cfg is not None
loaded = transfer.load_or_compute_control_frames(
precomputed_cfg.hints["edge"],
height=8,
width=8,
max_frames=2,
input_frames=None,
)
assert tuple(loaded.shape) == (3, 2, 8, 8)
preprocess = get_cosmos3_pre_process_func(SimpleNamespace())
class FramesWithFps(list):
fps = 12.5
frames = FramesWithFps(Image.new("RGB", (8, 4), color) for color in ("red", "green", "blue", "yellow", "black"))
request = SimpleNamespace(
prompts=[{"prompt": "transfer", "multi_modal_data": {"video": frames}}],
sampling_params=SimpleNamespace(
height=16,
width=32,
extra_args={"edge": True, "max_frames": 4, "resolution": "256"},
),
)
additional = preprocess(request).prompts[0]["additional_information"]
assert (request.sampling_params.height, request.sampling_params.width) == (192, 320)
assert tuple(additional["preprocessed_transfer_video"].shape) == (1, 3, 4, 192, 320)
assert additional["transfer_input_fps"] == 12.5
assert "preprocessed_video" not in additional
def test_transfer_fps_matches_resolved_frame_rate_precedence() -> None:
"""When fps and frame_rate differ, transfer must pick frame_rate -- the same
precedence as OmniDiffusionSamplingParams.resolved_frame_rate. Uses the real
sampling-params dataclass so the resolved_frame_rate property is exercised."""
from vllm_omni.diffusion.models.cosmos3 import transfer
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
sp = OmniDiffusionSamplingParams(fps=24, frame_rate=12.0, extra_args={"edge": True})
assert sp.resolved_frame_rate == 12.0
cfg = transfer.resolve_transfer_config(sp)
assert cfg is not None
# edge has no preset fps default, so cfg.fps comes straight from fps resolution.
assert cfg.fps == sp.resolved_frame_rate == 12.0
def test_transfer_edge_uses_rgb_canny(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transfer
class FakeCv2:
def __init__(self) -> None:
self.canny_inputs: list[np.ndarray] = []
def Canny(self, image, lower, upper):
assert (lower, upper) == (100, 200)
self.canny_inputs.append(image.copy())
return np.zeros(image.shape[:2], dtype=np.uint8)
fake_cv2 = FakeCv2()
monkeypatch.setattr(transfer, "_import_cv2", lambda _hint_key: fake_cv2)
frames = torch.zeros(3, 1, 4, 5, dtype=torch.uint8)
frames[0] = 255
edge = transfer.make_edge_control(frames, "medium")
assert tuple(edge.shape) == (3, 1, 4, 5)
assert len(fake_cv2.canny_inputs) == 1
assert fake_cv2.canny_inputs[0].shape == (4, 5, 3)
def test_transfer_blur_uses_scaled_bilateral(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transfer
class FakeCv2:
INTER_AREA = 1
INTER_LINEAR = 2
INTER_CUBIC = 3
def __init__(self) -> None:
self.bilateral_calls: list[tuple[tuple[int, int, int], int, float, float]] = []
def resize(self, image, size, interpolation):
del interpolation
width, height = size
return np.zeros((height, width, image.shape[2]), dtype=image.dtype)
def bilateralFilter(self, image, diameter, sigma_color, sigma_space):
self.bilateral_calls.append((image.shape, diameter, sigma_color, sigma_space))
return image
def GaussianBlur(self, *args, **kwargs):
raise AssertionError("Cosmos3 transfer blur should use bilateralFilter, not GaussianBlur.")
fake_cv2 = FakeCv2()
monkeypatch.setattr(transfer, "_import_cv2", lambda _hint_key: fake_cv2)
frames = torch.zeros(3, 1, 72, 72, dtype=torch.uint8)
blurred = transfer.make_blur_control(frames, "high")
assert tuple(blurred.shape) == (3, 1, 72, 72)
assert fake_cv2.bilateral_calls == [((72, 72, 3), 3, 15.0, 10.0)]
def test_postprocess_handles_image_video_audio_and_validation() -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_post_process_func
@@ -543,7 +735,7 @@ def test_prepare_latents_for_video_image_sound_and_action(make_cosmos3_pipeline)
latents = pipeline._prepare_latents(16, 24, 5, torch.Generator(device="cpu").manual_seed(0))
assert latents.shape == (1, 2, 2, 2, 3)
pipeline._encode_conditioning_video = lambda *args, **kwargs: torch.full((1, 2, 2, 2, 3), 5.0)
pipeline._encode_conditioning_image_latent = lambda *args, **kwargs: torch.full((1, 2, 1, 2, 3), 5.0)
i2v_latents, velocity_mask, image_latent = pipeline._prepare_latents_i2v(
torch.zeros(1, 3, 16, 24), 16, 24, 5, torch.Generator(device="cpu").manual_seed(0)
)
@@ -593,6 +785,44 @@ def test_prepare_latents_for_video_image_sound_and_action(make_cosmos3_pipeline)
torch.testing.assert_close(action, clean)
def test_prepare_latents_i2v_encodes_only_conditioning_frame(make_cosmos3_pipeline) -> None:
pipeline = make_cosmos3_pipeline()
calls: list[tuple[str, tuple[int, ...]]] = []
def record_to_vae_device(tensor: torch.Tensor, *, pin_cpu: bool = False) -> torch.Tensor:
assert pin_cpu is True
calls.append(("to_vae_device", tuple(tensor.shape)))
return tensor
class RecordingVAE(StubCosmos3VAE):
def encode(self, video: torch.Tensor):
calls.append(("encode", tuple(video.shape)))
return super().encode(video)
pipeline._to_vae_device = record_to_vae_device
pipeline.vae = RecordingVAE(z_dim=2)
generator = torch.Generator(device="cpu").manual_seed(0)
latents, velocity_mask, image_latent = pipeline._prepare_latents_i2v(
torch.zeros(1, 3, 16, 24),
16,
24,
9,
generator,
)
assert calls == [
("to_vae_device", (1, 3, 16, 24)),
("encode", (1, 3, 1, 16, 24)),
]
assert pipeline.vae.encode_input_shapes[-1] == (1, 3, 1, 16, 24)
assert latents.shape == (1, 2, 3, 2, 3)
assert image_latent.shape == (1, 2, 1, 2, 3)
torch.testing.assert_close(latents[:, :, 0:1], image_latent)
assert not torch.allclose(latents[:, :, 1:], image_latent.expand(-1, -1, 2, -1, -1))
assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]], [[1.0]]]]]
def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> None:
pipeline = make_cosmos3_pipeline()
latents = torch.zeros(1, 2, 1, 1, 1)
@@ -624,6 +854,9 @@ def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> N
image_latent=torch.full((1, 2, 1, 1, 1), 7.0),
)
torch.testing.assert_close(i2v[:, :, 0:1], torch.full((1, 2, 1, 1, 1), 7.0))
i2v_noise = pipeline.scheduler.step_calls[-1][0]
torch.testing.assert_close(i2v_noise[:, :, 0:1], torch.zeros(1, 2, 1, 1, 1))
torch.testing.assert_close(i2v_noise[:, :, 1:2], torch.full((1, 2, 1, 1, 1), 2.0))
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, action_gen=True, action_dim=4)
video_result, action_result = pipeline.diffuse(
@@ -643,6 +876,267 @@ def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> N
torch.testing.assert_close(action_result, torch.full((), 44.0).expand_as(action_result))
def test_diffuse_transfer_applies_control_cfg(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
pipeline = make_cosmos3_pipeline()
latents = torch.zeros(1, 2, 1, 1, 1)
velocity_mask = torch.ones(1, 1, 1, 1, 1)
result = pipeline.diffuse_transfer(
latents=latents,
timesteps=torch.tensor([7]),
cond_ids=_ids(2),
cond_mask=_mask(),
uncond_ids=_ids(1),
uncond_mask=_mask(),
guidance_scale=3.0,
control_guidance=1.5,
control_guidance_interval=None,
control_latents=[torch.zeros_like(latents)],
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
velocity_mask=velocity_mask,
condition_latents=torch.zeros_like(latents),
)
assert [(call["token"], call["has_control"]) for call in pipeline.transformer.calls] == [
(2, True),
(2, False),
(1, True),
]
torch.testing.assert_close(result, torch.full_like(latents, 254.0))
def test_diffuse_transfer_skips_idle_cfg_branches(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
latents = torch.zeros(1, 2, 1, 1, 1)
velocity_mask = torch.ones(1, 1, 1, 1, 1)
control_only = make_cosmos3_pipeline()
control_result = control_only.diffuse_transfer(
latents=latents,
timesteps=torch.tensor([7]),
cond_ids=_ids(2),
cond_mask=_mask(),
uncond_ids=_ids(1),
uncond_mask=_mask(),
guidance_scale=1.0,
control_guidance=1.5,
control_guidance_interval=None,
control_latents=[torch.zeros_like(latents)],
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
velocity_mask=velocity_mask,
condition_latents=torch.zeros_like(latents),
)
assert [(call["token"], call["has_control"]) for call in control_only.transformer.calls] == [
(2, True),
(2, False),
]
torch.testing.assert_close(control_result, torch.full_like(latents, 152.0))
text_only = make_cosmos3_pipeline()
text_result = text_only.diffuse_transfer(
latents=latents,
timesteps=torch.tensor([7]),
cond_ids=_ids(2),
cond_mask=_mask(),
uncond_ids=_ids(1),
uncond_mask=_mask(),
guidance_scale=3.0,
control_guidance=1.0,
control_guidance_interval=None,
control_latents=[torch.zeros_like(latents)],
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
velocity_mask=velocity_mask,
condition_latents=torch.zeros_like(latents),
)
assert [(call["token"], call["has_control"]) for call in text_only.transformer.calls] == [
(2, True),
(1, True),
]
torch.testing.assert_close(text_result, torch.full_like(latents, 104.0))
def test_diffuse_transfer_interval_switches_branch_counts(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
pipeline = make_cosmos3_pipeline()
latents = torch.zeros(1, 2, 1, 1, 1)
velocity_mask = torch.ones(1, 1, 1, 1, 1)
result = pipeline.diffuse_transfer(
latents=latents,
timesteps=torch.tensor([900, 500, 100]),
cond_ids=_ids(2),
cond_mask=_mask(),
uncond_ids=_ids(1),
uncond_mask=_mask(),
guidance_scale=3.0,
control_guidance=1.5,
control_guidance_interval=(400.0, 1000.0),
control_latents=[torch.zeros_like(latents)],
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
velocity_mask=velocity_mask,
condition_latents=torch.zeros_like(latents),
guidance_interval=(800.0, 1000.0),
)
assert [(call["token"], call["has_control"]) for call in pipeline.transformer.calls] == [
(2, True),
(2, False),
(1, True),
(2, True),
(2, False),
(2, True),
]
torch.testing.assert_close(result, torch.full_like(latents, 508.0))
@pytest.mark.parametrize(("hint_key", "expected_fps"), [("edge", 8.0), ("wsm", 10.0)])
def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint_key: str, expected_fps: float) -> None:
pipeline = make_cosmos3_pipeline()
captured: dict[str, Any] = {}
def fake_format(prompt, negative_prompt, num_frames, frame_rate, height, width, *args, **kwargs):
del prompt, negative_prompt, num_frames, height, width, args, kwargs
captured["format_frame_rate"] = frame_rate
return _ids(2), _mask(), _ids(1), _mask()
def fake_encode(video: torch.Tensor) -> torch.Tensor:
latent_frames = (video.shape[2] - 1) // pipeline.vae_scale_factor_temporal + 1
return torch.ones(
1,
2,
latent_frames,
max(1, video.shape[-2] // pipeline.vae_scale_factor_spatial),
max(1, video.shape[-1] // pipeline.vae_scale_factor_spatial),
)
def fake_prepare(target_norm, current_conditional_frames, generator):
del current_conditional_frames, generator
latent = fake_encode(target_norm)
velocity_mask = torch.ones(1, 1, latent.shape[2], 1, 1)
return torch.zeros_like(latent), velocity_mask, torch.zeros_like(latent)
def fake_diffuse_transfer(**kwargs):
captured["shared_kwargs"] = kwargs["shared_kwargs"]
return kwargs["latents"]
pipeline._transfer_bucket_size = lambda sp, source_hw: (16, 16)
pipeline._format_and_tokenize_prompts = fake_format
pipeline._encode_video_tensor = fake_encode
pipeline._prepare_transfer_latents = fake_prepare
pipeline.diffuse_transfer = fake_diffuse_transfer
pipeline._set_flow_shift = lambda target: captured.setdefault("flow_shifts", []).append(target)
pipeline._decode_latents = lambda latents: torch.zeros(1, 3, 5, 16, 16, device="meta")
control = torch.zeros(3, 5, 16, 16, dtype=torch.uint8)
request = SimpleNamespace(
prompts=[
{
"prompt": "transfer",
"modalities": ["video"],
"additional_information": {
"preprocessed_transfer_video": torch.zeros(1, 3, 5, 16, 16),
"transfer_input_fps": 8.0,
},
}
],
sampling_params=make_sampling_params(
height=16,
width=16,
# fps omitted (no frame_rate) -> non-wsm uses the source video fps (8), wsm uses its preset (10).
extra_args={
hint_key: {"control": control},
"max_frames": 5,
"num_video_frames_per_chunk": 5,
"show_control_condition": True,
},
),
)
output = pipeline.forward(request)
assert captured["format_frame_rate"] == expected_fps
assert captured["shared_kwargs"]["fps"] == expected_fps
assert captured["flow_shifts"] == [10.0]
assert output.custom_output["fps"] == expected_fps
assert output.output["video"].device.type == "meta"
def test_forward_transfer_runs_multichunk_overlap_path(
make_cosmos3_pipeline,
sequential_cfg_parallel,
) -> None:
pipeline = make_cosmos3_pipeline()
captured: dict[str, Any] = {"targets": [], "conditional_frames": []}
pipeline._transfer_bucket_size = lambda sp, source_hw: (16, 16)
pipeline._format_and_tokenize_prompts = lambda *args, **kwargs: (_ids(2), _mask(), _ids(1), _mask())
pipeline._set_flow_shift = lambda target: captured.setdefault("flow_shifts", []).append(target)
original_prepare = pipeline._prepare_transfer_latents
def recording_prepare(target_norm, current_conditional_frames, generator):
captured["targets"].append(target_norm.detach().clone())
captured["conditional_frames"].append(current_conditional_frames)
return original_prepare(target_norm, current_conditional_frames, generator)
pipeline._prepare_transfer_latents = recording_prepare
decoded_chunks = [
torch.tensor([-0.6, -0.5, -0.4, -0.3, -0.2], dtype=torch.float32),
torch.tensor([0.1, 0.2, 0.3, 0.4, 0.5], dtype=torch.float32),
]
def fake_decode(latents):
chunk_values = decoded_chunks[len(captured.setdefault("decode_calls", []))]
captured["decode_calls"].append(latents.detach().clone())
return chunk_values.view(1, 1, 5, 1, 1).expand(1, 3, 5, 16, 16).clone()
pipeline._decode_latents = fake_decode
transfer_video = torch.zeros(1, 3, 8, 16, 16)
transfer_video[:, :, 1] = 1.0
control = torch.zeros(3, 8, 16, 16, dtype=torch.uint8)
request = SimpleNamespace(
prompts=[
{
"prompt": "transfer",
"modalities": ["video"],
"additional_information": {
"preprocessed_transfer_video": transfer_video,
"transfer_input_fps": 8.0,
},
}
],
sampling_params=make_sampling_params(
height=16,
width=16,
num_inference_steps=1,
guidance_scale=1.0,
extra_args={
"edge": {"control": control},
"control_guidance": 1.0,
"max_frames": 8,
"num_video_frames_per_chunk": 5,
"num_conditional_frames": 1,
"num_first_chunk_conditional_frames": 2,
},
),
)
output = pipeline.forward(request)
assert captured["conditional_frames"] == [2, 1]
assert len(captured["decode_calls"]) == 2
assert output.output["video"].shape == (1, 3, 8, 16, 16)
torch.testing.assert_close(
output.output["video"][0, 0, :, 0, 0],
torch.tensor([-0.6, -0.5, -0.4, -0.3, -0.2, 0.2, 0.3, 0.4]),
)
assert output.custom_output["transfer_controls"]["edge"].shape == (1, 3, 8, 16, 16)
torch.testing.assert_close(captured["targets"][0][:, :, 0], torch.full((1, 3, 16, 16), -1.0))
torch.testing.assert_close(captured["targets"][0][:, :, 1], torch.full((1, 3, 16, 16), 1.0))
torch.testing.assert_close(captured["targets"][0][:, :, 2:], torch.full((1, 3, 3, 16, 16), 1.0))
torch.testing.assert_close(captured["targets"][1][:, :, 0], torch.full((1, 3, 16, 16), -0.2))
def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -> None:
"""With cache-dit active the uncond pass is kept even outside the guidance
interval (so cache-dit's has_separate_cfg parity stays in phase), and the
@@ -680,7 +1174,18 @@ class TestForwardRouting:
def _install_forward_stubs(self, pipeline):
captured: dict[str, object] = {"diffuse_calls": [], "prepare_calls": []}
def fake_format(prompt, negative_prompt, num_frames, frame_rate, height, width, *args, **kwargs):
def fake_format(
prompt,
negative_prompt,
num_frames,
frame_rate,
height,
width,
max_sequence_length,
sp,
use_system_prompt=False,
is_t2i=False,
):
captured["format"] = {
"prompt": prompt,
"negative_prompt": negative_prompt,
@@ -688,7 +1193,8 @@ class TestForwardRouting:
"frame_rate": frame_rate,
"height": height,
"width": width,
"is_t2i": kwargs["is_t2i"],
"use_system_prompt": use_system_prompt,
"is_t2i": is_t2i,
}
return _ids(2), _mask(), _ids(1), _mask()
@@ -718,12 +1224,26 @@ class TestForwardRouting:
(
{"prompt": "A painted robot", "modalities": ["image"]},
make_sampling_params(num_outputs_per_prompt=2),
{"key": "image", "is_t2i": True, "flow": [3.0], "steps": [50, 50], "frames": 1},
{
"key": "image",
"is_t2i": True,
"use_system_prompt": True,
"flow": [3.0],
"steps": [50, 50],
"frames": 1,
},
),
(
"A warehouse robot",
make_sampling_params(),
{"key": "video", "is_t2i": False, "flow": [1.0], "steps": [35], "frames": 189},
{
"key": "video",
"is_t2i": False,
"use_system_prompt": True,
"flow": [1.0],
"steps": [35],
"frames": 189,
},
),
],
)
@@ -741,6 +1261,7 @@ class TestForwardRouting:
assert expected["key"] in output.output
assert captured["format"]["is_t2i"] is expected["is_t2i"]
assert captured["format"]["use_system_prompt"] is expected["use_system_prompt"]
assert captured["format"]["num_frames"] == expected["frames"]
assert captured["flow_shifts"] == expected["flow"]
assert [call[0] for call in pipeline.scheduler.set_timesteps_calls] == expected["steps"]
@@ -769,6 +1290,7 @@ class TestForwardRouting:
)
)
assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is velocity_mask
assert captured["format"]["use_system_prompt"] is True
video_tensor = torch.zeros(1, 3, 5, 16, 16)
v2v_condition = torch.full((1, 2, 2, 1, 1), 4.0)
@@ -795,6 +1317,7 @@ class TestForwardRouting:
)
assert captured["flow_shifts"][-1] == 10.0
assert captured["format"]["negative_prompt"] == ""
assert captured["format"]["use_system_prompt"] is True
assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is v2v_mask
assert captured["diffuse_calls"][-1]["condition_latents"] is v2v_condition
@@ -810,6 +1333,7 @@ class TestForwardRouting:
)
)
assert captured["diffuse_calls"][-1]["sound_latents"] is sound_latents
assert captured["format"]["use_system_prompt"] is True
assert output.output["audio_sample_rate"] == 10
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, action_gen=True, action_dim=4)
@@ -835,6 +1359,7 @@ class TestForwardRouting:
)
)
assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7]
assert captured["format"]["use_system_prompt"] is False
assert output.custom_output["action"].shape == (1, 2, 2)
assert "action_only_output" not in output.custom_output
@@ -910,6 +1435,7 @@ class TestForwardRouting:
"frame_rate": 15.0,
"height": 16,
"width": 16,
"use_system_prompt": False,
"is_t2i": False,
}
assert "flow_shifts" not in captured
@@ -936,6 +1462,26 @@ class TestForwardRouting:
make_sampling_params(),
"only for video",
),
(
[{"prompt": "x", "modalities": ["image"]}],
make_sampling_params(extra_args={"edge": {"control_path": "/tmp/control.mp4"}}),
"transfer inference is supported only for video outputs",
),
(
[{"prompt": "x", "modalities": ["video"], "generate_sound": True}],
make_sampling_params(extra_args={"edge": {"control_path": "/tmp/control.mp4"}}),
"cannot be combined with sound generation",
),
(
[{"prompt": "x", "modalities": ["video"]}],
make_sampling_params(
extra_args={
"edge": {"control_path": "/tmp/control.mp4"},
"action_mode": "policy",
}
),
"cannot be combined with action generation",
),
],
)
def test_forward_rejects_invalid_public_requests(
@@ -131,6 +131,38 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No
assert tuple(output.shape) == (1, 2, 1, 2, 2)
def test_forward_accepts_transfer_control_latents(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
model = transformer_cosmos3.Cosmos3VFMTransformer(
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
)
hidden_states = torch.zeros(1, 2, 1, 2, 2)
output = model(
hidden_states=hidden_states,
timestep=torch.tensor([1.0]),
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
text_mask=torch.ones(1, 2, dtype=torch.long),
video_shape=(1, 2, 2),
fps=24.0,
control_latents=[torch.ones_like(hidden_states), torch.full_like(hidden_states, 2.0)],
)
assert tuple(output.shape) == tuple(hidden_states.shape)
with pytest.raises(ValueError, match="control latent shape"):
model(
hidden_states=hidden_states,
timestep=torch.tensor([1.0]),
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
text_mask=torch.ones(1, 2, dtype=torch.long),
video_shape=(1, 2, 2),
fps=24.0,
control_latents=[torch.zeros(1, 2, 2, 2, 2)],
)
def test_forward_gathers_gen_tokens_before_unpatchify(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
@@ -354,3 +386,33 @@ def test_compute_rope_freqs_places_text_video_action_and_sound_positions() -> No
_, gen_pos = rotary.position_ids
assert gen_pos.shape == (3, 1, 5)
assert gen_pos[0, 0].tolist() == [102, 103, 103, 104, 102]
rotary.position_ids.clear()
model._compute_rope_freqs(
text_mask=torch.tensor([[1, 1]], dtype=torch.long),
t=2,
hp=1,
wp=1,
fps=24.0,
device=torch.device("cpu"),
dtype=torch.float32,
num_vision_items=3,
share_vision_temporal_positions=True,
)
_, shared_gen_pos = rotary.position_ids
assert shared_gen_pos[0, 0].tolist() == [102, 103, 102, 103, 102, 103]
rotary.position_ids.clear()
model._compute_rope_freqs(
text_mask=torch.tensor([[1, 1]], dtype=torch.long),
t=2,
hp=1,
wp=1,
fps=24.0,
device=torch.device("cpu"),
dtype=torch.float32,
num_vision_items=3,
share_vision_temporal_positions=False,
)
_, offset_gen_pos = rotary.position_ids
assert offset_gen_pos[0, 0].tolist() == [102, 103, 104, 105, 106, 107]
@@ -466,11 +466,18 @@ class TestCFGParallelForwardPath:
"""Test the LTX-2.3 CFG-parallel denoising path without loading model weights."""
@pytest.mark.parametrize(("cfg_rank", "expected_prompt_value"), [(0, 1.0), (1, 0.0)])
@pytest.mark.parametrize(
("frame_rate_input", "audio_sampling_rate", "expected_frame_rate"),
[(1.0, 1, 1.0), (None, 24, 24.0)],
)
def test_forward_cfg_parallel_steps_video_and_audio_scheduler(
self,
monkeypatch,
cfg_rank,
expected_prompt_value,
frame_rate_input,
audio_sampling_rate,
expected_frame_rate,
):
from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -484,7 +491,7 @@ class TestCFGParallelForwardPath:
pipe.vae_temporal_compression_ratio = 1
pipe.transformer_spatial_patch_size = 1
pipe.transformer_temporal_patch_size = 1
pipe.audio_sampling_rate = 1
pipe.audio_sampling_rate = audio_sampling_rate
pipe.audio_hop_length = 1
pipe.audio_vae_temporal_compression_ratio = 1
pipe.audio_vae_mel_compression_ratio = 1
@@ -553,8 +560,11 @@ class TestCFGParallelForwardPath:
assert prompt_embeds.shape[0] == 2
return prompt_embeds, prompt_embeds, prompt_attention_mask
rope_video_fps: list[float] = []
class FakeRope:
def prepare_video_coords(self, batch_size, num_frames, height, width, device, fps):
rope_video_fps.append(fps)
return torch.zeros(batch_size, num_frames * height * width, 3, device=device)
def prepare_audio_coords(self, batch_size, num_frames, device):
@@ -611,7 +621,7 @@ class TestCFGParallelForwardPath:
height=32,
width=32,
num_frames=1,
frame_rate=1.0,
frame_rate=frame_rate_input,
num_inference_steps=2,
guidance_scale=4.0,
latents=video_latents,
@@ -651,6 +661,11 @@ class TestCFGParallelForwardPath:
torch.testing.assert_close(video_out, (video_latents - 2 * expected_video_noise).reshape(1, 2, 1, 1, 1))
torch.testing.assert_close(audio_out, (audio_latents - 2 * expected_audio_noise).reshape(1, 1, 1, 2))
# fps regression guard: an omitted request fps (frame_rate_input=None) must resolve
# to the model's own 24.0 default, not crash on None; a provided rate is passed through.
assert rope_video_fps
assert all(fps == expected_frame_rate for fps in rope_video_fps)
class TestRegistryIntegration:
"""Verify all LTX-2.3 pipeline variants are registered."""
@@ -730,6 +730,39 @@ class TestStageDiffusionClientErrorPropagation:
batch_size=1,
)
@pytest.mark.asyncio
async def test_collective_rpc_async_returns_none_result(self, monkeypatch):
client = self._make_client()
client._owns_process = False
client._proc = None
client._encoder.encode.return_value = b"encoded-rpc"
async def _unexpected_poll(*_, **__):
raise AssertionError("collective_rpc_async should not keep polling after a None rpc_result arrives")
client._response_poller = SimpleNamespace(poll=_unexpected_poll)
rpc_id = "rpc-none"
monkeypatch.setattr(
"vllm_omni.diffusion.stage_diffusion_client.uuid.uuid4",
lambda: SimpleNamespace(hex=rpc_id),
)
def _drain() -> None:
client._rpc_results[rpc_id] = None
client._drain_responses = _drain
result = await client.collective_rpc_async(
method="profile",
timeout=0.01,
args=(False, None),
)
assert result is None
client._request_socket.send.assert_called_once_with(b"encoded-rpc")
assert rpc_id not in client._pending_rpcs
# ───────── monitor thread & death sentinel integration tests ─────────
View File
Whitespace-only changes.
+75
View File
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for kv utils."""
import pytest
import torch
from vllm_omni.diffusion.utils.kv_utils import left_pad_stack
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_uniform_lengths():
"""Ensure uniform 3D tensors correctly stack to 4D and have no mask."""
tensors = [torch.randn(10, 4, 128) for _ in range(3)]
stacked, mask = left_pad_stack(tensors)
assert stacked.shape == (3, 10, 4, 128)
assert mask is None
for i in range(3):
assert torch.equal(stacked[i], tensors[i])
def test_variable_lengths():
"""Ensure variable 3D tensors correctly stack to 4D with a mask."""
t1 = torch.ones(5, 2, 4)
t2 = torch.ones(8, 2, 4)
t3 = torch.ones(3, 2, 4)
stacked, mask = left_pad_stack([t1, t2, t3])
assert stacked.shape == (3, 8, 2, 4)
assert mask is not None
assert mask.shape == (3, 8)
# Ensure summing over dim 1 gives our seq lens back
assert mask.sum(dim=1).tolist() == [5, 8, 3]
def test_single_tensor_is_4d():
"""Ensure a single 3D tensor is expanded to 4D."""
t = torch.randn(7, 4, 128)
stacked, mask = left_pad_stack([t])
assert stacked.shape == (1, 7, 4, 128)
assert mask is None
assert torch.equal(stacked[0], t)
def test_preserves_device_and_dtype():
"""Ensure device/dtype is preserved."""
t1 = torch.randn(3, 2, dtype=torch.bfloat16)
t2 = torch.randn(5, 2, dtype=torch.bfloat16)
stacked, mask = left_pad_stack([t1, t2])
assert stacked.dtype == torch.bfloat16
assert stacked.device == t1.device
assert mask.device == t1.device
def test_mismatched_trailing_shapes_raises():
"""Ensure that mismatched dims outside of 0 explodes."""
t1 = torch.randn(5, 4, 128)
t2 = torch.randn(5, 8, 128)
with pytest.raises(ValueError):
left_pad_stack([t1, t2])
def test_mismatched_ndim_raises():
"""Ensure that mismatched ndims explodes."""
t1 = torch.randn(5, 4)
t2 = torch.randn(5, 4, 128)
with pytest.raises(ValueError):
left_pad_stack([t1, t2])
def test_empty_list_raises():
"""Ensure that tensors must be nonempty."""
with pytest.raises(ValueError):
left_pad_stack([])
@@ -7,7 +7,7 @@ End-to-end tests for Bagel with shared memory connector: img2img and text2img.
- img2img: validates output vs reference pixels within a ±10 tolerance.
- text2img: validates output vs reference pixels within a ±5 tolerance
(equivalent to `examples/offline_inference/bagel/end2end.py` with
text2img modality and 15 steps).
text2img modality and 14 steps).
"""
import os
@@ -46,7 +46,6 @@ REFERENCE_PIXELS = [
{"position": (256, 256), "rgb": (181, 201, 221)},
]
# text2img reference pixels (aligned with offline `bagel/end2end.py` text2img, 15 steps)
# "Generated with seed=52, num_inference_steps=15,
# prompt='A cute cat'"
@@ -591,6 +591,24 @@ def test_build_engine_args_cli_tokenizer_overrides_inferred_base_tokenizer(tmp_p
assert engine_args["tokenizer"] == "/external/tokenizer"
def test_build_engine_args_stage_model_overrides_parent_model():
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
stage_cfg = types.SimpleNamespace(
stage_id=0,
stage_type="llm",
engine_args={"model": "/stage/model"},
default_sampling_params={},
)
engine_args = build_engine_args_dict(
stage_cfg,
"/parent/model",
)
assert engine_args["model"] == "/stage/model"
def test_build_engine_args_keeps_stage_owned_tokenizer_subdir(tmp_path):
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
+20 -1
View File
@@ -24,7 +24,12 @@ from vllm_omni.engine.messages import (
ShutdownRequestMessage,
StageSubmissionMessage,
)
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
_build_terminal_empty_output,
_infer_stage_audio_sample_rate,
)
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -135,6 +140,20 @@ class FakeStageClient:
self._diffusion_outputs.put_nowait(output)
def test_terminal_empty_audio_output_uses_stage_sample_rate() -> None:
final_stage = FakeStageClient(final_output=True, final_output_type="audio")
final_stage.sample_rate = 44100
final_pool = SimpleNamespace(stage_client=final_stage, _stage_vllm_config=None)
terminal_output = _build_terminal_empty_output(
"req-1",
final_output_type="audio",
audio_sample_rate=_infer_stage_audio_sample_rate(final_pool),
)
assert terminal_output.outputs[0].multimodal_output["sr"] == 44100
class FakeCollectiveRpcStageClient(FakeStageClient):
def __init__(self, *args, rpc_result: Any = None, **kwargs) -> None:
super().__init__(*args, **kwargs)
@@ -0,0 +1,156 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import queue
from types import SimpleNamespace
from typing import Any
import janus
import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.stage_pool import StagePool
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeStageClient:
def __init__(
self,
*,
next_inputs: list[dict[str, Any]] | None = None,
final_output: bool = False,
) -> None:
self.stage_id = 0
self.replica_id = 0
self.stage_type = "llm"
self.final_output = final_output
self.final_output_type = "text"
self.default_sampling_params = SamplingParams(max_tokens=1)
self.requires_multimodal_data = False
self.engine_input_source = [0]
self.is_comprehension = False
self.model_stage = None
self.custom_process_input_func = None
self.next_inputs = list(next_inputs or [])
self.add_request_calls: list[tuple[Any, ...]] = []
self._engine_core_outputs = queue.Queue()
async def add_request_async(self, *args, **_kwargs) -> None:
self.add_request_calls.append(args)
async def get_output_async(self):
try:
return self._engine_core_outputs.get_nowait()
except queue.Empty:
return SimpleNamespace(outputs=[])
def process_engine_inputs(self, _source_outputs, prompt=None, streaming_context=None):
return list(self.next_inputs)
async def abort_requests_async(self, _request_ids: list[str]) -> None:
return None
def set_engine_outputs(self, _outputs) -> None:
return None
def check_health(self) -> None:
return None
def shutdown(self) -> None:
return None
class FakeOutputProcessor:
def add_request(self, *args, **kwargs) -> None:
return None
class FakeInputProcessor:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def process_inputs(self, **kwargs):
self.calls.append(kwargs)
return SimpleNamespace(
request_id=kwargs["request_id"],
prompt_token_ids=[101, 102],
prompt_embeds=None,
external_req_id=None,
)
def _request_output(request_id: str) -> RequestOutput:
completion = CompletionOutput(
index=0,
text="transcript",
token_ids=[11, 12],
cumulative_logprob=None,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)
return RequestOutput(
request_id=request_id,
prompt="prompt",
prompt_token_ids=[1, 2],
prompt_logprobs=None,
outputs=[completion],
finished=True,
metrics=None,
lora_request=None,
)
@pytest.mark.asyncio
async def test_forward_text_prompt_uses_target_stage_input_processor() -> None:
stage0 = FakeStageClient(final_output=True)
stage1 = FakeStageClient(
final_output=True,
next_inputs=[{"prompt": "hello", "multi_modal_data": {"video": ["frame"]}}],
)
stage_pools = [
StagePool(
0,
[stage0],
output_processor=FakeOutputProcessor(),
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
),
StagePool(
1,
[stage1],
output_processor=FakeOutputProcessor(),
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
),
]
request_q = janus.Queue()
output_q = janus.Queue()
rpc_q = janus.Queue()
orchestrator = Orchestrator(
request_async_queue=request_q.async_q,
output_async_queue=output_q.async_q,
rpc_async_queue=rpc_q.async_q,
stage_pools=stage_pools,
async_chunk=False,
)
input_processor = FakeInputProcessor()
orchestrator._stage_input_processors[1] = input_processor
req_state = OrchestratorRequestState(
request_id="req-text",
prompt={"prompt": "original"},
sampling_params_list=[SamplingParams(max_tokens=1), SamplingParams(max_tokens=1)],
final_stage_id=1,
)
await orchestrator._forward_to_next_stage("req-text", 0, _request_output("req-text"), req_state)
assert input_processor.calls
assert input_processor.calls[0]["prompt"] == {"prompt": "hello", "multi_modal_data": {"video": ["frame"]}}
assert stage1.add_request_calls
submitted_request = stage1.add_request_calls[0][0]
assert submitted_request.prompt_token_ids == [101, 102]
assert submitted_request.external_req_id == "req-text"
@@ -1,8 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for multistage diffusion generation input construction."""
"""Regression tests for multistage generation input construction."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
@@ -93,6 +94,79 @@ def test_build_multistage_generation_inputs_applies_stage_specific_overrides(ser
assert engine.default_sampling_params_list[2].lora_request is None
def test_prepare_multistage_multimodal_inputs_defers_downstream_modalities(serving_chat):
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
image_payload = object()
serving_chat.engine_client = SimpleNamespace(
stage_configs=[
SimpleNamespace(model_stage="asr", requires_multimodal_data=True),
SimpleNamespace(model_stage="aura", requires_multimodal_data=True),
]
)
serving_chat.model_config = SimpleNamespace(
allowed_local_media_path="",
allowed_media_domains=None,
)
request = SimpleNamespace(media_io_kwargs=None)
messages = [
{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": "data:audio/wav;base64,abc"}},
{"type": "image_pil", "image_pil": image_payload},
{"type": "text", "text": "What changed?"},
],
}
]
stripped_messages, deferred_data = asyncio.run(
OmniOpenAIServingChat._prepare_multistage_multimodal_inputs(
serving_chat,
messages,
request,
)
)
assert deferred_data == {"image": [image_payload]}
stripped_content = stripped_messages[0]["content"]
assert {"type": "image_pil", "image_pil": image_payload} not in stripped_content
assert {"type": "text", "text": "What changed?"} in stripped_content
def test_needs_multistage_multimodal_split_noops_for_single_stage(serving_chat):
serving_chat.engine_client = SimpleNamespace(
stage_configs=[
SimpleNamespace(model_stage="thinker", requires_multimodal_data=True),
]
)
assert serving_chat._needs_multistage_multimodal_split() is False
def test_needs_multistage_multimodal_split_noops_for_qwen3_omni(serving_chat):
serving_chat.engine_client = SimpleNamespace(
stage_configs=[
SimpleNamespace(model_stage="thinker", requires_multimodal_data=True),
SimpleNamespace(model_stage="talker", requires_multimodal_data=False),
SimpleNamespace(model_stage="code2wav", requires_multimodal_data=False),
]
)
assert serving_chat._needs_multistage_multimodal_split() is False
def test_needs_multistage_multimodal_split_detects_aura_handoff(serving_chat):
serving_chat.engine_client = SimpleNamespace(
stage_configs=[
SimpleNamespace(model_stage="asr", requires_multimodal_data=True),
SimpleNamespace(model_stage="aura", requires_multimodal_data=True),
]
)
assert serving_chat._needs_multistage_multimodal_split() is True
def test_build_multistage_generation_inputs_multi_image_emits_n_img_placeholders(serving_chat):
"""N reference images with bot_task set must emit N <img> placeholders.
@@ -6,6 +6,8 @@ Tests that standard OpenAI API parameters (max_tokens, temperature, etc.)
are correctly applied to the comprehension stage while preserving YAML defaults.
"""
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from vllm.sampling_params import SamplingParams
@@ -487,6 +489,35 @@ def test_build_sampling_params_list_empty_stop_preserves_yaml(serving_chat, mock
assert comprehension_params.stop_token_ids == []
def test_to_sampling_params_list_pads_missing_tail_stage_with_defaults():
"""AURA callers may pass 3 semantic model params for a 4-stage engine pipeline."""
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
instance = object.__new__(OmniOpenAIServingChat)
default_params = [
SamplingParams(max_tokens=10),
SamplingParams(max_tokens=20),
SamplingParams(max_tokens=30),
SamplingParams(max_tokens=40),
]
instance.engine_client = SimpleNamespace(
stage_configs=[SimpleNamespace(stage_type="llm") for _ in range(4)],
default_sampling_params_list=default_params,
)
result = instance._to_sampling_params_list(
[
{"max_tokens": 1},
{"max_tokens": 2},
{"max_tokens": 3},
]
)
assert len(result) == 4
assert [params.max_tokens for params in result] == [1, 2, 3, 40]
assert result[3] is not default_params[3]
# =============================================================================
# Tests for _get_comprehension_stage_index
# =============================================================================
@@ -1826,15 +1826,27 @@ class TestFileValidationFunctions:
class TestStreamingProtocolValidation:
"""Unit tests for the stream field validators in OpenAICreateSpeechRequest."""
"""Unit tests for streaming validators in OpenAICreateSpeechRequest."""
def test_default_is_non_streaming(self):
req = OpenAICreateSpeechRequest(input="Hello")
assert req.stream is False
assert req.stream_format is None
assert req.is_streaming() is False
def test_stream_validation_errors(self):
"""stream=True requires response_format not in ('pcm', 'wav') and speed=1.0."""
"""stream=True requires response_format in ('pcm', 'wav') and speed=1.0."""
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)
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)
def test_stream_valid(self):
"""stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted."""
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm")
@@ -1843,10 +1855,17 @@ class TestStreamingProtocolValidation:
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav")
assert req.stream is True
def test_sse_stream_format_is_blocked(self):
"""stream_format='sse' is blocked."""
with pytest.raises(ValidationError, match="sse"):
OpenAICreateSpeechRequest(input="Hello", stream_format="sse")
def test_stream_format_audio_is_valid(self):
req = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm")
assert req.stream_format == "audio"
assert req.is_raw_audio_stream() is True
def test_sse_stream_format_is_valid(self):
"""stream_format='sse' is accepted for /audio/speech."""
req = OpenAICreateSpeechRequest(input="Hello", stream_format="sse")
assert req.stream_format == "sse"
assert req.is_sse_stream() is True
class TestStreamingResponse:
@@ -1927,6 +1946,146 @@ class TestStreamingResponse:
assert "audio/pcm" in response.headers["content-type"]
assert len(response.content) > 0
def test_stream_format_audio_streaming(self, streaming_app):
"""stream_format=audio without stream=True returns raw audio/pcm chunks."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "pcm"},
)
assert response.status_code == 200
assert "audio/pcm" in response.headers["content-type"]
assert "text/event-stream" not in response.headers["content-type"]
assert len(response.content) > 0
def test_sse_streaming(self, streaming_app):
"""stream_format=sse without stream=True returns audio deltas as SSE."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"},
)
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.delta" in body
assert "event: speech.audio.done" in body
data_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(data_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.delta"
assert payload["response_format"] == "pcm"
assert base64.b64decode(payload["audio"])
def test_stream_true_prefers_raw_audio_over_sse(self, streaming_app):
"""stream=True keeps the existing raw audio stream behavior even with stream_format=sse."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "sse", "response_format": "pcm"},
)
assert response.status_code == 200
assert "audio/pcm" in response.headers["content-type"]
assert "text/event-stream" not in response.headers["content-type"]
assert len(response.content) > 0
def test_sse_rejects_unsupported_response_format(self, streaming_app):
"""stream_format=sse with a non-pcm/wav format must fail before streaming starts."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "mp3"},
)
assert response.status_code in (400, 422)
assert "text/event-stream" not in response.headers.get("content-type", "")
def test_sse_rejects_speed_adjustment(self, streaming_app):
"""stream_format=sse with speed != 1.0 must fail before streaming starts."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm", "speed": 2.0},
)
assert response.status_code in (400, 422)
assert "text/event-stream" not in response.headers.get("content-type", "")
def test_stream_format_audio_rejects_unsupported_response_format(self, streaming_app):
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "mp3"},
)
assert response.status_code in (400, 422)
assert "audio/" not in response.headers.get("content-type", "")
def test_stream_format_audio_rejects_speed_adjustment(self, streaming_app):
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "pcm", "speed": 2.0},
)
assert response.status_code in (400, 422)
assert "audio/" not in response.headers.get("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."""
async def mock_generate_streaming(*args, **kwargs):
raise RuntimeError("boom: simulated engine failure")
yield # pragma: no cover - generator marker, unreachable
mock_engine_client = mocker.MagicMock()
mock_engine_client.errored = False
mock_engine_client.generate = mocker.MagicMock(side_effect=mock_generate_streaming)
mock_engine_client.default_sampling_params_list = [{}]
mock_models = mocker.MagicMock()
mock_models.is_base_model.return_value = True
speech_server = OmniOpenAIServingSpeech(
engine_client=mock_engine_client,
models=mock_models,
request_logger=mocker.MagicMock(),
)
original_create_speech = speech_server.create_speech
sig = signature(original_create_speech)
new_parameters = [p for name, p in sig.parameters.items() if name != "raw_request"]
new_sig = Signature(parameters=new_parameters, return_annotation=sig.return_annotation)
async def awaitable_create_speech(*args, **kwargs):
return await original_create_speech(*args, **kwargs)
awaitable_create_speech.__signature__ = new_sig
speech_server.create_speech = awaitable_create_speech
app = FastAPI()
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
return app
def test_sse_emits_error_event_on_generator_failure(self, erroring_streaming_app):
"""An exception inside the SSE generator must surface as a speech.audio.error event."""
client = TestClient(erroring_streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"},
)
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.error" in body
error_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(error_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.error"
assert "error" in payload
assert payload["error"]["message"]
def test_non_streaming_unchanged(self, streaming_app):
"""Non-streaming path must still return audio/wav."""
client = TestClient(streaming_app)
@@ -2883,6 +3042,40 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=sse should request delta-style multimodal outputs."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream_format="sse")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=audio should request delta-style multimodal outputs."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream_format="audio", response_format="pcm")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false(
self, qwen3_tts_server, mocker: MockerFixture
):
@@ -1,4 +1,6 @@
import asyncio
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, WebSocket
@@ -9,6 +11,7 @@ from starlette.websockets import WebSocketDisconnect
from vllm_omni.entrypoints.openai import serving_speech_stream as streaming_speech_module
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.utils.forced_aligner import WordTimestamp
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -25,10 +28,11 @@ def _build_test_app(
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"RIFF" + b"\x00" * 32, "audio/wav"))
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-1", object(), {}))
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (b"\x01\x02", b"\x03\x04\x05"):
yield chunk
yield (chunk, 24000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
speech_service.engine_client = mocker.MagicMock()
@@ -82,6 +86,7 @@ class TestStreamingSpeechWebSocket:
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_prepare_speech_generation(request):
captured_requests.append(request)
@@ -89,9 +94,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mock_prepare_speech_generation
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"):
yield chunk
yield (chunk, 24000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
app, _ = _build_test_app(speech_service)
@@ -130,6 +135,164 @@ class TestStreamingSpeechWebSocket:
assert captured_requests[0].initial_codec_chunk_frames == 12
assert speech_service._generate_audio_bytes.await_count == 0
def test_word_timestamps_requires_configured_aligner(self, mocker: MockerFixture):
app, _ = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
error = ws.receive_json()
assert error["type"] == "error"
assert "without --forced-aligner" in error["message"]
def test_word_timestamps_emit_sidecar_json_frame(self, mocker: MockerFixture):
captured_requests = []
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = SimpleNamespace(model="aligner")
async def mock_prepare_speech_generation(request):
captured_requests.append(request)
return "req-stream", object(), {}
speech_service._prepare_speech_generation = mock_prepare_speech_generation
first_chunk = b"\x01" * 1000
second_chunk = b"\x02" * 1000
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (first_chunk, second_chunk):
yield (chunk, 1000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
# Sentence-level: aligner runs once over the whole sentence audio.
mock_align = mocker.AsyncMock(
return_value=[
WordTimestamp("Hello", 0, 200),
WordTimestamp("world", 200, 900),
]
)
mocker.patch.object(streaming_speech_module, "forced_align", mock_align)
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
start = ws.receive_json()
assert start["type"] == "audio.start"
assert start["word_timestamps"] is True
# Audio streams first; timestamps are null until the sentence
# is fully aligned.
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 0
assert chunk["chunk_start_ms"] == 0
assert chunk["chunk_end_ms"] == 500
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == first_chunk
assert chunk["timestamps"] is None
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 1
assert chunk["chunk_start_ms"] == 500
assert chunk["chunk_end_ms"] == 1000
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == second_chunk
assert chunk["timestamps"] is None
# Final frame: empty audio carrying the whole-sentence timestamps.
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 2
assert chunk["chunk_start_ms"] == 0
assert chunk["chunk_end_ms"] == 1000
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == b""
assert chunk["timestamps"] == [
{"word": "Hello", "start_ms": 0, "end_ms": 200},
{"word": "world", "start_ms": 200, "end_ms": 900},
]
done = ws.receive_json()
assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": 2000, "error": False}
assert captured_requests[0].word_timestamps is True
assert mock_align.await_count == 1
def test_word_timestamps_emit_word_dicts(self, mocker: MockerFixture):
# The streaming layer forwards the aligner's (already monotonic,
# non-overlapping) words as JSON dicts in the trailing frame.
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = SimpleNamespace(model="aligner")
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
chunk = b"\x01" * 1000
yield (chunk, 1000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
mock_align = mocker.AsyncMock(
return_value=[
WordTimestamp("Hello", 0, 1000),
WordTimestamp("world", 1000, 1200),
]
)
mocker.patch.object(streaming_speech_module, "forced_align", mock_align)
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
assert ws.receive_json()["type"] == "audio.start"
# Real-time audio chunk (timestamps null), then the timestamp frame.
assert ws.receive_json()["timestamps"] is None
final = ws.receive_json()
assert final["type"] == "audio.chunk"
timestamps = final["timestamps"]
assert timestamps == [
{"word": "Hello", "start_ms": 0, "end_ms": 1000},
{"word": "world", "start_ms": 1000, "end_ms": 1200},
]
for ts in timestamps:
assert ts["end_ms"] >= ts["start_ms"]
def test_flush_on_input_done(self, mocker: MockerFixture):
app, _ = _build_test_app(mocker=mocker)
@@ -249,6 +412,7 @@ class TestStreamingSpeechWebSocket:
speech_service._generate_pcm_chunks = mocker.AsyncMock()
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
@@ -269,8 +433,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-stream-err", object(), {}))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
yield b"\x01\x02"
raise RuntimeError("stream boom")
@@ -361,8 +526,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-abort", object(), {}))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
yield b"\x01\x02"
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
@@ -385,7 +551,9 @@ class TestStreamingSpeechWebSocket:
config.ref_audio = None
config.ref_text = None
config.x_vector_only_mode = None
config.speaker_embedding = None
config.stream_audio = True
config.word_timestamps = False
with pytest.raises(WebSocketDisconnect):
asyncio.run(handler._generate_and_send(websocket, config, "Hello world.", 0))
@@ -423,6 +423,7 @@ def test_decode_video_bytes_can_keep_last_frames():
)
assert len(frames) == 2
assert frames.fps == pytest.approx(8.0)
red_means = [np.asarray(frame)[:, :, 0].mean() for frame in frames]
assert red_means[0] > 100
assert red_means[1] > red_means[0]
@@ -520,11 +521,49 @@ def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture):
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_frames == 72
assert captured.fps == 24
assert captured.frame_rate == 24.0
# fps omitted -> sampling params carry None (the "not provided" signal); the 24
# default is applied only at output encoding.
assert captured.fps is None
assert captured.frame_rate is None
assert fps_values == [24]
def test_model_reported_fps_wins_when_request_fps_omitted(test_client, mocker: MockerFixture):
fps_values = []
def _fake_encode(video, fps, audio=None, audio_sample_rate=None, **kwargs):
del video, audio, audio_sample_rate, kwargs
fps_values.append(fps)
return b"fake-video"
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
side_effect=_fake_encode,
)
engine = test_client.app.state.openai_serving_video._engine_client
async def _generate(prompt, request_id, sampling_params_list):
engine.captured_prompt = prompt
engine.captured_sampling_params_list = sampling_params_list
result = MockVideoResult([object()])
result.multimodal_output["fps"] = 8
yield result
engine.generate = _generate
response = test_client.post("/v1/videos", data={"prompt": "source fps"})
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
captured = engine.captured_sampling_params_list[0]
# fps omitted -> None on the sampling params; the model-reported fps (8) wins for output.
assert captured.fps is None
assert captured.frame_rate is None
assert fps_values == [8]
def test_size_param_sets_width_height(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
+96
View File
@@ -0,0 +1,96 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.experimental.fullduplex.joyvl.decision.output_parser import parse_action
from vllm_omni.experimental.fullduplex.joyvl.memory.brain import InteractionBrain
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeSummarizer:
def __init__(self):
self.chunks = 0
async def summarize_chunk(self, chunk_index, frame_range, frames):
self.chunks += 1
return f"summary-of-chunk-{chunk_index}"
async def compress_to_long_term(self, existing, mids):
return (existing + " | " if existing else "") + f"compressed({len(mids)})"
class _FakeDelegation:
def __init__(self):
self._ready = {}
async def submit(self, question, note, frames):
self._ready["t1"] = question
return "t1"
async def poll(self, task_id):
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import DelegationResult
return DelegationResult(task_id, "ready", digest=f"answer to {self._ready[task_id]}")
def test_query_freshness_and_archive_on_change():
b = InteractionBrain()
assert b.update_query("count bottles") is True
assert b.update_query("count bottles") is False
b.record_response("1 bottle")
assert b.update_query("now describe") is True
assert b.memory.qa_history[-1].query == "count bottles"
assert b.memory.qa_history[-1].responses == [("0.0s", "1 bottle")]
def test_query_in_frame_on_arrival_then_prefix_on_later_chunks():
b = InteractionBrain(frame_seconds=1.0)
b.tick(3)
b.update_query("alert me if a fire breaks out")
assert b.query_in_current_chunk is True
assert "alert me if a fire breaks out" not in b.build_prefix()
b.record_response("a fire is breaking out")
b.close_chunk()
assert b.query_in_current_chunk is False
prefix = b.build_prefix()
assert "alert me if a fire breaks out" in prefix
assert "a fire is breaking out" in prefix
@pytest.mark.asyncio
async def test_flush_archives_qa_and_summarizes():
summ = _FakeSummarizer()
b = InteractionBrain(summarizer=summ, chunk_frames=3, long_term_every_n_chunks=2, frame_seconds=1.0)
b.update_query("watch the room")
b.tick(3)
assert b.should_flush() is True
b.record_response("a person enters")
await b.flush([("0.0s", "u0"), ("2.0s", "u2")])
assert summ.chunks == 1
assert b.chunk_index == 2
assert b.should_flush() is False
assert b.memory.qa_history[-1].responses == [("2.0s", "a person enters")]
assert len(b.memory.mid_term_summaries) == 1
@pytest.mark.asyncio
async def test_long_term_compression_after_n_chunks():
summ = _FakeSummarizer()
b = InteractionBrain(summarizer=summ, chunk_frames=1, long_term_every_n_chunks=2, frame_seconds=1.0)
for _ in range(2):
await b.flush([("0.0s", "u")])
assert b.memory.long_term_memory.startswith("compressed(")
assert b.memory.mid_term_summaries == []
@pytest.mark.asyncio
async def test_delegation_submit_and_fold():
b = InteractionBrain(delegation=_FakeDelegation())
action = parse_action("</response> hold on <delegation> solve this")
info = await b.submit_delegation(action, [])
assert info["status"] == "submitted"
folded = await b.fold_delegations()
assert folded["status"] == "ready"
assert b.memory.qa_history[-1].responses[0][1] == "answer to solve this"
+282
View File
@@ -0,0 +1,282 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import pytest
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import (
DelegationResult,
ImageEditDelegationBridge,
ImageGenDelegationBridge,
OpenAIDelegationBridge,
)
from vllm_omni.experimental.fullduplex.joyvl.decision.policy import JoyVLPolicy, sample_frames
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_sample_frames_keeps_recent():
assert sample_frames(["a", "b"], 4) == ["a", "b"]
out = sample_frames([str(i) for i in range(10)], 4)
assert out[-1] == "9" and len(out) == 4
def test_build_messages_stable_head_and_query():
p = JoyVLPolicy(num_frames=4)
p.tick(2)
p.set_query("count the bottles")
msgs, user = p.build_messages([{"type": "image_url", "image_url": {"url": "x"}}])
assert msgs[0]["role"] == "system" and "</silence>" in msgs[0]["content"]
assert any("count the bottles" in part.get("text", "") for part in user["content"])
def test_commit_records_only_spoken():
p = JoyVLPolicy()
p.set_query("q")
assert p.commit("</silence>").action.value == "silence"
assert p.brain.response_records == []
assert p.commit("</response> hello").action.value == "response"
assert p.brain.response_records == [("0.0s", "hello")]
def test_commit_dedup_default_is_exact_match_only():
p = JoyVLPolicy() # threshold 1.0 == reference behavior
assert p.commit("</response> a red ball appears").action.value == "response"
assert p.commit("</response> a red ball appears").action.value == "silence" # exact repeat dropped
assert p.commit("</response> a red ball is appearing").action.value == "response" # near-dup kept
def test_commit_dedup_threshold_drops_near_duplicates():
p = JoyVLPolicy(response_dedup_threshold=0.8)
assert p.commit("</response> a red ball appears").action.value == "response"
assert p.commit("</response> a red ball appears!").action.value == "silence" # near-dup dropped
assert p.commit("</response> the sky is now blue").action.value == "response" # distinct kept
def test_commit_dedup_rearms_after_silence():
p = JoyVLPolicy(response_dedup_threshold=0.85)
assert p.commit("</response> someone raises a hand").action.value == "response"
# no gap: an immediate repeat is still suppressed
assert p.commit("</response> someone raises a hand").action.value == "silence"
# a genuine silent tick (hand lowered) re-arms; the recurrence speaks again
assert p.commit("</silence>").action.value == "silence"
assert p.commit("</response> someone raises a hand").action.value == "response"
class _FakeBrain(OpenAIDelegationBridge):
"""Override only the network call so submit/poll plumbing is exercised for real."""
def __init__(self, answer, gate=None):
super().__init__("http://unused/v1", "background-model")
self._answer = answer
self._gate = gate
async def _complete(self, messages):
if self._gate is not None:
await self._gate.wait()
return self._answer
@pytest.mark.asyncio
async def test_openai_delegation_bridge_is_nonblocking_and_resolves():
gate = asyncio.Event()
bridge = _FakeBrain("the bottle is on the left shelf", gate=gate)
frames = [("0.0s", "data:image/jpeg;base64,AAA")]
task_id = await bridge.submit("where is the bottle?", "let me check", frames)
# poll must not block on the slow background brain
assert (await bridge.poll(task_id)).status == "pending"
gate.set()
await asyncio.sleep(0)
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
assert "bottle is on the left shelf" in result.digest
assert (await bridge.poll("missing")).status == "error"
class _FakeImageBrain(ImageGenDelegationBridge):
async def _call_images(self, payload):
# echo the prompt back so the test can assert it was passed through
return {"data": [{"b64_json": "QUJD"}]} # "ABC"
@pytest.mark.asyncio
async def test_image_delegation_bridge_returns_media_not_text():
bridge = _FakeImageBrain("http://unused/v1")
task_id = await bridge.submit("draw a red dragon", "", [])
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
# the image rides in media as a data URL; digest is only a short text placeholder
assert result.media == "data:image/png;base64,QUJD"
assert "QUJD" not in result.digest and "red dragon" in result.digest
class _FakeEditBrain(ImageEditDelegationBridge):
def __init__(self, base_url, model):
super().__init__(base_url, model)
self.seen_frame = None
async def _call_chat(self, frame, instruction):
self.seen_frame = frame
return {"choices": [{"message": {"content": [{"image_url": {"url": "data:image/png;base64,Q0FSVA=="}}]}}]}
@pytest.mark.asyncio
async def test_image_edit_delegation_conditions_on_frame_and_returns_media():
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
frames = [("0.0s", "data:image/jpeg;base64,OLD"), ("1.0s", "data:image/jpeg;base64,NOW")]
task_id = await bridge.submit("把画面变成卡通风格", "", frames)
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
assert bridge.seen_frame == "data:image/jpeg;base64,NOW" # latest frame, not the old one
assert result.media == "data:image/png;base64,Q0FSVA=="
assert result.media not in result.digest # image rides in media, not context
@pytest.mark.asyncio
async def test_image_edit_delegation_errors_without_frame():
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
task_id = await bridge.submit("cartoonify", "", [])
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.status == "error"
class _NamedBridge:
def __init__(self, name):
self.name = name
async def submit(self, question, note, frames):
return f"{self.name}-1"
async def poll(self, task_id):
return DelegationResult(task_id, "ready", digest=self.name)
@pytest.mark.asyncio
async def test_routing_delegation_dispatches_by_request():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import RoutingDelegationBridge
chat, image, edit = _NamedBridge("chat"), _NamedBridge("image"), _NamedBridge("edit")
r = RoutingDelegationBridge(chat=chat, image=image, edit=edit)
async def route(q):
tid = await r.submit(q, "", [])
return (await r.poll(tid)).digest
assert await route("把你看到的画面变成卡通风格") == "edit" # restyle current view
assert await route("画一只红色的龙") == "image" # generate a new image
assert await route("这道数学题的答案是什么") == "chat" # hard question -> chat brain
class _Delegation:
async def submit(self, question, note, frames):
return "t1"
async def poll(self, task_id):
return DelegationResult(task_id, "ready", digest="background answer")
def test_build_delegation_off_unless_backend_configured():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
# No backend URL -> delegation disabled (None), NOT a silent stub that folds fake answers.
assert SessionManager._build_delegation(InteractionConfig()) is None
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="router")) is None
# Stub only on explicit opt-in.
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="stub")) is not None
# A real chat backend -> a live bridge.
cfg = InteractionConfig(delegation_backend_url="http://x/v1", delegation_model="m")
assert SessionManager._build_delegation(cfg) is not None
@pytest.mark.asyncio
async def test_delegation_cancel_drops_task():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
b = StubDelegationBridge(ready_after_ticks=1)
task_id = await b.submit("q", "", [])
b.cancel(task_id)
# after cancel the task is gone -> poll reports it as unknown rather than leaking
assert (await b.poll(task_id)).status == "error"
@pytest.mark.asyncio
async def test_delegation_aclose_clears_pending():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
b = StubDelegationBridge(ready_after_ticks=5)
task_id = await b.submit("q", "", [])
await b.aclose()
assert (await b.poll(task_id)).status == "error"
@pytest.mark.asyncio
async def test_session_reset_cancels_and_awaits_consolidation():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.session import InteractionSession
class _Backend:
async def generate(self, *a, **k):
return "</silence>", None
async def aclose(self):
pass
class _Summarizer:
async def summarize_chunk(self, *a, **k):
await asyncio.sleep(100) # hang so the consolidation task is in-flight
return "x"
async def compress_to_long_term(self, *a, **k):
return ""
async def aclose(self):
pass
sess = InteractionSession("s", InteractionConfig(), _Backend(), summarizer=_Summarizer())
sess._spawn_consolidation(1, [("0.0s", "u")])
assert len(sess._consolidating) == 1
await sess.reset() # must cancel AND await the in-flight task, not just clear the set
assert len(sess._consolidating) == 0
@pytest.mark.asyncio
async def test_manager_aclose_closes_without_error():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
mgr = SessionManager(InteractionConfig(enable_memory=False, enable_delegation=False))
await mgr.aclose()
await mgr.aclose() # idempotent
@pytest.mark.asyncio
async def test_delegation_submit_and_fold():
p = JoyVLPolicy(delegation=_Delegation())
action = p.commit("</response> hold on <delegation> hard question")
assert action.action.value == "delegate"
info = await p.submit_if_delegate(action)
assert info["status"] == "submitted"
folded = await p.fold_delegations()
assert folded["status"] == "ready"
assert p.brain.memory.qa_history[-1].responses[0][1] == "background answer"
+172
View File
@@ -0,0 +1,172 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
from collections.abc import AsyncIterator
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.adapter import DuplexCapability, OutputChunk
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
from vllm_omni.experimental.fullduplex.core.session import DuplexSession, DuplexSessionConfig
from vllm_omni.experimental.fullduplex.joyvl.adapter import JoyVLDuplexAdapter
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
async def _feed(events):
for e in events:
yield e
def _collector():
out: list[dict] = []
async def emit(event: dict) -> None:
out.append(event)
return out, emit
class _FakeAdapter:
def __init__(self, chunks, barge_after=None):
self._chunks = chunks
self._barge_after = barge_after
def capabilities(self):
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
async def on_input(self, session, modality, data):
pass
def should_respond(self, session):
return True
async def respond(self, session) -> AsyncIterator[OutputChunk]:
for i, c in enumerate(self._chunks):
if self._barge_after is not None and i == self._barge_after:
session.barge_in()
yield OutputChunk("text", c)
async def on_barge_in(self, session):
pass
async def on_playback_ack(self, session, cursor):
pass
@pytest.mark.asyncio
async def test_runtime_basic_response():
session = DuplexSession("s", DuplexSessionConfig(output_modalities=("text",)))
rt = DuplexRuntime(session, _FakeAdapter(["a", "b"]))
out, emit = _collector()
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
types = [e["type"] for e in out]
assert types == [ev.RESPONSE_CREATED, ev.RESPONSE_DELTA, ev.RESPONSE_DELTA, ev.RESPONSE_DONE]
assert [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA] == ["a", "b"]
@pytest.mark.asyncio
async def test_runtime_barge_in_drops_stale_output():
session = DuplexSession("s")
rt = DuplexRuntime(session, _FakeAdapter(["a", "b", "c"], barge_after=1))
out, emit = _collector()
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
data = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
assert data == ["a"]
assert ev.RESPONSE_DONE not in [e["type"] for e in out]
class _SlowAdapter:
def capabilities(self):
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
async def on_input(self, session, modality, data):
pass
def should_respond(self, session):
return True
async def respond(self, session) -> AsyncIterator[OutputChunk]:
for c in ["a", "b", "c", "d", "e"]:
await asyncio.sleep(0.02)
yield OutputChunk("text", c)
async def on_barge_in(self, session):
pass
async def on_playback_ack(self, session, cursor):
pass
@pytest.mark.asyncio
async def test_runtime_cancel_event_interrupts_active_response():
session = DuplexSession("s")
rt = DuplexRuntime(session, _SlowAdapter())
out, emit = _collector()
async def feed():
yield {"type": ev.INPUT_COMMIT}
await asyncio.sleep(0.03)
yield {"type": ev.RESPONSE_CANCEL}
yield {"type": ev.CLOSE}
await rt.run(feed(), emit)
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
types = [e["type"] for e in out]
assert len(deltas) < 5
assert ev.RESPONSE_CANCELLED in types
assert ev.RESPONSE_DONE not in types
@pytest.mark.asyncio
async def test_runtime_new_response_supersedes_inflight_without_blocking():
# A second response trigger arriving while the first is still streaming must
# supersede it (barge-in) rather than block the input loop until it finishes.
session = DuplexSession("s")
rt = DuplexRuntime(session, _SlowAdapter())
out, emit = _collector()
async def feed():
yield {"type": ev.INPUT_COMMIT}
await asyncio.sleep(0.03) # let the first response emit ~one chunk
yield {"type": ev.INPUT_COMMIT} # supersedes the in-flight first response
await asyncio.sleep(0.2) # let the second response finish
yield {"type": ev.CLOSE}
await rt.run(feed(), emit)
created = [e for e in out if e["type"] == ev.RESPONSE_CREATED]
done = [e for e in out if e["type"] == ev.RESPONSE_DONE]
# two responses created; only the latest one completes (the first was cancelled)
assert len(created) == 2
assert len(done) == 1
assert done[0]["response_index"] == created[-1]["response_index"]
@pytest.mark.asyncio
async def test_joyvl_adapter_speaks_then_silences():
replies = iter(["</response> a fire is breaking out", "</silence>"])
async def fake_generate(messages):
return next(replies)
adapter = JoyVLDuplexAdapter(fake_generate, num_frames=4)
cfg = DuplexSessionConfig(input_modalities=("video", "text"), output_modalities=("text",), proactive=True)
rt = DuplexRuntime(DuplexSession("s", cfg), adapter)
out, emit = _collector()
await rt.run(
_feed(
[
{"type": ev.INPUT_APPEND, "modality": "text", "data": "alert me if a fire breaks out"},
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,AAA"},
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,BBB"},
{"type": ev.CLOSE},
]
),
emit,
)
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
assert "a fire is breaking out" in deltas
assert deltas.count("a fire is breaking out") == 1
@@ -0,0 +1,69 @@
# 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.aura_omni.qwen3_vl import (
AuraQwen3VLForConditionalGeneration,
AuraQwen3VLProcessingInfo,
Qwen3VLProcessor,
)
from vllm_omni.model_executor.models.registry import OmniModelRegistry
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeCtx:
def __init__(self, config):
self.config = config
self.processor_type = None
def get_hf_config(self, typ=None):
if typ is not None:
raise TypeError("wrong config class")
return self.config
def get_hf_processor(self, typ=None, **kwargs):
self.processor_type = typ
return SimpleNamespace(kwargs=kwargs)
def test_aura_qwen3_vl_processing_accepts_remote_qwen3_vl_config():
info = object.__new__(AuraQwen3VLProcessingInfo)
info.ctx = _FakeCtx(
SimpleNamespace(
model_type="qwen3_vl",
vision_config=SimpleNamespace(spatial_merge_size=2),
text_config=SimpleNamespace(hidden_size=4096),
)
)
assert info.get_hf_config().model_type == "qwen3_vl"
def test_aura_qwen3_vl_processing_rejects_unrelated_remote_config():
info = object.__new__(AuraQwen3VLProcessingInfo)
info.ctx = _FakeCtx(SimpleNamespace(model_type="not_qwen3_vl"))
with pytest.raises(TypeError, match="wrong config class"):
info.get_hf_config()
def test_aura_qwen3_vl_model_arch_registered():
model_cls = OmniModelRegistry._try_load_model_cls("AuraQwen3VLForConditionalGeneration")
assert model_cls is AuraQwen3VLForConditionalGeneration
def test_aura_qwen3_vl_processing_forces_upstream_processor_class():
info = object.__new__(AuraQwen3VLProcessingInfo)
ctx = _FakeCtx(SimpleNamespace(model_type="qwen3_vl"))
info.ctx = ctx
processor = info.get_hf_processor(extra="value")
assert ctx.processor_type is Qwen3VLProcessor
assert processor.kwargs["use_fast"] is True
assert processor.kwargs["extra"] == "value"
@@ -0,0 +1,197 @@
# 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.qwen3_tts.prompt_embeds_builder import (
PRECOMPUTED_TEXT_IDS_KEY,
)
from vllm_omni.model_executor.stage_input_processors.aura_omni import (
SILENT_TEXT,
asr2aura,
aura2tts,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _source_output(text: str, request_id: str = "req-1", token_ids: list[int] | None = None):
output = SimpleNamespace(text=text, cumulative_token_ids=token_ids or [1, 2, 3], multimodal_output={})
return SimpleNamespace(request_id=request_id, outputs=[output])
def _source_delta_final_output(cumulative_text: str, request_id: str = "req-1"):
output = SimpleNamespace(
text="",
cumulative_text=cumulative_text,
cumulative_token_ids=[1, 2, 3],
multimodal_output={},
)
return SimpleNamespace(request_id=request_id, outputs=[output])
def test_asr2aura_carries_video_payload_and_transcript():
prompt = {
"multi_modal_data": {"video": ["frame-0", "frame-1"]},
"additional_information": {"aura_system_prompt": ["system"]},
}
[next_input] = asr2aura([_source_output("What is happening now?")], prompt=[prompt])
assert next_input["multi_modal_data"] == {"video": ["frame-0", "frame-1"]}
assert "<|video_pad|>" in next_input["prompt"]
assert "What is happening now?" in next_input["prompt"]
assert next_input["prompt"].startswith("<|im_start|>system\nsystem")
def test_asr2aura_drops_audio_before_qwen3_vl_stage():
prompt = {
"multi_modal_data": {
"audio": ("wave", 16000),
"video": ["frame-0", "frame-1"],
},
}
[next_input] = asr2aura([_source_output("Check the video")], prompt=[prompt])
assert next_input["multi_modal_data"] == {"video": ["frame-0", "frame-1"]}
assert "<|video_pad|>" in next_input["prompt"]
def test_asr2aura_reads_video_stashed_for_downstream_stage():
prompt = {
"multi_modal_data": {"audio": ("wave", 16000)},
"additional_information": {
"deferred_multi_modal_data": {"video": ["frame-0", "frame-1"]},
},
}
[next_input] = asr2aura([_source_output("Check the video")], prompt=[prompt])
assert next_input["multi_modal_data"] == {"video": ["frame-0", "frame-1"]}
assert "<|video_pad|>" in next_input["prompt"]
def test_asr2aura_supports_video_only_observation():
prompt = {"multi_modal_data": {"video": ["frame-0", "frame-1"]}}
[next_input] = asr2aura([_source_output("")], prompt=[prompt])
assert "<|video_pad|>" in next_input["prompt"]
assert "<|im_start|>assistant" in next_input["prompt"]
def test_aura2tts_builds_qwen3_tts_prompt_information():
prompt = {
"additional_information": {
"tts_language": ["Chinese"],
"tts_instruct": ["Calm voice."],
"tts_ref_audio": ["ref.wav"],
"tts_ref_text": ["Reference transcript sample."],
}
}
[tts_input] = aura2tts([_source_output("Hello.")], prompt=[prompt])
assert len(tts_input["prompt_token_ids"]) >= 32
assert tts_input["additional_information"]["text"] == ["Hello."]
assert PRECOMPUTED_TEXT_IDS_KEY not in tts_input["additional_information"]
assert tts_input["additional_information"]["task_type"] == ["Base"]
assert tts_input["additional_information"]["language"] == ["Chinese"]
assert tts_input["additional_information"]["ref_audio"] == ["ref.wav"]
assert tts_input["additional_information"]["ref_text"] == ["Reference transcript sample."]
assert tts_input["additional_information"]["x_vector_only_mode"] == [False]
assert tts_input["additional_information"]["instruct"] == ["Calm voice."]
def test_aura2tts_prefers_streaming_cumulative_text():
prompt = {
"additional_information": {
"tts_ref_audio": ["ref.wav"],
"tts_ref_text": ["Reference transcript sample."],
}
}
[tts_input] = aura2tts(
[_source_delta_final_output("The complete AURA reply.")],
prompt=[prompt],
)
assert tts_input["additional_information"]["text"] == ["The complete AURA reply."]
def test_aura2tts_supports_base_ref_audio_override():
prompt = {
"additional_information": {
"tts_ref_audio": ["custom.wav"],
"tts_ref_text": ["custom transcript"],
}
}
[tts_input] = aura2tts([_source_output("Hello.")], prompt=[prompt])
assert tts_input["additional_information"]["task_type"] == ["Base"]
assert tts_input["additional_information"]["ref_audio"] == ["custom.wav"]
assert tts_input["additional_information"]["ref_text"] == ["custom transcript"]
assert tts_input["additional_information"]["x_vector_only_mode"] == [False]
def test_aura2tts_supports_x_vector_only_mode_for_base():
prompt = {
"additional_information": {
"tts_task_type": ["Base"],
"tts_x_vector_only_mode": [True],
"tts_ref_audio": ["ref.wav"],
"tts_ref_text": ["Reference transcript sample."],
}
}
[tts_input] = aura2tts([_source_output("Hello.")], prompt=[prompt])
assert tts_input["additional_information"]["x_vector_only_mode"] == [True]
def test_aura2tts_supports_custom_voice_mode():
prompt = {
"additional_information": {
"tts_task_type": ["CustomVoice"],
"tts_speaker": ["vivian"],
}
}
[tts_input] = aura2tts([_source_output("Hello.")], prompt=[prompt])
assert tts_input["additional_information"]["task_type"] == ["CustomVoice"]
assert tts_input["additional_information"]["speaker"] == ["Vivian"]
assert "ref_audio" not in tts_input["additional_information"]
def test_aura2tts_passes_token_ids_to_qwen3_tts_when_enabled():
prompt = {
"additional_information": {
"tts_ref_audio": ["ref.wav"],
"tts_ref_text": ["Reference transcript sample."],
"tts_pass_token_ids": [True],
}
}
[tts_input] = aura2tts(
[
_source_output(
"Hello.",
token_ids=[151644, 77091, 198, 108386, 1773, 151645, 198],
)
],
prompt=[prompt],
)
assert tts_input["additional_information"][PRECOMPUTED_TEXT_IDS_KEY] == [
[151644, 77091, 198, 108386, 1773, 151645, 198, 151644, 77091, 198]
]
assert "text" not in tts_input["additional_information"]
def test_aura2tts_drops_silent_response():
assert aura2tts([_source_output(SILENT_TEXT)]) == []
+47
View File
@@ -57,6 +57,53 @@ def test_sensenova_extra_registry_declares_request_and_response_params() -> None
assert should_init_extra_args_for_non_diffusion_stages("SenseNovaU1Pipeline") is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_magi_human_extra_registry_declares_request_and_response_params() -> None:
assert get_extra_body_params("MagiHumanPipeline") == frozenset(
{
"seconds",
"audio_path",
"image_path",
"sr_height",
"sr_width",
"sr_num_inference_steps",
}
)
assert get_extra_output_params("MagiHumanPipeline") == frozenset()
assert should_init_extra_args_for_non_diffusion_stages("MagiHumanPipeline") is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_helios_extra_registry_declares_request_and_response_params() -> None:
expected_body = frozenset(
{
"is_enable_stage2",
"pyramid_num_stages",
"pyramid_num_inference_steps_list",
"is_amplify_first_chunk",
"is_skip_first_chunk",
"use_cfg_zero_star",
"use_zero_init",
"zero_steps",
"image",
"video",
"add_noise_to_image_latents",
"image_noise_sigma_min",
"image_noise_sigma_max",
"add_noise_to_video_latents",
"video_noise_sigma_min",
"video_noise_sigma_max",
}
)
# Both the base and pyramid class names resolve to the same declaration.
for cls in ("HeliosPipeline", "HeliosPyramidPipeline"):
assert get_extra_body_params(cls) == expected_body
assert get_extra_output_params(cls) == frozenset()
assert should_init_extra_args_for_non_diffusion_stages(cls) is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_pipeline_has_empty_extra_registry() -> None:
+28
View File
@@ -1384,6 +1384,34 @@ class TestCLIOverrideFlow:
assert s.runtime_overrides["enforce_eager"] is True
class TestAuraOmniDeploy:
def test_aura_omni_deploy_forces_pipeline_override(self):
deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "aura_omni.yaml"
deploy = load_deploy_config(deploy_path)
assert deploy.pipeline == "aura_omni"
def test_aura_omni_deploy_resolves_four_native_stages(self):
stages = StageConfigFactory._create_from_registry(
"qwen3_tts",
cli_overrides={},
deploy_config_path=str(Path(__file__).parent.parent / "vllm_omni" / "deploy" / "aura_omni.yaml"),
)
assert [stage.model_stage for stage in stages] == [
"asr",
"aura",
"qwen3_tts",
"code2wav",
]
assert [stage.final_output for stage in stages] == [False, True, False, True]
assert [stage.final_output_type for stage in stages] == [None, "text", None, "audio"]
assert stages[0].yaml_engine_args["model_arch"] == "Qwen3ASRForConditionalGeneration"
assert stages[1].yaml_engine_args["model_arch"] == "AuraQwen3VLForConditionalGeneration"
assert stages[2].yaml_engine_args["model_arch"] == "Qwen3TTSTalkerForConditionalGeneration"
assert stages[3].yaml_engine_args["model_arch"] == "Qwen3TTSCode2Wav"
class TestSentinelDefaultPrecedence:
"""Caller-typed (non-None) values win over YAML; None values fall through
to YAML / dataclass defaults (#3035)."""
+165
View File
@@ -0,0 +1,165 @@
import numpy as np
import pytest
from vllm_omni.utils import forced_aligner
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_decode_timestamps_maps_boundary_bins_to_words():
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0
logits[1, 2] = 1.0
logits[2, 2] = 1.0
logits[3, 4] = 1.0
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
audio_duration_ms=1000,
)
assert timestamps == [
forced_aligner.WordTimestamp("hello", 0, 400),
forced_aligner.WordTimestamp("world", 400, 800),
]
def test_decode_timestamps_repairs_non_monotonic_bins():
# Word 1's end bin (1) dips below its start bin (2); fix_timestamp should
# snap it back so the frame never carries end_ms < start_ms.
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0 # word0 start -> bin 0
logits[1, 2] = 1.0 # word0 end -> bin 2
logits[2, 1] = 1.0 # word1 start -> bin 1 (out of order)
logits[3, 3] = 1.0 # word1 end -> bin 3
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
timestamp_segment_time_ms=200,
audio_duration_ms=1000,
)
for ts in timestamps:
assert ts.end_ms >= ts.start_ms
# bins [0, 2, 1, 3] -> repaired [0, 2, 2, 3] -> ms x200
assert timestamps == [
forced_aligner.WordTimestamp("hello", 0, 400),
forced_aligner.WordTimestamp("world", 400, 600),
]
def test_decode_timestamps_clamps_to_audio_duration_and_stays_ordered():
# Bins run past the 1000 ms audio; the decoder must cap each bound at the
# audio length and keep words non-overlapping (start <= end, monotonic).
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0 # word0 start -> bin 0
logits[1, 3] = 1.0 # word0 end -> bin 3 (1200 ms, past audio)
logits[2, 3] = 1.0 # word1 start -> bin 3 (1200 ms, past audio)
logits[3, 4] = 1.0 # word1 end -> bin 4 (1600 ms, past audio)
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["a", "b"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
timestamp_segment_time_ms=400,
audio_duration_ms=1000,
)
assert timestamps == [
forced_aligner.WordTimestamp("a", 0, 1000),
forced_aligner.WordTimestamp("b", 1000, 1000),
]
def test_decode_timestamps_rejects_marker_count_mismatch():
logits = np.zeros((2, 5), dtype=np.float32)
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1],
classify_num=5,
audio_duration_ms=1000,
)
assert timestamps == []
def test_build_config_from_yaml(tmp_path):
cfg = tmp_path / "forced_aligner.yaml"
cfg.write_text(
"""
forced_aligner:
model: Qwen/Qwen3-ForcedAligner-0.6B
gpu_memory_utilization: 0.42
dtype: float16
max_model_len: 2048
trust_remote_code: false
""",
encoding="utf-8",
)
args = type("Args", (), {"forced_aligner": None, "forced_aligner_config": str(cfg)})()
out = forced_aligner.build_forced_aligner_config(args)
assert out == forced_aligner.ForcedAlignerConfig(
model="Qwen/Qwen3-ForcedAligner-0.6B",
runner="pooling",
architecture="Qwen3ASRForcedAlignerForTokenClassification",
pooling_task="token_classify",
gpu_memory_utilization=0.42,
dtype="float16",
max_model_len=2048,
trust_remote_code=False,
)
def test_build_config_cli_model_overrides_yaml(tmp_path):
cfg = tmp_path / "forced_aligner.yaml"
cfg.write_text(
"forced_aligner:\n model: old\n gpu_memory_utilization: 0.2\n dtype: float16\n",
encoding="utf-8",
)
args = type(
"Args",
(),
{
"forced_aligner": "new",
"forced_aligner_config": str(cfg),
},
)()
out = forced_aligner.build_forced_aligner_config(args)
assert out is not None
# --forced-aligner overrides the YAML model; gpu_memory_utilization/dtype
# come from the user YAML (there is no longer a CLI flag for gpu mem).
assert out.model == "new"
assert out.gpu_memory_utilization == 0.2
assert out.dtype == "float16"
assert out.runner == "pooling"
def test_build_config_from_cli_model_uses_default_yaml():
args = type(
"Args",
(),
{
"forced_aligner": "local-aligner",
"forced_aligner_config": None,
},
)()
out = forced_aligner.build_forced_aligner_config(args)
assert out is not None
assert out.model == "local-aligner"
assert out.runner == "pooling"
assert out.architecture == "Qwen3ASRForcedAlignerForTokenClassification"
@@ -0,0 +1,65 @@
import pytest
from vllm_omni.utils import qwen3_force_align_processor as processor
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_build_prompt_has_boundary_timestamp_markers():
prompt = processor.build_prompt(["hello", "world"])
assert prompt.count("<timestamp>") == 4
assert "hello<timestamp><timestamp>world" in prompt
assert prompt.endswith("<|im_start|>assistant\n")
@pytest.mark.parametrize(
"text,expected",
[
# Punctuation inside a whitespace segment is stripped, not split on:
# the hand-rolled splitter used to break these into many fake words.
("U.S.A", ["USA"]),
("hello, world!", ["hello", "world"]),
("don't stop", ["don't", "stop"]),
# CJK characters peel off as individual tokens, Latin runs stay whole.
("你好world", ["你", "好", "world"]),
("我爱 China", ["我", "爱", "China"]),
(" spaced out ", ["spaced", "out"]),
],
)
def test_tokenize_space_lang_matches_official_segmentation(text, expected):
assert processor._tokenize_space_lang(text) == expected
def test_segment_words_falls_back_to_port_without_qwen_asr(monkeypatch):
# With qwen_asr unavailable, segmentation must use the built-in port and
# still produce the faithful result for the common (non-JP/KO) case.
monkeypatch.setattr(processor, "_get_official_processor", lambda: None)
assert processor.segment_words("U.S.A is here", "auto") == ["USA", "is", "here"]
def test_fix_timestamp_repairs_dip_onto_monotonic_sequence():
# 200 dips below the preceding 400; it snaps back up to 400.
assert processor.fix_timestamp([0, 400, 200, 800]) == [0, 400, 400, 800]
def test_fix_timestamp_passes_through_monotonic():
assert processor.fix_timestamp([0, 100, 100, 250]) == [0, 100, 100, 250]
def test_resolve_timestamp_token_id_defaults_to_marker_token():
# Regression: the default must resolve the same <timestamp> marker that
# build_prompt inserts, not None (which would degrade every request).
seen = {}
class FakeTokenizer:
def convert_tokens_to_ids(self, token):
seen["token"] = token
return 151705
tid = processor.resolve_timestamp_token_id(FakeTokenizer())
assert tid == 151705
assert seen["token"] == processor.TIMESTAMP_TOKEN
assert processor.TIMESTAMP_TOKEN in processor.build_prompt(["hello", "world"])
+33 -1
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
import pytest
import torch
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner, _filter_mrope_kwargs_for_model
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -96,12 +96,44 @@ class CaptureTalkerMTP(torch.nn.Module):
return req_embeds, codes
class StrictMRoPEModel:
def get_mrope_input_positions(self, input_tokens, mm_features):
raise NotImplementedError
class FlexibleMRoPEModel:
def get_mrope_input_positions(self, input_tokens, mm_features=None, **kwargs):
raise NotImplementedError
@contextmanager
def _noop_forward_context(*args, **kwargs):
"""A no-op context manager to replace vLLM forward context in CPU tests."""
yield
def test_filter_mrope_kwargs_for_strict_model_signature():
kwargs = {
"mm_features": ["audio"],
"hf_config": object(),
"image_grid_thw": [],
}
assert _filter_mrope_kwargs_for_model(StrictMRoPEModel(), kwargs) == {
"mm_features": ["audio"],
}
def test_filter_mrope_kwargs_preserves_flexible_model_kwargs():
kwargs = {
"mm_features": ["video"],
"hf_config": object(),
"video_grid_thw": [[1, 2, 3]],
}
assert _filter_mrope_kwargs_for_model(FlexibleMRoPEModel(), kwargs) is kwargs
def _make_runner(req_ids=("r1", "r2"), hidden_size=4):
# Create an instance without calling OmniGPUModelRunner.__init__
runner = object.__new__(OmniGPUModelRunner)
+6 -1
View File
@@ -233,7 +233,12 @@ def _forward_with_fish_kvcache(
if attn_metadata is not None and not attn_metadata.use_cascade:
num_actual_tokens = attn_metadata.num_actual_tokens
key_cache, value_cache = kv_cache.unbind(0)
# vLLM >=0.23.0 lays out the KV cache as
# (num_blocks, 2, block_size, num_kv_heads, head_size), so key/value
# live on dim 1 (matches FlashAttentionImpl's kv_cache.unbind(1)).
# The pre-0.23 layout had them on dim 0; unbind(0) here would split
# along num_blocks and raise "too many values to unpack".
key_cache, value_cache = kv_cache.unbind(1)
q = query[:num_actual_tokens]
out = output[:num_actual_tokens]
active_batch = int(q.shape[0])
@@ -1,255 +0,0 @@
"""Daily-Omni: optional consistency check between text stream and generated speech.
The benchmark MCQ accuracy uses ``generated_text`` only. When the omni server also
streams ``modality=audio`` (TTS), this module can transcribe the concatenated WAV
with Whisper and compare the inferred option letter to the one parsed from text.
Requires ``openai-whisper`` (``pip install openai-whisper``). Enable via env
``DAILY_OMNI_TEXT_AUDIO_CONSISTENCY=1`` or CLI ``--daily-omni-text-audio-consistency``.
Whisper model name defaults to ``tiny`` (override with ``DAILY_OMNI_WHISPER_MODEL``).
"""
from __future__ import annotations
import logging
import os
import re
import threading
from typing import Any
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest
from vllm_omni.benchmarks.data_modules.daily_omni_eval import extract_predicted_choice
logger = logging.getLogger(__name__)
_whisper_model = None
_whisper_model_name: str | None = None
_whisper_lock = threading.Lock()
def env_text_audio_check_enabled() -> bool:
return os.environ.get("DAILY_OMNI_TEXT_AUDIO_CONSISTENCY", "").lower() in (
"1",
"true",
"yes",
)
def extract_choice_from_asr_transcript(transcript: str) -> str | None:
"""Parse A–D from ASR text; extends :func:`extract_predicted_choice` with spoken Chinese phrases."""
c = extract_predicted_choice(transcript)
if c:
return c
t = transcript or ""
for pat in (
r"(?i)选项\s*([ABCD])\b",
r"(?i)选\s*([ABCD])\b",
r"(?i)答案\s*是\s*([ABCD])\b",
r"(?i)答案\s*([ABCD])\b",
):
m = re.search(pat, t)
if m:
return m.group(1).upper()
return None
def _get_whisper_model(model_name: str):
global _whisper_model, _whisper_model_name
with _whisper_lock:
if _whisper_model is None or _whisper_model_name != model_name:
import whisper
logger.warning(
"Loading Whisper model %r for Daily-Omni text/audio consistency (one-time)...",
model_name,
)
_whisper_model = whisper.load_model(model_name)
_whisper_model_name = model_name
return _whisper_model
def transcribe_wav_bytes(
wav_bytes: bytes,
*,
language: str | None = None,
model_name: str | None = None,
) -> tuple[str | None, str | None]:
"""Transcribe WAV bytes. Returns ``(transcript, error)`` — one of them is set.
Args:
wav_bytes: RIFF WAV file bytes.
language: Optional Whisper language code (e.g. ``en``, ``zh``); improves accuracy/latency.
model_name: Override model id; else ``DAILY_OMNI_WHISPER_MODEL`` or ``tiny``.
"""
if not wav_bytes:
return None, "empty_wav"
if model_name is None or not str(model_name).strip():
model_name = os.environ.get("DAILY_OMNI_WHISPER_MODEL") or "tiny"
model_name = str(model_name).strip() or "tiny"
path: str | None = None
try:
import tempfile
model = _get_whisper_model(model_name)
fd, path = tempfile.mkstemp(suffix=".wav")
with os.fdopen(fd, "wb") as fp:
fp.write(wav_bytes)
kwargs: dict = {}
if language:
kwargs["language"] = language
result = model.transcribe(path, **kwargs)
text = (result.get("text") or "").strip()
return (text if text else None), None
except ImportError:
return None, "openai-whisper is not installed (pip install openai-whisper)"
except Exception as e:
return None, str(e)[:500]
finally:
if path:
try:
os.unlink(path)
except OSError:
pass
def compute_daily_omni_text_audio_consistency_metrics(
input_requests: list[Any],
outputs: list[Any],
*,
include_per_item: bool = False,
) -> dict[str, Any] | None:
"""Compare option letter from ``generated_text`` vs Whisper transcript of output audio.
Only considers requests where ``outputs[i]`` has ``generated_audio_wav_bytes`` set
(populated by the omni benchmark when TA check is enabled).
"""
if not input_requests or len(input_requests) != len(outputs):
return None
if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests):
return None
ta_no_wav = 0
ta_asr_failed = 0
ta_text_unparsed = 0
ta_audio_unparsed = 0
ta_consistent = 0
ta_mismatch = 0
ta_both_parsed = 0
items: list[dict[str, Any]] = []
for req, out in zip(input_requests, outputs, strict=True):
assert isinstance(req, DailyOmniSampleRequest)
rid = req.request_id
if not getattr(out, "success", False):
if include_per_item:
items.append(
{
"request_id": rid,
"skipped": True,
"reason": "request_not_success",
}
)
continue
wav = getattr(out, "generated_audio_wav_bytes", None)
if not wav:
ta_no_wav += 1
if include_per_item:
items.append(
{
"request_id": rid,
"skipped": False,
"reason": "no_output_audio",
"text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
}
)
continue
transcript, asr_err = transcribe_wav_bytes(wav)
if asr_err:
ta_asr_failed += 1
if include_per_item:
items.append(
{
"request_id": rid,
"asr_error": asr_err,
"text_choice": extract_predicted_choice(getattr(out, "generated_text", "") or ""),
}
)
continue
text_choice = extract_predicted_choice(getattr(out, "generated_text", "") or "")
audio_choice = extract_choice_from_asr_transcript(transcript or "")
if text_choice is None:
ta_text_unparsed += 1
if audio_choice is None:
ta_audio_unparsed += 1
if text_choice is not None and audio_choice is not None:
ta_both_parsed += 1
if text_choice == audio_choice:
ta_consistent += 1
else:
ta_mismatch += 1
if include_per_item:
consistent: bool | None
if text_choice is None or audio_choice is None:
consistent = None
else:
consistent = text_choice == audio_choice
items.append(
{
"request_id": rid,
"text_choice": text_choice,
"audio_choice": audio_choice,
"asr_transcript": (transcript or "")[:500],
"text_audio_consistent": consistent,
}
)
comparable = ta_consistent + ta_mismatch
rate = (ta_consistent / comparable) if comparable else None
out: dict[str, Any] = {
"daily_omni_ta_enabled": True,
"daily_omni_ta_no_output_audio": ta_no_wav,
"daily_omni_ta_asr_failed": ta_asr_failed,
"daily_omni_ta_text_unparsed": ta_text_unparsed,
"daily_omni_ta_audio_unparsed": ta_audio_unparsed,
"daily_omni_ta_both_parsed": ta_both_parsed,
"daily_omni_ta_consistent": ta_consistent,
"daily_omni_ta_mismatch": ta_mismatch,
"daily_omni_ta_consistency_rate": rate,
}
if include_per_item:
out["daily_omni_ta_items"] = items
return out
def print_daily_omni_text_audio_summary(metrics: dict[str, Any]) -> None:
if not metrics.get("daily_omni_ta_enabled"):
return
print("{s:{c}^{n}}".format(s=" Daily-Omni text vs audio (ASR) ", n=50, c="="))
print("{:<40} {:<10}".format("No output audio captured:", metrics.get("daily_omni_ta_no_output_audio", 0)))
print("{:<40} {:<10}".format("ASR failed:", metrics.get("daily_omni_ta_asr_failed", 0)))
print("{:<40} {:<10}".format("Both text+audio letter parsed:", metrics.get("daily_omni_ta_both_parsed", 0)))
print("{:<40} {:<10}".format("Consistent (same letter):", metrics.get("daily_omni_ta_consistent", 0)))
print("{:<40} {:<10}".format("Mismatch:", metrics.get("daily_omni_ta_mismatch", 0)))
r = metrics.get("daily_omni_ta_consistency_rate")
if r is not None:
print("{:<40} {:<10.4f}".format("Consistency rate (of both parsed):", r))
print(
"{:<40} {:<10}".format(
"Text unparsed (among w/ audio):",
metrics.get("daily_omni_ta_text_unparsed", 0),
)
)
print(
"{:<40} {:<10}".format(
"Audio unparsed (among w/ audio):",
metrics.get("daily_omni_ta_audio_unparsed", 0),
)
)
+4
View File
@@ -44,6 +44,10 @@ _OMNI_PIPELINES: dict[str, tuple[str, str]] = {
"vllm_omni.model_executor.models.qwen3_omni.pipeline",
"QWEN3_OMNI_PIPELINE",
),
"aura_omni": (
"vllm_omni.model_executor.models.aura_omni.pipeline",
"AURA_OMNI_PIPELINE",
),
"qwen3_tts": (
"vllm_omni.model_executor.models.qwen3_tts.pipeline",
"QWEN3_TTS_PIPELINE",
+110
View File
@@ -0,0 +1,110 @@
# AURA Omni deploy profile: Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Code2Wav.
#
# Pass `--deploy-config vllm_omni/deploy/aura_omni.yaml` and point the parent
# `--model` at any local placeholder repo if needed; each stage overrides its
# own model below.
pipeline: aura_omni
# AURA has a semantic ASR -> AURA -> TTS dependency. Do not prewarm downstream
# stages before AURA text is converted into Qwen3-TTS metadata by aura2tts.
async_chunk: false
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 72
initial_codec_chunk_frames: 1
decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325]
stages:
- stage_id: 0
max_num_seqs: 8
gpu_memory_utilization: 0.1
trust_remote_code: true
enable_prefix_caching: true
async_scheduling: true
max_model_len: 8192
max_num_batched_tokens: 4096
devices: "0"
model: Qwen/Qwen3-ASR-1.7B
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 256
- stage_id: 1
max_num_seqs: 4
gpu_memory_utilization: 0.4
trust_remote_code: true
enable_prefix_caching: true
async_scheduling: true
max_model_len: 262144
max_num_batched_tokens: 15360
devices: "0"
model: aurateam/AURA
default_sampling_params:
temperature: 0.5
top_p: 1.0
top_k: -1
max_tokens: 256
repetition_penalty: 1.0
- stage_id: 2
max_num_seqs: 8
gpu_memory_utilization: 0.2
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 512
max_model_len: 4096
devices: "0"
output_connectors:
to_stage_3: connector_of_shared_memory
# model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
model: Qwen/Qwen3-TTS-12Hz-1.7B-Base
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
repetition_penalty: 1.05
subtalker_sampling_params:
do_sample: true
temperature: 0.9
top_k: 50
top_p: 1.0
- stage_id: 3
max_num_seqs: 8
gpu_memory_utilization: 0.2
enforce_eager: false
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
input_connectors:
from_stage_2: connector_of_shared_memory
# model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
model: Qwen/Qwen3-TTS-12Hz-1.7B-Base
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.0
platforms:
npu:
stages:
- stage_id: 2
enforce_eager: true
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more