Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3a1c8200 | ||
|
|
08b2f694f3 | ||
|
|
170f98c847 | ||
|
|
3805852a71 | ||
|
|
d75a6433b4 | ||
|
|
6506ed3d4d | ||
|
|
d4f2a49a68 | ||
|
|
9bc8705c98 | ||
|
|
1733831f78 | ||
|
|
20b7a67b0d | ||
|
|
9577ccba50 | ||
|
|
54a9d241c0 | ||
|
|
6de0ca36bb | ||
|
|
293396737d | ||
|
|
aa59dc50c7 | ||
|
|
f44f3ef9f7 | ||
|
|
8f869c8ea5 |
No files matched your search
@@ -149,7 +149,7 @@ steps:
|
||||
commands:
|
||||
- pytest -s -v tests/entrypoints/ -m "advanced_model and cuda and L4" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -164,7 +164,35 @@ steps:
|
||||
- group: ":card_index_dividers: E2E Test"
|
||||
depends_on: upload-merge-pipeline
|
||||
steps:
|
||||
- label: "TTS · Step-Audio2 E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/offline_inference/test_step_audio2.py -m 'advanced_model' --run-level 'advanced_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
always-pull: true
|
||||
propagate-environment: true
|
||||
shm-size: "8gb"
|
||||
environment:
|
||||
- "HF_HOME=/fsx/hf_cache"
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Qwen3-TTS CustomVoice Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
|
||||
- tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -173,7 +201,7 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -186,6 +214,12 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Qwen3-TTS Base Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_base.py
|
||||
- tests/e2e/offline_inference/test_qwen3_tts_base.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -194,7 +228,7 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -207,6 +241,12 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Ming-Omni-TTS Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_ming_tts.py
|
||||
- tests/e2e/offline_inference/test_ming_tts.py
|
||||
- vllm_omni/model_executor/models/ming_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/ming_tts.py
|
||||
- vllm_omni/deploy/ming_tts.yaml
|
||||
timeout_in_minutes: 20
|
||||
commands:
|
||||
- |
|
||||
@@ -411,6 +451,11 @@ steps:
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "TTS · VoxCPM2 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/offline_inference/test_voxcpm2_tts.py
|
||||
- tests/e2e/online_serving/test_voxcpm2_tts.py
|
||||
- vllm_omni/model_executor/models/voxcpm2/
|
||||
- vllm_omni/deploy/voxcpm2.yaml
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
- |
|
||||
@@ -434,6 +479,12 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "Omni · Higgs-Audio V2 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_higgs_audio_v2.py
|
||||
- tests/e2e/offline_inference/test_higgs_audio_v2.py
|
||||
- vllm_omni/model_executor/models/higgs_audio_v2/
|
||||
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v2.py
|
||||
- vllm_omni/deploy/higgs_audio_v2.yaml
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
- |
|
||||
@@ -805,6 +856,10 @@ steps:
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "Diffusion · Z Image Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/offline_inference/test_z_image.py
|
||||
- tests/e2e/online_serving/test_z_image.py
|
||||
- vllm_omni/diffusion/models/z_image/
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
@@ -822,6 +877,10 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "Diffusion · FLUX.2 Klein Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/offline_inference/test_flux2_klein.py
|
||||
- tests/e2e/online_serving/test_flux2_klein.py
|
||||
- vllm_omni/diffusion/models/flux2_klein/
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py tests/e2e/online_serving/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model'
|
||||
|
||||
+87
-37
@@ -215,47 +215,78 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "Entrypoints Test"
|
||||
- group: ":card_index_dividers: Entrypoints Test"
|
||||
depends_on: upload-ready-pipeline
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c "
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -sv tests/entrypoints/ -m 'core_model and cuda' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 2
|
||||
volumeMounts:
|
||||
steps:
|
||||
- label: "Entrypoints Test with H100"
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c "
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -sv tests/entrypoints/ -m 'core_model and cuda and H100' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
resources:
|
||||
limits:
|
||||
nvidia.com/gpu: 2
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
mountPath: /root/.cache/huggingface
|
||||
env:
|
||||
- name: HF_HOME
|
||||
value: /root/.cache/huggingface
|
||||
nodeSelector:
|
||||
node.kubernetes.io/instance-type: gpu-h100-sxm
|
||||
volumes:
|
||||
- name: devshm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
- name: hf-cache
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "Entrypoints Test with L4"
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c "
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -sv tests/entrypoints/ -m 'core_model and cuda and L4' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
always-pull: true
|
||||
propagate-environment: true
|
||||
shm-size: "8gb"
|
||||
environment:
|
||||
- "HF_HOME=/fsx/hf_cache"
|
||||
- "HF_TOKEN"
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- group: ":card_index_dividers: E2E Test"
|
||||
depends_on: upload-ready-pipeline
|
||||
steps:
|
||||
- label: "RLHF · VeRL-Omni E2E Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py
|
||||
- tests/e2e/offline_inference/custom_pipeline/
|
||||
- vllm_omni/diffusion/
|
||||
- vllm_omni/engine/
|
||||
- vllm_omni/entrypoints/
|
||||
- vllm_omni/inputs/
|
||||
- vllm_omni/outputs/
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -316,13 +347,17 @@ steps:
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "TTS · VoxCPM2 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_voxcpm2_tts.py
|
||||
- vllm_omni/model_executor/models/voxcpm2/
|
||||
- vllm_omni/deploy/voxcpm2.yaml
|
||||
timeout_in_minutes: 30
|
||||
commands:
|
||||
- |
|
||||
timeout 30m bash -c "
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model'
|
||||
pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
@@ -339,6 +374,11 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Qwen3-TTS CustomVoice Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -346,7 +386,7 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -360,6 +400,11 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Qwen3-TTS Base Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_base.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -369,7 +414,7 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -383,6 +428,11 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "TTS · Higgs-Audio-V3 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_higgs_audio_v3.py
|
||||
- vllm_omni/model_executor/models/higgs_audio_v3/
|
||||
- vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py
|
||||
- vllm_omni/deploy/higgs_multimodal_qwen3.yaml
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c "
|
||||
@@ -392,7 +442,7 @@ steps:
|
||||
pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model'
|
||||
"
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
|
||||
@@ -142,15 +142,12 @@ WORKDIR /workspace/vllm-omni
|
||||
COPY . .
|
||||
|
||||
ENV VLLM_OMNI_TARGET_DEVICE=xpu
|
||||
RUN uv pip install --no-cache-dir ".[dev]" --no-build-isolation
|
||||
|
||||
# FIX triton
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip uninstall triton triton-xpu && \
|
||||
uv pip install triton-xpu==3.7.0
|
||||
|
||||
# remove torch bundled oneccl to avoid conflicts
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
echo "triton @ https://example.com/blocked" > /tmp/vllm_omni_constraints.txt && \
|
||||
PIP_CONSTRAINT=/tmp/vllm_omni_constraints.txt \
|
||||
uv pip install --no-build-isolation \
|
||||
--extra-index-url https://mirrors.ustc.edu.cn/pypi/simple/ \
|
||||
".[dev]" && \
|
||||
uv pip uninstall oneccl oneccl-devel
|
||||
|
||||
FROM vllm-omni AS vllm-omni-openai
|
||||
|
||||
@@ -268,8 +268,8 @@ Before entering specific testing levels, the project establishes two common spec
|
||||
1. ***PR Checklist ([Tests Style](../ci/tests_style.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance.
|
||||
2. ***CI Failure Explanation ([CI Failures](../ci/failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency.
|
||||
|
||||
<<<<<<< ready
|
||||
## Notes
|
||||
|
||||
### Diff-aware Buildkite uploads (`source_file_dependencies`)
|
||||
|
||||
L2 (`.buildkite/test-ready.yml`) and L3 (`.buildkite/test-merge.yml`) pipelines can **skip unrelated GPU jobs at upload time** based on the PR diff. This is implemented by `.buildkite/scripts/upload_pipeline.py`, which filters steps before calling `buildkite-agent pipeline upload`.
|
||||
@@ -302,12 +302,17 @@ Steps **without** `source_file_dependencies` are always uploaded (subject to the
|
||||
|
||||
To balance CI cost and coverage:
|
||||
|
||||
- **Always run** (no `source_file_dependencies`): baseline groups such as Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3), and **all E2E jobs on L4 docker queues** (`gpu_1_queue`, `gpu_4_queue`).
|
||||
- **Diff-gated** (`source_file_dependencies` set): **H100 E2E jobs only** (`queue: mithril-h100-pool` under the E2E Test group)—typically the model’s pytest file(s) plus related `vllm_omni/` model, deploy, and stage paths.
|
||||
- **Always run** (no `source_file_dependencies`): baseline groups outside E2E Test—e.g. Simple Test, Diffusion unit tests, Engine/Model Executor, Distributed, Custom Pipeline, Entrypoints (L2), LoRA / Entrypoints (L3).
|
||||
- **Diff-gated** (`source_file_dependencies` set): **every leaf job under the E2E Test group** in `test-ready.yml` and `test-merge.yml`, regardless of queue (`mithril-h100-pool`, `gpu_1_queue`, or `gpu_4_queue`). Each step lists the smallest set of prefixes that should trigger it—typically:
|
||||
- pytest file(s) exercised by the job (online and/or offline);
|
||||
- model code under `vllm_omni/model_executor/models/` or `vllm_omni/diffusion/models/`;
|
||||
- related `vllm_omni/model_executor/stage_input_processors/` and `vllm_omni/deploy/*.yaml` when applicable.
|
||||
|
||||
Adding a new expensive H100 E2E step: list the test file(s) and the smallest set of source prefixes that should trigger it. Prefer **per-step** deps on leaf jobs rather than a broad group-level list unless every child shares the same paths.
|
||||
Adding a new E2E step: add `source_file_dependencies` on the leaf job with those prefixes. Prefer **per-step** deps rather than a broad group-level list unless every child shares the same paths.
|
||||
|
||||
#### YAML example
|
||||
#### YAML examples
|
||||
|
||||
H100 E2E (kubernetes / `mithril-h100-pool`):
|
||||
|
||||
```yaml
|
||||
- label: "Diffusion · Qwen Image Test"
|
||||
@@ -320,6 +325,21 @@ Adding a new expensive H100 E2E step: list the test file(s) and the smallest set
|
||||
queue: "mithril-h100-pool"
|
||||
```
|
||||
|
||||
Docker E2E (`gpu_1_queue` / `gpu_4_queue`)—same key, same upload-time filtering:
|
||||
|
||||
```yaml
|
||||
- label: "TTS · Qwen3-TTS CustomVoice Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen3_tts_customvoice.py
|
||||
- vllm_omni/model_executor/models/qwen3_tts/
|
||||
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
|
||||
- vllm_omni/deploy/qwen3_tts.yaml
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py ...
|
||||
agents:
|
||||
queue: "gpu_4_queue"
|
||||
```
|
||||
|
||||
A **group** may also define `source_file_dependencies`; nested steps inherit filtering as a unit—the whole group is dropped if no prefix matches.
|
||||
|
||||
#### Local dry-run
|
||||
@@ -339,7 +359,7 @@ On a PR build, Buildkite logs from `upload_pipeline.py` include lines such as `s
|
||||
|
||||
- Implementation: `.buildkite/scripts/upload_pipeline.py`
|
||||
- L2/L3 diff skip does **not** replace label-based triggers (`ready`, `merge-test`); it only reduces which steps appear **after** the pipeline is already scheduled.
|
||||
=======
|
||||
|
||||
### Test helper environment variables
|
||||
|
||||
Some shared helpers under `tests/helpers/` honor optional environment variables for local debugging. These are **not** set in CI by default.
|
||||
@@ -361,7 +381,6 @@ Example (Windows PowerShell):
|
||||
$env:VLLM_OMNI_KEEP_REQUEST_MEDIA = "1"
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio
|
||||
```
|
||||
>>>>>>> main
|
||||
|
||||
## Chapter 1: L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification
|
||||
|
||||
@@ -397,7 +416,7 @@ A clear directory structure is key to managing test cases efficiently.
|
||||
### 1.4 Execution Method and Example
|
||||
|
||||
- ***Trigger Timing***: **`PR with ready label`**. That is, when a developer adds a "ready for review" or similar label to a PR on platforms like GitHub, L1 and L2 tests are automatically triggered.
|
||||
- ***Diff-aware step skipping***: On L2, expensive **H100 E2E** jobs may be omitted at pipeline upload when the PR diff does not touch their [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) paths; L4 docker jobs and non-E2E steps still run. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
|
||||
- ***Diff-aware step skipping***: On L2, **E2E Test** jobs may be omitted at pipeline upload when the PR diff does not touch their [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) prefixes (H100 and docker queues alike); non-E2E groups still always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
|
||||
- ***Execution Environment***: L1 uses ***CPU*** environment; L2 requires ***GPU*** environment.
|
||||
- ***Script Example***:
|
||||
|
||||
@@ -501,7 +520,7 @@ L3 level testing executes after code is merged into the main branch. Its core pu
|
||||
### 2.4 Execution Method and Example
|
||||
|
||||
- ***Trigger Timing***: **`PR Merged`**. Automatically triggered after code review is approved and merged into the main branch (typically via `merge-test` label on the PR before merge).
|
||||
- ***Diff-aware step skipping***: Same [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) mechanism as L2—only **H100 E2E** steps are diff-gated; L4 docker E2E and other L3 groups always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
|
||||
- ***Diff-aware step skipping***: Same [`source_file_dependencies`](#diff-aware-buildkite-uploads-source_file_dependencies) mechanism as L2—**all E2E Test** leaf steps are diff-gated; other L3 groups always upload. See [Diff-aware Buildkite uploads](#diff-aware-buildkite-uploads-source_file_dependencies).
|
||||
- ***Execution Environment***: ***GPU*** servers.
|
||||
- ***Script Example***:
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Step-Audio2 Offline Inference Examples
|
||||
|
||||
This directory contains examples for running offline inference with Step-Audio2 using vLLM-Omni.
|
||||
|
||||
## Model Overview
|
||||
|
||||
Step-Audio2 is a two-stage audio model:
|
||||
|
||||
- **Stage 0 (Thinker)**: Audio understanding → Text + Audio tokens
|
||||
- Input: Audio (16kHz)
|
||||
- Output: Text transcription + Audio tokens for synthesis
|
||||
|
||||
- **Stage 1 (Token2Wav)**: Audio synthesis
|
||||
- Input: Audio tokens + Speaker prompt wav
|
||||
- Output: Synthesized audio waveform (24kHz)
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
| Mode | GPU Configuration | VRAM Required |
|
||||
|------|-------------------|---------------|
|
||||
| ASR (S2T) | 1x GPU | ~20-25GB |
|
||||
| TTS/S2ST (single GPU) | 1x GPU | ~40-50GB |
|
||||
| TTS/S2ST (multi GPU) | 2x GPU | GPU0: ~28GB, GPU1: ~22GB |
|
||||
|
||||
**Tested on:**
|
||||
- 1x NVIDIA H100 80GB (single-card S2ST)
|
||||
- 2x NVIDIA A10 40GB (multi-card S2ST)
|
||||
|
||||
**Notes:**
|
||||
- Single GPU mode requires high VRAM due to both stages sharing memory
|
||||
- Multi GPU mode separates Stage 0 (Thinker) and Stage 1 (Token2Wav) across GPUs
|
||||
- VRAM usage can be adjusted via `gpu_memory_utilization` in stage config
|
||||
|
||||
## Performance Benchmark
|
||||
|
||||
### vLLM-Omni vs Official Step-Audio2
|
||||
|
||||
Single request latency comparison between vLLM-Omni and official Step-Audio2 implementation.
|
||||
|
||||
| Task | Tokens | vllm-omni | Step-Audio2 | Speedup |
|
||||
|------|--------|-----------|-------------|---------|
|
||||
| S2ST | ~85 | 5.45s | 7.36s | **1.35x** |
|
||||
| S2ST | ~160 | 6.67s | 13.92s | **2.09x** |
|
||||
| S2ST | ~315 | 9.42s | 31.50s | **3.34x** |
|
||||
| TTS | ~1024 | 16.65s | ~87s | **~5.2x** |
|
||||
|
||||
**Key observations:**
|
||||
- Speedup increases with sequence length due to vLLM's efficient KV cache management
|
||||
- TTS (pure generation) shows the largest speedup (~5x)
|
||||
- S2ST benefits from optimized multi-stage pipeline
|
||||
|
||||
**Benchmark environment:**
|
||||
- GPU: NVIDIA H100 80GB (single card)
|
||||
- Model: Step-Audio2-mini
|
||||
- Warmup: 1 run, Measured: 3 runs (averaged)
|
||||
|
||||
### Async Chunk Streaming Performance
|
||||
|
||||
Comparison between sequential (non-async) and async chunk modes via `/v1/audio/speech` TTS endpoint.
|
||||
|
||||
| Mode | Mean TTFP | Mean E2E | Mean RTF | Audio Throughput |
|
||||
|------|-----------|----------|----------|-----------------|
|
||||
| Sequential | 4316ms | 4316ms | 0.938 | 1.07x realtime |
|
||||
| **Async Chunk** | **1437ms** | 4362ms | 0.949 | 1.06x realtime |
|
||||
| **Improvement** | **-67% (3x faster)** | ~same | ~same | ~same |
|
||||
|
||||
**Key observations:**
|
||||
- Async chunk reduces **time-to-first-audio (TTFP) by 67%** (4.3s → 1.4s)
|
||||
- E2E latency remains comparable — async chunk overlaps Thinker decode with Token2Wav synthesis
|
||||
- RTF < 1 in both modes (real-time capable)
|
||||
- Sequential mode: TTFP ≈ E2E (must wait for all audio tokens before synthesis starts)
|
||||
- Async chunk mode: Token2Wav starts after first 28 tokens (chunk_size=25 + lookahead=3)
|
||||
|
||||
**Benchmark environment:**
|
||||
- GPU: 4x NVIDIA RTX 3090 24GB (TP=2 for Thinker, 1 GPU for Token2Wav)
|
||||
- Model: Step-Audio2-mini
|
||||
- Endpoint: `/v1/audio/speech` (10 prompts, concurrency=1)
|
||||
- Measured via `bench_tts_serve.py`
|
||||
|
||||
## Installation
|
||||
|
||||
Make sure you have installed vLLM-Omni and all required dependencies:
|
||||
|
||||
```bash
|
||||
# Install vLLM-Omni
|
||||
pip install vllm-omni
|
||||
|
||||
# Install Step-Audio2 (REQUIRED for Token2Wav stage)
|
||||
pip install step-audio2
|
||||
```
|
||||
|
||||
## Model Setup
|
||||
|
||||
### Option 1: Auto-download from HuggingFace (Recommended)
|
||||
|
||||
The script will **automatically download** the model on first run:
|
||||
|
||||
```bash
|
||||
# Just run without specifying --model, it will auto-download stepfun-ai/Step-Audio2-mini
|
||||
python end2end.py --query-type audio_to_text
|
||||
|
||||
# Or explicitly specify the HuggingFace model
|
||||
python end2end.py --query-type audio_to_text --model stepfun-ai/Step-Audio2-mini
|
||||
```
|
||||
|
||||
Models will be cached in `~/.cache/huggingface/hub/` for future use.
|
||||
|
||||
**Available models**:
|
||||
- `stepfun-ai/Step-Audio2-mini` (smaller, faster)
|
||||
- `stepfun-ai/Step-Audio2-7B` (larger, better quality)
|
||||
|
||||
### Option 2: Manual Download (for offline use)
|
||||
|
||||
Download and use locally:
|
||||
|
||||
```bash
|
||||
# Download from HuggingFace
|
||||
huggingface-cli download stepfun-ai/Step-Audio2-mini --local-dir ./models/Step-Audio2-mini
|
||||
|
||||
# Then use the local path
|
||||
python end2end.py --query-type audio_to_text --model ./models/Step-Audio2-mini
|
||||
```
|
||||
|
||||
Ensure the model directory contains:
|
||||
```
|
||||
Step-Audio2-mini/
|
||||
├── config.json
|
||||
├── model.safetensors (or pytorch_model.bin)
|
||||
├── tokenizer.json
|
||||
├── tokenizer_config.json
|
||||
└── token2wav/ # Token2Wav models (REQUIRED)
|
||||
├── speech_tokenizer_v2_25hz.onnx # Audio tokenizer
|
||||
├── campplus.onnx # Speaker encoder
|
||||
├── flow.yaml # Flow model config
|
||||
├── flow.pt # Flow model weights
|
||||
└── hift.pt # HiFT vocoder weights
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Audio to Text (ASR - Speech Recognition)
|
||||
|
||||
Transcribe audio to text:
|
||||
|
||||
```bash
|
||||
# Quick start - Using default model and test audio
|
||||
python end2end.py --query-type audio_to_text
|
||||
|
||||
# Using your own audio file (model will auto-download)
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path /path/to/input.wav
|
||||
|
||||
# With specific model
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path /path/to/input.wav \
|
||||
--model stepfun-ai/Step-Audio2-7B
|
||||
|
||||
# With custom question
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path input.wav \
|
||||
--question "What is the speaker saying?"
|
||||
```
|
||||
|
||||
**Output**: Text transcription saved to `output_step_audio2/00000_text.txt`
|
||||
|
||||
### 2. Text to Audio (TTS - Speech Synthesis)
|
||||
|
||||
Convert text to speech:
|
||||
|
||||
```bash
|
||||
# Basic TTS (model auto-downloads)
|
||||
python end2end.py --query-type text_to_audio \
|
||||
--text "Hello, this is a test of Step Audio 2 synthesis."
|
||||
|
||||
# With specific model
|
||||
python end2end.py --query-type text_to_audio \
|
||||
--text "Hello, this is a test." \
|
||||
--model stepfun-ai/Step-Audio2-7B
|
||||
```
|
||||
|
||||
**Note**: Speaker voice is controlled by the `STEP_AUDIO2_DEFAULT_PROMPT_WAV` environment variable or the default prompt wav bundled with the model.
|
||||
|
||||
**Output**:
|
||||
- Text: `output_step_audio2/00000_text.txt`
|
||||
- Audio: `output_step_audio2/00000_output.wav` (24kHz)
|
||||
|
||||
### 3. Audio to Audio (Voice Conversion)
|
||||
|
||||
Process input audio and generate output audio:
|
||||
|
||||
```bash
|
||||
# Basic voice conversion (model auto-downloads)
|
||||
python end2end.py --query-type audio_to_audio \
|
||||
--audio-path /path/to/source_audio.wav
|
||||
|
||||
# With specific model
|
||||
python end2end.py --query-type audio_to_audio \
|
||||
--audio-path source.wav \
|
||||
--model stepfun-ai/Step-Audio2-7B
|
||||
```
|
||||
|
||||
This mode:
|
||||
1. Understands the content in `--audio-path` (source)
|
||||
2. Generates audio output with the default voice
|
||||
|
||||
**Note**: To use a custom speaker voice, set the `STEP_AUDIO2_DEFAULT_PROMPT_WAV` environment variable.
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Use custom stage configuration
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--stage-configs-path /path/to/custom_config.yaml
|
||||
|
||||
# Multiple prompts (for batch testing)
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path input.wav \
|
||||
--num-prompts 5
|
||||
|
||||
# Custom output directory
|
||||
python end2end.py --query-type text_to_audio \
|
||||
--text "Test synthesis" \
|
||||
--output-dir ./my_outputs
|
||||
|
||||
# Enable detailed logging
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path input.wav \
|
||||
--enable-stats
|
||||
|
||||
# Adjust generation parameters
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path input.wav \
|
||||
--max-tokens 2048
|
||||
|
||||
# Use custom speaker voice via environment variable
|
||||
STEP_AUDIO2_DEFAULT_PROMPT_WAV=/path/to/speaker.wav python end2end.py \
|
||||
--query-type text_to_audio \
|
||||
--text "Hello world"
|
||||
|
||||
# Use Ray backend for distributed processing
|
||||
python end2end.py --query-type text_to_audio \
|
||||
--text "Hello world" \
|
||||
--worker-backend ray \
|
||||
--ray-address "auto"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Stage Configuration
|
||||
|
||||
The default configuration (`step_audio_2.yaml`) uses:
|
||||
|
||||
- **Stage 0 (Thinker)**: GPU 0, 80% memory
|
||||
- **Stage 1 (Token2Wav)**: GPU 1, 30% memory
|
||||
|
||||
For **single GPU** setup, edit the config to use `devices: "0"` for both stages.
|
||||
|
||||
### Sampling Parameters
|
||||
|
||||
- **Thinker (Stage 0)**:
|
||||
- Temperature: 0.7 (balanced creativity)
|
||||
- Top-p: 0.9
|
||||
- Max tokens: 1024 (configurable)
|
||||
|
||||
- **Token2Wav (Stage 1)**:
|
||||
- Temperature: 0.0 (deterministic)
|
||||
- Operates in generation mode (not sampling)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. ImportError: No module named 's3tokenizer'
|
||||
|
||||
**Solution**: Install Step-Audio2 package:
|
||||
```bash
|
||||
pip install step-audio2
|
||||
```
|
||||
|
||||
### 2. FileNotFoundError: prompt_wav file not found
|
||||
|
||||
**Solution**: Set the `STEP_AUDIO2_DEFAULT_PROMPT_WAV` environment variable to a valid audio file:
|
||||
```bash
|
||||
export STEP_AUDIO2_DEFAULT_PROMPT_WAV=/path/to/speaker.wav
|
||||
python end2end.py --query-type text_to_audio --text "Hello"
|
||||
```
|
||||
Or ensure the default prompt wav (`default_female.wav`) exists in your model directory.
|
||||
|
||||
### 3. FileNotFoundError: token2wav models not found
|
||||
|
||||
**Solution**: Ensure your model directory has the complete `token2wav/` subdirectory with all ONNX and PyTorch models.
|
||||
|
||||
### 4. CUDA Out of Memory
|
||||
|
||||
**Solutions**:
|
||||
- Use single GPU mode (set both stages to `devices: "0"`)
|
||||
- Reduce `gpu_memory_utilization` in config
|
||||
- Reduce `max_num_batched_tokens`
|
||||
- Process fewer prompts at once
|
||||
|
||||
### 5. Model not found in registry
|
||||
|
||||
**Solution**: Ensure you're using vLLM-Omni's entry point with `--omni` flag or install vllm-omni properly:
|
||||
```bash
|
||||
pip install vllm-omni
|
||||
```
|
||||
|
||||
## Output Files
|
||||
|
||||
The script generates files in the output directory (default: `output_step_audio2/`):
|
||||
|
||||
```
|
||||
output_step_audio2/
|
||||
├── 00000_text.txt # Text output from Thinker stage
|
||||
├── 00000_output.wav # Audio output from Token2Wav stage (24kHz)
|
||||
├── 00001_text.txt # (if multiple prompts)
|
||||
└── 00001_output.wav
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **First run is slow**: Stage initialization takes 20-60 seconds
|
||||
2. **Single GPU**: Set both stages to `devices: "0"` in config
|
||||
3. **Multiple prompts**: Use `--num-prompts N` for batch testing
|
||||
4. **Ray backend**: For multi-node or advanced scheduling
|
||||
5. **Logging**: Use `--enable-stats` to debug performance issues
|
||||
|
||||
## Speaker Voice Configuration
|
||||
|
||||
The Token2Wav stage requires a speaker prompt wav for voice conditioning. It is automatically resolved in this order:
|
||||
|
||||
1. `STEP_AUDIO2_DEFAULT_PROMPT_WAV` environment variable (if set)
|
||||
2. `{model_dir}/assets/default_female.wav`
|
||||
3. `{model_dir}/default_female.wav`
|
||||
|
||||
If none are found, set the environment variable explicitly:
|
||||
```bash
|
||||
export STEP_AUDIO2_DEFAULT_PROMPT_WAV=/path/to/speaker.wav
|
||||
```
|
||||
|
||||
**Guidelines for custom speaker prompt:**
|
||||
|
||||
- **Duration**: 3-10 seconds recommended
|
||||
- **Quality**: Clean audio, minimal background noise
|
||||
- **Format**: WAV, MP3, FLAC (will be resampled internally)
|
||||
- **Content**: Clear speech, representative of target voice
|
||||
|
||||
## Example Workflow
|
||||
|
||||
Complete example from audio to final output:
|
||||
|
||||
```bash
|
||||
# 1. ASR: Transcribe audio
|
||||
python end2end.py --query-type audio_to_text \
|
||||
--audio-path interview.wav \
|
||||
--model ./models/Step-Audio2-7B \
|
||||
--output-dir ./outputs
|
||||
|
||||
# 2. Check the transcription
|
||||
cat ./outputs/00000_text.txt
|
||||
|
||||
# 3. TTS: Synthesize new speech (with custom voice)
|
||||
STEP_AUDIO2_DEFAULT_PROMPT_WAV=./speaker_samples/female_voice.wav \
|
||||
python end2end.py --query-type text_to_audio \
|
||||
--text "The quick brown fox jumps over the lazy dog" \
|
||||
--model ./models/Step-Audio2-7B \
|
||||
--output-dir ./outputs
|
||||
|
||||
# 4. Listen to the result
|
||||
# Audio saved to: ./outputs/00000_output.wav
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Step-Audio2 Paper](https://arxiv.org/abs/2507.16632)
|
||||
- [vLLM-Omni Documentation](https://vllm-omni.readthedocs.io)
|
||||
- [Model on HuggingFace](https://huggingface.co/stepfun-ai/Step-Audio-2-mini)
|
||||
@@ -0,0 +1,527 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
This example shows how to use vLLM-Omni for running offline inference
|
||||
with Step-Audio2 model for audio understanding and generation.
|
||||
|
||||
Step-Audio2 is a two-stage model:
|
||||
- Stage 0 (Thinker): Audio understanding → Text + Audio tokens
|
||||
- Stage 1 (Token2Wav): Audio tokens → Waveform (24kHz)
|
||||
|
||||
Usage examples:
|
||||
# Audio to text (ASR)
|
||||
python end2end.py --query-type audio_to_text --audio-path input.wav --model-path "$MODEL_PATH"
|
||||
|
||||
# Text to audio (TTS with default voice)
|
||||
python end2end.py --query-type text_to_audio --model-path "$MODEL_PATH"
|
||||
|
||||
# Audio to audio (Voice conversion with default voice)
|
||||
python end2end.py --query-type audio_to_audio --audio-path input.wav --model-path "$MODEL_PATH"
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import librosa # noqa: TID251
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from vllm.assets.audio import AudioAsset
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
|
||||
SEED = 42
|
||||
|
||||
|
||||
class QueryResult(NamedTuple):
|
||||
inputs: dict
|
||||
limit_mm_per_prompt: dict[str, int]
|
||||
|
||||
|
||||
def get_audio_to_text_query(
|
||||
audio_path: str | None = None,
|
||||
question: str | None = None,
|
||||
sampling_rate: int = 16000,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Audio Speech Recognition (ASR) - Convert audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
question: Question about the audio (optional)
|
||||
sampling_rate: Target sampling rate (16kHz for Step-Audio2)
|
||||
|
||||
Returns:
|
||||
QueryResult with prompt and audio data
|
||||
"""
|
||||
if question is None:
|
||||
question = "请记录下你所听到的语音内容。"
|
||||
|
||||
prompt = (
|
||||
f"<|im_start|>system\n{question}<|im_end|>\n<|im_start|>user\n<audio_patch><|im_end|>\n<|im_start|>assistant\n"
|
||||
)
|
||||
|
||||
# Load audio
|
||||
if audio_path:
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
audio_signal, sr = librosa.load(audio_path, sr=sampling_rate)
|
||||
audio_data = (audio_signal.astype(np.float32), sr)
|
||||
else:
|
||||
# Use default test audio
|
||||
audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
# Resample to 16kHz if needed
|
||||
if audio_data[1] != sampling_rate:
|
||||
audio_signal = librosa.resample(audio_data[0], orig_sr=audio_data[1], target_sr=sampling_rate)
|
||||
audio_data = (audio_signal.astype(np.float32), sampling_rate)
|
||||
|
||||
return QueryResult(
|
||||
inputs={
|
||||
"prompt": prompt,
|
||||
"multi_modal_data": {
|
||||
"audio": audio_data,
|
||||
},
|
||||
},
|
||||
limit_mm_per_prompt={"audio": 1},
|
||||
)
|
||||
|
||||
|
||||
def get_text_to_audio_query(
|
||||
text: str | None = None,
|
||||
sampling_rate: int = 16000,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Text-to-Speech (TTS) - Convert text to audio.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
sampling_rate: Target sampling rate (16kHz for Step-Audio2)
|
||||
|
||||
Returns:
|
||||
QueryResult with text prompt and speaker wav path
|
||||
|
||||
Note:
|
||||
For audio generation, the prompt must end with "<tts_start>"
|
||||
to signal the model to generate audio tokens instead of just text.
|
||||
"""
|
||||
if text is None:
|
||||
text = "Hello, this is a test of Step Audio 2 text to speech synthesis."
|
||||
|
||||
prompt = (
|
||||
"<|im_start|>system\n"
|
||||
"请逐字朗读用户提供的文本,不要改写或补充。<|im_end|>\n"
|
||||
"<|im_start|>user\n"
|
||||
f"{text}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n"
|
||||
"<tts_start>"
|
||||
)
|
||||
|
||||
inputs = {
|
||||
"prompt": prompt,
|
||||
}
|
||||
|
||||
return QueryResult(
|
||||
inputs=inputs,
|
||||
limit_mm_per_prompt={},
|
||||
)
|
||||
|
||||
|
||||
def get_audio_to_audio_query(
|
||||
audio_path: str | None = None,
|
||||
question: str | None = None,
|
||||
sampling_rate: int = 16000,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Audio-to-Audio - Voice conversion/cloning.
|
||||
|
||||
Args:
|
||||
audio_path: Path to source audio file
|
||||
question: Question/instruction about the audio
|
||||
sampling_rate: Target sampling rate (16kHz for Step-Audio2)
|
||||
|
||||
Returns:
|
||||
QueryResult with audio input
|
||||
|
||||
Note:
|
||||
This mode processes input audio and generates output audio.
|
||||
Speaker voice is controlled by STEP_AUDIO2_DEFAULT_PROMPT_WAV env var.
|
||||
"""
|
||||
if question is None:
|
||||
question = "请仔细聆听这段语音,然后复述其内容。"
|
||||
|
||||
prompt = (
|
||||
"<|im_start|>system\n"
|
||||
f"{question}<|im_end|>\n"
|
||||
"<|im_start|>user\n"
|
||||
"<audio_patch><|im_end|>\n"
|
||||
"<|im_start|>assistant\n"
|
||||
"<tts_start>"
|
||||
)
|
||||
|
||||
# Load source audio
|
||||
if audio_path:
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
audio_signal, sr = librosa.load(audio_path, sr=sampling_rate)
|
||||
audio_data = (audio_signal.astype(np.float32), sr)
|
||||
|
||||
else:
|
||||
audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
if audio_data[1] != sampling_rate:
|
||||
audio_signal = librosa.resample(audio_data[0], orig_sr=audio_data[1], target_sr=sampling_rate)
|
||||
audio_data = (audio_signal.astype(np.float32), sampling_rate)
|
||||
|
||||
return QueryResult(
|
||||
inputs={
|
||||
"prompt": prompt,
|
||||
"multi_modal_data": {
|
||||
"audio": audio_data,
|
||||
},
|
||||
},
|
||||
limit_mm_per_prompt={"audio": 1},
|
||||
)
|
||||
|
||||
|
||||
query_map = {
|
||||
"audio_to_text": get_audio_to_text_query,
|
||||
"text_to_audio": get_text_to_audio_query,
|
||||
"audio_to_audio": get_audio_to_audio_query,
|
||||
}
|
||||
|
||||
|
||||
def main(args):
|
||||
# Use local model path or HuggingFace model name
|
||||
if not args.model:
|
||||
sys.exit(1)
|
||||
|
||||
model_name = args.model
|
||||
|
||||
# Check if it's a local path
|
||||
if os.path.isdir(model_name):
|
||||
# Verify essential files exist
|
||||
config_path = os.path.join(model_name, "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
sys.exit(1)
|
||||
|
||||
# Get query configuration
|
||||
query_func = query_map[args.query_type]
|
||||
|
||||
# Build query based on type
|
||||
if args.query_type == "audio_to_text":
|
||||
query_result = query_func(
|
||||
audio_path=args.audio_path,
|
||||
question=args.question,
|
||||
sampling_rate=args.sampling_rate,
|
||||
)
|
||||
elif args.query_type == "text_to_audio":
|
||||
query_result = query_func(
|
||||
text=args.text,
|
||||
sampling_rate=args.sampling_rate,
|
||||
)
|
||||
elif args.query_type == "audio_to_audio":
|
||||
query_result = query_func(
|
||||
audio_path=args.audio_path,
|
||||
question=args.question,
|
||||
sampling_rate=args.sampling_rate,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown query type: {args.query_type}")
|
||||
|
||||
# Initialize vLLM-Omni with Step-Audio2
|
||||
# Resolve stage config path
|
||||
if args.stage_configs_path:
|
||||
stage_config_path = args.stage_configs_path
|
||||
else:
|
||||
configs_dir = Path(__file__).parent.parent.parent.parent / "vllm_omni/model_executor/stage_configs"
|
||||
if args.query_type == "audio_to_text":
|
||||
# ASR only needs Thinker (Stage 0), no Token2Wav
|
||||
stage_config_path = str(configs_dir / "step_audio_2_asr.yaml")
|
||||
if not os.path.exists(stage_config_path):
|
||||
stage_config_path = str(configs_dir / "step_audio_2.yaml")
|
||||
else:
|
||||
# TTS/S2ST need both stages
|
||||
stage_config_path = str(configs_dir / "step_audio_2.yaml")
|
||||
|
||||
omni_llm = Omni(
|
||||
model=model_name,
|
||||
log_stats=args.enable_stats,
|
||||
log_file=("step_audio2_pipeline.log" if args.enable_stats else None),
|
||||
init_sleep_seconds=args.init_sleep_seconds,
|
||||
batch_timeout=args.batch_timeout,
|
||||
init_timeout=args.init_timeout,
|
||||
shm_threshold_bytes=args.shm_threshold_bytes,
|
||||
stage_configs_path=stage_config_path,
|
||||
worker_backend=args.worker_backend,
|
||||
ray_address=args.ray_address,
|
||||
)
|
||||
|
||||
# Configure sampling parameters for each stage
|
||||
# Stage 0 (Thinker): Generate text and audio tokens
|
||||
# Note: For audio generation tasks, we need to allow the model to generate audio tokens
|
||||
# Audio tokens are in range [151696, 158257], so we should not stop too early
|
||||
# Qwen EOS token is typically 151645 (<|im_end|>), but we want to allow audio generation
|
||||
# So we don't set stop_token_ids for audio tasks, but we do set it for text-only tasks
|
||||
stop_token_ids = None
|
||||
if args.query_type == "audio_to_text":
|
||||
# For ASR, stop at EOS token
|
||||
stop_token_ids = [151645] # Qwen EOS token <|im_end|>
|
||||
|
||||
# Adjust sampling parameters based on query type
|
||||
# For audio generation tasks, we need higher repetition_penalty to avoid text loops
|
||||
# and allow the model to transition to audio token generation
|
||||
repetition_penalty = 1.1 if args.query_type in ["text_to_audio", "audio_to_audio"] else 1.05
|
||||
|
||||
thinker_sampling_params = SamplingParams(
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
top_k=-1,
|
||||
max_tokens=args.max_tokens,
|
||||
seed=SEED,
|
||||
detokenize=True,
|
||||
repetition_penalty=repetition_penalty,
|
||||
stop_token_ids=stop_token_ids,
|
||||
)
|
||||
|
||||
# Stage 1 (Token2Wav): Convert audio tokens to waveform
|
||||
# Note: This stage doesn't actually sample, it's deterministic generation
|
||||
token2wav_sampling_params = SamplingParams(
|
||||
temperature=0.0,
|
||||
top_p=1.0,
|
||||
top_k=-1,
|
||||
max_tokens=1,
|
||||
seed=SEED,
|
||||
detokenize=False,
|
||||
)
|
||||
|
||||
if args.query_type == "audio_to_text":
|
||||
# ASR: single stage, only Thinker
|
||||
sampling_params_list = [thinker_sampling_params]
|
||||
else:
|
||||
# TTS/S2ST: two stages
|
||||
sampling_params_list = [thinker_sampling_params, token2wav_sampling_params]
|
||||
|
||||
# Prepare prompts
|
||||
if args.num_prompts > 1:
|
||||
prompts = [query_result.inputs for _ in range(args.num_prompts)]
|
||||
else:
|
||||
prompts = [query_result.inputs]
|
||||
|
||||
# Run inference
|
||||
omni_outputs = omni_llm.generate(prompts, sampling_params_list)
|
||||
|
||||
# Create output directory
|
||||
output_dir = args.output_dir
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
def _get_request_index(req_id: str, fallback: int) -> int:
|
||||
"""Parse request index from vLLM request_id.
|
||||
|
||||
vLLM may return IDs like "0_<uuid>". Use the leading integer if present,
|
||||
otherwise fall back to the loop index.
|
||||
"""
|
||||
try:
|
||||
return int(req_id)
|
||||
except (TypeError, ValueError):
|
||||
if isinstance(req_id, str):
|
||||
head = req_id.split("_", 1)[0]
|
||||
if head.isdigit():
|
||||
return int(head)
|
||||
return fallback
|
||||
|
||||
# Process outputs from each stage
|
||||
for stage_idx, stage_outputs in enumerate(omni_outputs):
|
||||
# request_output may be a single object or a list
|
||||
raw_output = stage_outputs.request_output
|
||||
outputs_list = raw_output if isinstance(raw_output, list) else [raw_output]
|
||||
|
||||
if stage_outputs.final_output_type == "text":
|
||||
# Stage 0 (Thinker) text output
|
||||
for i, output in enumerate(outputs_list):
|
||||
request_id = _get_request_index(output.request_id, i)
|
||||
text_output = output.outputs[0].text
|
||||
|
||||
# Save to file
|
||||
out_txt = os.path.join(output_dir, f"{request_id:05d}_text.txt")
|
||||
with open(out_txt, "w", encoding="utf-8") as f:
|
||||
f.write(f"Prompt:\n{prompts[request_id]['prompt']}\n\n")
|
||||
f.write(f"Output:\n{text_output}\n")
|
||||
print(f"Text output saved to: {out_txt}")
|
||||
print(f"Text: {text_output[:200]}")
|
||||
|
||||
elif stage_outputs.final_output_type == "audio":
|
||||
# Stage 1 (Token2Wav) audio output
|
||||
for i, output in enumerate(outputs_list):
|
||||
request_id = _get_request_index(output.request_id, i)
|
||||
|
||||
# Get audio tensor (24kHz)
|
||||
mm_out = {}
|
||||
if hasattr(output, "multimodal_output") and output.multimodal_output:
|
||||
mm_out = output.multimodal_output
|
||||
elif output.outputs and hasattr(output.outputs[0], "multimodal_output"):
|
||||
mm_out = output.outputs[0].multimodal_output or {}
|
||||
audio_tensor = None
|
||||
for key in ("audio", "wav", "waveform", "audio_pcm", "pcm", "model_outputs"):
|
||||
if key in mm_out and mm_out[key] is not None:
|
||||
audio_tensor = mm_out[key]
|
||||
break
|
||||
|
||||
if audio_tensor is not None:
|
||||
# Save audio file
|
||||
output_wav = os.path.join(output_dir, f"{request_id:05d}_output.wav")
|
||||
if isinstance(audio_tensor, list):
|
||||
chunks = []
|
||||
for chunk in audio_tensor:
|
||||
if chunk is None:
|
||||
continue
|
||||
if isinstance(chunk, torch.Tensor):
|
||||
chunk_np = chunk.detach().cpu().numpy()
|
||||
else:
|
||||
chunk_np = np.asarray(chunk)
|
||||
if chunk_np.size > 0:
|
||||
chunks.append(chunk_np)
|
||||
if not chunks:
|
||||
continue
|
||||
audio_numpy = np.concatenate(chunks, axis=-1)
|
||||
elif isinstance(audio_tensor, torch.Tensor):
|
||||
audio_numpy = audio_tensor.detach().cpu().numpy()
|
||||
else:
|
||||
audio_numpy = np.asarray(audio_tensor)
|
||||
sf.write(output_wav, audio_numpy, samplerate=24000)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = FlexibleArgumentParser(description="Demo on using vLLM-Omni for offline inference with Step-Audio2")
|
||||
|
||||
# Model and config
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
type=str,
|
||||
required=True,
|
||||
help=(
|
||||
"Model path (required). Can be:\n"
|
||||
" - Local path: /path/to/step-audio-2\n"
|
||||
" - HuggingFace ID: stepfun-ai/Step-Audio2-mini"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stage-configs-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to stage config YAML file (default: use built-in step_audio_2.yaml)",
|
||||
)
|
||||
|
||||
# Query configuration
|
||||
parser.add_argument(
|
||||
"--query-type",
|
||||
"-q",
|
||||
type=str,
|
||||
default="audio_to_text",
|
||||
choices=query_map.keys(),
|
||||
help="Query type: audio_to_text (ASR), text_to_audio (TTS), audio_to_audio (voice conversion)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-path",
|
||||
"-a",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to input audio file (for audio_to_text and audio_to_audio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text",
|
||||
"-t",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Text to synthesize (for text_to_audio mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--question",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Question/instruction about the audio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sampling-rate",
|
||||
type=int,
|
||||
default=16000,
|
||||
help="Audio sampling rate for input (default: 16000 Hz)",
|
||||
)
|
||||
|
||||
# Generation parameters
|
||||
parser.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Maximum tokens to generate in Thinker stage (default: 1024)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-prompts",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of times to repeat the prompt (for testing)",
|
||||
)
|
||||
|
||||
# System configuration
|
||||
parser.add_argument(
|
||||
"--enable-stats",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable detailed statistics logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--init-sleep-seconds",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Sleep seconds after starting each stage (default: 20)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-timeout",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Batch timeout in seconds (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--init-timeout",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Initialization timeout in seconds (default: 300)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shm-threshold-bytes",
|
||||
type=int,
|
||||
default=65536,
|
||||
help="Shared memory threshold in bytes (default: 65536)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worker-backend",
|
||||
type=str,
|
||||
default="multi_process",
|
||||
choices=["multi_process", "ray"],
|
||||
help="Worker backend (default: multi_process)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ray-address",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Ray cluster address (for ray backend)",
|
||||
)
|
||||
|
||||
# Output
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
"-o",
|
||||
type=str,
|
||||
default="output_step_audio2",
|
||||
help="Output directory for generated files (default: output_step_audio2)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
main(args)
|
||||
@@ -0,0 +1,193 @@
|
||||
# Step-Audio2 Online Serving
|
||||
|
||||
This directory contains examples for running Step-Audio2 with vLLM-Omni's online serving API.
|
||||
|
||||
## Installation
|
||||
|
||||
Please refer to [README.md](../../../README.md)
|
||||
|
||||
## Launch the Server
|
||||
|
||||
```bash
|
||||
# Async chunk mode (recommended — lower first-packet latency for TTS)
|
||||
vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092 \
|
||||
--stage-configs-path vllm_omni/model_executor/stage_configs/step_audio_2_async_chunk.yaml \
|
||||
--trust-remote-code --enforce-eager
|
||||
```
|
||||
|
||||
Sequential mode:
|
||||
```bash
|
||||
vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092 \
|
||||
--stage-configs-path vllm_omni/model_executor/stage_configs/step_audio_2.yaml \
|
||||
--trust-remote-code --enforce-eager
|
||||
```
|
||||
|
||||
With local model:
|
||||
```bash
|
||||
vllm serve /path/to/Step-Audio-2-mini --omni --port 8092 \
|
||||
--trust-remote-code --enforce-eager
|
||||
```
|
||||
|
||||
## Send Requests
|
||||
|
||||
### TTS via `/v1/audio/speech` (Recommended)
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/step_audio2
|
||||
|
||||
# Python client
|
||||
python openai_speech_client.py --text "你好世界"
|
||||
|
||||
# With custom system prompt
|
||||
python openai_speech_client.py --text "Hello, how are you?" \
|
||||
--instructions "You are a friendly assistant."
|
||||
|
||||
# Save to specific file
|
||||
python openai_speech_client.py --text "你好世界" -o output.wav
|
||||
```
|
||||
|
||||
Or via curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8092/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"stepfun-ai/Step-Audio2-mini","input":"你好世界","voice":"default"}' \
|
||||
--output output.wav
|
||||
```
|
||||
|
||||
This endpoint bypasses the chat template and directly triggers TTS mode. It supports async chunk streaming for low first-packet latency.
|
||||
|
||||
**Note**: Speaker voice is controlled by `STEP_AUDIO2_DEFAULT_PROMPT_WAV` env var on the server side.
|
||||
|
||||
### Chat Completions (ASR / S2ST)
|
||||
|
||||
```bash
|
||||
# Audio to Text (ASR)
|
||||
python openai_chat_completion_client.py --query-type audio_to_text
|
||||
|
||||
# Audio to Audio (S2ST)
|
||||
python openai_chat_completion_client.py --query-type audio_to_audio --audio-path /path/to/input.wav
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `--query-type`, `-q` | Query type: `audio_to_text`, `text_to_audio`, `audio_to_audio` |
|
||||
| `--audio-path`, `-a` | Path to input audio file (local or URL) |
|
||||
| `--text`, `-t` | Text to synthesize (for TTS mode) |
|
||||
| `--prompt`, `-p` | Custom prompt/question |
|
||||
| `--output-dir`, `-o` | Output directory for audio files (default: `output_online`) |
|
||||
| `--api-base` | API base URL (default: `http://localhost:8092/v1`) |
|
||||
|
||||
### Curl (Chat Completions)
|
||||
|
||||
```bash
|
||||
# Audio to Text
|
||||
bash run_curl.sh audio_to_text
|
||||
|
||||
# Text to Audio
|
||||
bash run_curl.sh text_to_audio
|
||||
|
||||
# Audio to Audio
|
||||
bash run_curl.sh audio_to_audio
|
||||
```
|
||||
|
||||
## Query Types
|
||||
|
||||
### 1. Audio to Text (ASR)
|
||||
|
||||
Transcribe audio to text.
|
||||
|
||||
```bash
|
||||
python openai_chat_completion_client.py \
|
||||
--query-type audio_to_text \
|
||||
--audio-path /path/to/speech.wav \
|
||||
--prompt "Transcribe this audio."
|
||||
```
|
||||
|
||||
### 2. Text to Audio (TTS)
|
||||
|
||||
Convert text to speech:
|
||||
|
||||
```bash
|
||||
# Via speech endpoint (recommended, returns WAV directly)
|
||||
python openai_speech_client.py --text "Hello, welcome to Step-Audio2."
|
||||
|
||||
# Via chat completions
|
||||
python openai_chat_completion_client.py \
|
||||
--query-type text_to_audio \
|
||||
--text "Hello, welcome to Step-Audio2."
|
||||
```
|
||||
|
||||
### 3. Audio to Audio (S2ST)
|
||||
|
||||
Process input audio and generate text transcription + audio output.
|
||||
|
||||
```bash
|
||||
python openai_chat_completion_client.py \
|
||||
--query-type audio_to_audio \
|
||||
--audio-path /path/to/source.wav
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
- **Text output**: Printed to console
|
||||
- **Audio output**: Saved to `output_online/audio_0.wav` (24kHz WAV)
|
||||
|
||||
## API Format
|
||||
|
||||
Step-Audio2 uses the OpenAI-compatible chat completions API:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "stepfun-ai/Step-Audio2-mini",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "Transcribe the audio."}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio_url", "audio_url": {"url": "..."}},
|
||||
{"type": "text", "text": "Please transcribe."}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sampling_params_list": [
|
||||
{"temperature": 0.7, "max_tokens": 1024},
|
||||
{"temperature": 0.0, "max_tokens": 1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Async Chunk vs Sequential
|
||||
|
||||
Benchmark via `/v1/audio/speech` (4x RTX 3090, 10 prompts, concurrency=1):
|
||||
|
||||
| Mode | Mean TTFP | Mean E2E | Mean RTF |
|
||||
|------|-----------|----------|----------|
|
||||
| Sequential | 4316ms | 4316ms | 0.938 |
|
||||
| **Async Chunk** | **1437ms** | 4362ms | 0.949 |
|
||||
|
||||
Async chunk reduces TTFP by **67%** by streaming audio token chunks from Thinker to Token2Wav as they are generated. RTF < 1 in both modes (real-time capable).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server not responding
|
||||
- Check if the server is running: `curl http://localhost:8092/v1/models`
|
||||
- Verify the port number matches
|
||||
|
||||
### FileNotFoundError: prompt_wav file not found
|
||||
- Ensure `default_female.wav` exists at `{model_dir}/assets/default_female.wav`
|
||||
- Or set `STEP_AUDIO2_DEFAULT_PROMPT_WAV` environment variable when launching the server
|
||||
|
||||
### Audio not generated
|
||||
- For TTS, use the `/v1/audio/speech` endpoint (recommended) or `openai_speech_client.py`
|
||||
- For chat completions TTS, ensure the prompt ends with `<tts_start>`
|
||||
- Check server logs for errors
|
||||
|
||||
### Out of memory
|
||||
- Reduce `gpu_memory_utilization` in stage configs
|
||||
- Use a smaller batch size
|
||||
@@ -0,0 +1,342 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
OpenAI-compatible client for Step-Audio2 online serving.
|
||||
|
||||
Step-Audio2 is a two-stage model:
|
||||
- Stage 0 (Thinker): Audio understanding -> Text + Audio tokens
|
||||
- Stage 1 (Token2Wav): Audio tokens -> Waveform (24kHz)
|
||||
|
||||
Usage examples:
|
||||
# Start server first:
|
||||
vllm serve stepfun-ai/Step-Audio2-mini --omni --port 8092
|
||||
|
||||
# Audio to text (ASR)
|
||||
python openai_chat_completion_client.py --query-type audio_to_text
|
||||
|
||||
# Text to audio (TTS)
|
||||
python openai_chat_completion_client.py --query-type text_to_audio --text "Hello world"
|
||||
|
||||
# Audio to audio (Voice conversion)
|
||||
python openai_chat_completion_client.py --query-type audio_to_audio --audio-path input.wav
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
from openai import OpenAI
|
||||
from vllm.assets.audio import AudioAsset
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEED = 42
|
||||
|
||||
|
||||
def encode_base64_content_from_file(file_path: str) -> str:
|
||||
"""Encode a local file to base64 format."""
|
||||
with open(file_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def encode_base64_content_from_url(url: str) -> str:
|
||||
"""Encode content from URL to base64 format."""
|
||||
with requests.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return base64.b64encode(response.content).decode("utf-8")
|
||||
|
||||
|
||||
def get_audio_url(audio_path: str | None = None) -> str:
|
||||
"""Convert audio path to URL format for the API.
|
||||
|
||||
Args:
|
||||
audio_path: Local file path or URL. If None, uses default test audio.
|
||||
|
||||
Returns:
|
||||
Audio URL (either original URL or base64 data URL)
|
||||
"""
|
||||
if not audio_path:
|
||||
return AudioAsset("mary_had_lamb").url
|
||||
|
||||
if audio_path.startswith(("http://", "https://")):
|
||||
return audio_path
|
||||
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Detect MIME type
|
||||
audio_path_lower = audio_path.lower()
|
||||
if audio_path_lower.endswith(".wav"):
|
||||
mime_type = "audio/wav"
|
||||
elif audio_path_lower.endswith((".mp3", ".mpeg")):
|
||||
mime_type = "audio/mpeg"
|
||||
elif audio_path_lower.endswith(".ogg"):
|
||||
mime_type = "audio/ogg"
|
||||
elif audio_path_lower.endswith(".flac"):
|
||||
mime_type = "audio/flac"
|
||||
else:
|
||||
mime_type = "audio/wav"
|
||||
|
||||
audio_base64 = encode_base64_content_from_file(audio_path)
|
||||
return f"data:{mime_type};base64,{audio_base64}"
|
||||
|
||||
|
||||
def get_audio_to_text_query(
|
||||
audio_path: str | None = None,
|
||||
custom_prompt: str | None = None,
|
||||
) -> dict:
|
||||
"""Build query for Audio-to-Text (ASR) mode."""
|
||||
question = custom_prompt or "Please transcribe the audio content."
|
||||
audio_url = get_audio_url(audio_path)
|
||||
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}},
|
||||
{"type": "text", "text": question},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_text_to_audio_query(
|
||||
text: str | None = None,
|
||||
custom_prompt: str | None = None,
|
||||
) -> dict:
|
||||
"""Build query for Text-to-Audio (TTS) mode.
|
||||
|
||||
Returns a list of messages: user message + assistant message with <tts_start>.
|
||||
The assistant message triggers TTS mode and must be used with
|
||||
continue_final_message=True so the chat template does not append <|im_end|>.
|
||||
"""
|
||||
text_to_speak = text or "Hello, this is a test of Step Audio 2 text to speech."
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": text_to_speak}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<tts_start>",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_audio_to_audio_query(
|
||||
audio_path: str | None = None,
|
||||
custom_prompt: str | None = None,
|
||||
) -> dict:
|
||||
"""Build query for Audio-to-Audio (S2ST) mode.
|
||||
|
||||
Returns a list of messages: user message (with audio) + assistant message
|
||||
with <tts_start>. Must be used with continue_final_message=True.
|
||||
"""
|
||||
question = custom_prompt or "Please listen to this audio and repeat its content."
|
||||
audio_url = get_audio_url(audio_path)
|
||||
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}},
|
||||
{"type": "text", "text": question},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<tts_start>",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_system_prompt(query_type: str) -> dict:
|
||||
"""Get system prompt based on query type."""
|
||||
if query_type == "audio_to_text":
|
||||
system_text = "You are a speech recognition assistant. Transcribe the audio accurately."
|
||||
elif query_type == "text_to_audio":
|
||||
system_text = "You are a text-to-speech assistant. Read the text aloud exactly as provided."
|
||||
else: # audio_to_audio
|
||||
system_text = "You are an audio processing assistant. Listen and repeat the audio content."
|
||||
|
||||
return {
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": system_text}],
|
||||
}
|
||||
|
||||
|
||||
QUERY_MAP = {
|
||||
"audio_to_text": get_audio_to_text_query,
|
||||
"text_to_audio": get_text_to_audio_query,
|
||||
"audio_to_audio": get_audio_to_audio_query,
|
||||
}
|
||||
|
||||
|
||||
def run_step_audio2(args) -> None:
|
||||
"""Run Step-Audio2 inference via OpenAI-compatible API."""
|
||||
|
||||
# Initialize OpenAI client
|
||||
client = OpenAI(
|
||||
api_key="EMPTY",
|
||||
base_url=args.api_base,
|
||||
)
|
||||
|
||||
model_name = args.model
|
||||
|
||||
# Sampling parameters for each stage
|
||||
thinker_sampling_params = {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": -1,
|
||||
"max_tokens": args.max_tokens,
|
||||
"seed": SEED,
|
||||
"detokenize": True,
|
||||
"repetition_penalty": 1.1 if args.query_type != "audio_to_text" else 1.05,
|
||||
}
|
||||
|
||||
# For ASR, stop at EOS token
|
||||
if args.query_type == "audio_to_text":
|
||||
thinker_sampling_params["stop_token_ids"] = [151645]
|
||||
|
||||
token2wav_sampling_params = {
|
||||
"temperature": 0.0,
|
||||
"top_p": 1.0,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1,
|
||||
"seed": SEED,
|
||||
"detokenize": False,
|
||||
}
|
||||
|
||||
sampling_params_list = [
|
||||
thinker_sampling_params,
|
||||
token2wav_sampling_params,
|
||||
]
|
||||
|
||||
# Build query based on type
|
||||
query_func = QUERY_MAP[args.query_type]
|
||||
if args.query_type == "audio_to_text":
|
||||
user_message = query_func(audio_path=args.audio_path, custom_prompt=args.prompt)
|
||||
elif args.query_type == "text_to_audio":
|
||||
user_message = query_func(text=args.text, custom_prompt=args.prompt)
|
||||
else: # audio_to_audio
|
||||
user_message = query_func(audio_path=args.audio_path, custom_prompt=args.prompt)
|
||||
|
||||
system_message = get_system_prompt(args.query_type)
|
||||
|
||||
logger.info(f"Query type: {args.query_type}")
|
||||
logger.info(f"Model: {model_name}")
|
||||
logger.info("Sending request to server...")
|
||||
|
||||
# Build messages list.
|
||||
# TTS and S2ST return a list of messages (user + assistant with <tts_start>);
|
||||
# ASR returns a single user message dict.
|
||||
if isinstance(user_message, list):
|
||||
messages = [system_message] + user_message
|
||||
else:
|
||||
messages = [system_message, user_message]
|
||||
|
||||
# For TTS/S2ST, use continue_final_message so <tts_start> in the
|
||||
# assistant turn is not followed by <|im_end|>.
|
||||
extra_body: dict = {"sampling_params_list": sampling_params_list}
|
||||
if args.query_type in ("text_to_audio", "audio_to_audio"):
|
||||
extra_body["continue_final_message"] = True
|
||||
extra_body["add_generation_prompt"] = False
|
||||
|
||||
# Send request
|
||||
chat_completion = client.chat.completions.create(
|
||||
messages=messages,
|
||||
model=model_name,
|
||||
extra_body=extra_body,
|
||||
)
|
||||
|
||||
# Process response
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
audio_count = 0
|
||||
for choice in chat_completion.choices:
|
||||
if choice.message.audio:
|
||||
# Audio response
|
||||
audio_data = base64.b64decode(choice.message.audio.data)
|
||||
audio_file = os.path.join(args.output_dir, f"audio_{audio_count}.wav")
|
||||
with open(audio_file, "wb") as f:
|
||||
f.write(audio_data)
|
||||
logger.info(f"Audio saved to: {audio_file}")
|
||||
audio_count += 1
|
||||
|
||||
if choice.message.content:
|
||||
# Text response
|
||||
logger.info(f"Text output: {choice.message.content}")
|
||||
|
||||
if audio_count == 0 and args.query_type != "audio_to_text":
|
||||
logger.warning("No audio output received. Check server logs for details.")
|
||||
|
||||
logger.info("Done!")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = FlexibleArgumentParser(description="OpenAI-compatible client for Step-Audio2 online serving")
|
||||
|
||||
parser.add_argument(
|
||||
"--query-type",
|
||||
"-q",
|
||||
type=str,
|
||||
default="audio_to_text",
|
||||
choices=QUERY_MAP.keys(),
|
||||
help="Query type: audio_to_text (ASR), text_to_audio (TTS), audio_to_audio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-path",
|
||||
"-a",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to input audio file (local path or URL)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text",
|
||||
"-t",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Text to synthesize (for text_to_audio mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
"-p",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Custom prompt/question",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
type=str,
|
||||
default="stepfun-ai/Step-Audio2-mini",
|
||||
help="Model name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
type=str,
|
||||
default="http://localhost:8092/v1",
|
||||
help="API base URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Maximum tokens for Thinker stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
"-o",
|
||||
type=str,
|
||||
default="output_online",
|
||||
help="Output directory for audio files",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
run_step_audio2(args)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""OpenAI-compatible client for Step-Audio2 TTS via /v1/audio/speech endpoint.
|
||||
|
||||
Examples:
|
||||
# Basic TTS
|
||||
python openai_speech_client.py --text "你好世界"
|
||||
|
||||
# With custom system prompt
|
||||
python openai_speech_client.py --text "Hello, how are you?" \
|
||||
--instructions "You are a friendly assistant."
|
||||
|
||||
# Save to specific file
|
||||
python openai_speech_client.py --text "你好世界" -o output.wav
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_API_BASE = "http://localhost:8092"
|
||||
DEFAULT_API_KEY = "EMPTY"
|
||||
|
||||
|
||||
def run_tts_generation(args) -> None:
|
||||
"""Run TTS generation via /v1/audio/speech API."""
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"voice": args.voice,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
|
||||
if args.instructions:
|
||||
payload["instructions"] = args.instructions
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Text: {args.text}")
|
||||
print(f"Voice: {args.voice}")
|
||||
print("Generating audio...")
|
||||
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
response = client.post(api_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.text)
|
||||
return
|
||||
|
||||
try:
|
||||
text = response.content.decode("utf-8")
|
||||
if text.startswith('{"error"'):
|
||||
print(f"Error: {text}")
|
||||
return
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
output_path = args.output or "tts_output.wav"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"Audio saved to: {output_path}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Step-Audio2 TTS client via /v1/audio/speech",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
type=str,
|
||||
default=DEFAULT_API_BASE,
|
||||
help=f"API base URL (default: {DEFAULT_API_BASE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
type=str,
|
||||
default=DEFAULT_API_KEY,
|
||||
help="API key (default: EMPTY)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
type=str,
|
||||
default="stepfun-ai/Step-Audio2-mini",
|
||||
help="Model name/path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Text to synthesize",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--voice",
|
||||
type=str,
|
||||
default="default",
|
||||
help="Voice name (default: default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instructions",
|
||||
type=str,
|
||||
default=None,
|
||||
help="System prompt for the Thinker stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--response-format",
|
||||
type=str,
|
||||
default="wav",
|
||||
choices=["wav", "pcm"],
|
||||
help="Audio output format (default: wav)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output audio file path (default: tts_output.wav)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
run_tts_generation(args)
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Step-Audio2 curl client for online serving
|
||||
# Usage: bash run_curl.sh [audio_to_text|text_to_audio|audio_to_audio]
|
||||
|
||||
QUERY_TYPE="${1:-audio_to_text}"
|
||||
API_BASE="${API_BASE:-http://localhost:8092}"
|
||||
|
||||
# Validate query type
|
||||
if [[ ! "$QUERY_TYPE" =~ ^(audio_to_text|text_to_audio|audio_to_audio)$ ]]; then
|
||||
echo "Error: Invalid query type '$QUERY_TYPE'"
|
||||
echo "Usage: $0 [audio_to_text|text_to_audio|audio_to_audio]"
|
||||
echo " audio_to_text: Speech recognition (ASR)"
|
||||
echo " text_to_audio: Text-to-speech (TTS)"
|
||||
echo " audio_to_audio: Voice conversion"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SEED=42
|
||||
|
||||
# Default test audio URL
|
||||
MARY_HAD_LAMB_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/mary_had_lamb.ogg"
|
||||
|
||||
# Sampling parameters for Thinker stage
|
||||
thinker_sampling_params='{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1024,
|
||||
"seed": 42,
|
||||
"detokenize": true,
|
||||
"repetition_penalty": 1.05
|
||||
}'
|
||||
|
||||
# Sampling parameters for Token2Wav stage
|
||||
token2wav_sampling_params='{
|
||||
"temperature": 0.0,
|
||||
"top_p": 1.0,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1,
|
||||
"seed": 42,
|
||||
"detokenize": false
|
||||
}'
|
||||
|
||||
# Build request based on query type
|
||||
case "$QUERY_TYPE" in
|
||||
audio_to_text)
|
||||
system_content='[{"type": "text", "text": "You are a speech recognition assistant. Transcribe the audio accurately."}]'
|
||||
user_content='[
|
||||
{"type": "audio_url", "audio_url": {"url": "'"$MARY_HAD_LAMB_URL"'"}},
|
||||
{"type": "text", "text": "Please transcribe this audio."}
|
||||
]'
|
||||
# Add stop token for ASR
|
||||
thinker_sampling_params='{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1024,
|
||||
"seed": 42,
|
||||
"detokenize": true,
|
||||
"repetition_penalty": 1.05,
|
||||
"stop_token_ids": [151645]
|
||||
}'
|
||||
;;
|
||||
text_to_audio)
|
||||
system_content='[{"type": "text", "text": "You are a text-to-speech assistant. Read the text aloud exactly as provided."}]'
|
||||
user_content='[
|
||||
{"type": "text", "text": "Hello, this is a test of Step Audio 2 text to speech synthesis.<tts_start>"}
|
||||
]'
|
||||
thinker_sampling_params='{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1024,
|
||||
"seed": 42,
|
||||
"detokenize": true,
|
||||
"repetition_penalty": 1.1
|
||||
}'
|
||||
;;
|
||||
audio_to_audio)
|
||||
system_content='[{"type": "text", "text": "You are an audio processing assistant. Listen and repeat the audio content."}]'
|
||||
user_content='[
|
||||
{"type": "audio_url", "audio_url": {"url": "'"$MARY_HAD_LAMB_URL"'"}},
|
||||
{"type": "text", "text": "Please listen to this audio and repeat its content.<tts_start>"}
|
||||
]'
|
||||
thinker_sampling_params='{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": -1,
|
||||
"max_tokens": 1024,
|
||||
"seed": 42,
|
||||
"detokenize": true,
|
||||
"repetition_penalty": 1.1
|
||||
}'
|
||||
;;
|
||||
esac
|
||||
|
||||
sampling_params_list='[
|
||||
'"$thinker_sampling_params"',
|
||||
'"$token2wav_sampling_params"'
|
||||
]'
|
||||
|
||||
echo "Query type: $QUERY_TYPE"
|
||||
echo "API base: $API_BASE"
|
||||
echo "Sending request..."
|
||||
echo ""
|
||||
|
||||
output=$(curl -sS -X POST "${API_BASE}/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- <<EOF
|
||||
{
|
||||
"model": "stepfun-ai/Step-Audio2-mini",
|
||||
"sampling_params_list": $sampling_params_list,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": $system_content
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": $user_content
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Extract and display text content
|
||||
text_content=$(echo "$output" | jq -r '.choices[0].message.content // empty')
|
||||
if [[ -n "$text_content" ]]; then
|
||||
echo "Text output: $text_content"
|
||||
fi
|
||||
|
||||
# Check for audio content
|
||||
audio_data=$(echo "$output" | jq -r '.choices[0].message.audio.data // empty')
|
||||
if [[ -n "$audio_data" ]]; then
|
||||
echo "Audio output received (base64 encoded)"
|
||||
echo "To save audio, use the Python client or decode the base64 data"
|
||||
fi
|
||||
|
||||
# Check for errors
|
||||
error=$(echo "$output" | jq -r '.error // empty')
|
||||
if [[ -n "$error" ]]; then
|
||||
echo "Error: $error"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
@@ -281,6 +281,7 @@ extend-ignore-identifiers-re = [
|
||||
".*[Oo]no_[Aa]nna.*",
|
||||
".*cann.*",
|
||||
".*[Nn][Dd]3.*",
|
||||
".*updator.*",
|
||||
"byt5.*",
|
||||
]
|
||||
extend-ignore-re = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
-r common.txt
|
||||
onnxruntime-cann>=1.23.2
|
||||
torchaudio==2.9.0
|
||||
torchaudio==2.10.0
|
||||
@@ -9,6 +9,7 @@ import torch
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.ipc import (
|
||||
_SHM_TENSOR_THRESHOLD,
|
||||
DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
_pack_value_if_large,
|
||||
_unpack_if_shm_handle,
|
||||
pack_diffusion_output_shm,
|
||||
@@ -81,6 +82,31 @@ def test_diffusion_output_list_tensors_round_trip_through_shm() -> None:
|
||||
torch.testing.assert_close(output.output[1], frames[1])
|
||||
|
||||
|
||||
def test_rpc_result_envelope_diffusion_output_round_trips_through_shm() -> None:
|
||||
tensor = torch.arange(300_000, dtype=torch.float32)
|
||||
envelope = {
|
||||
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
"result": DiffusionOutput(output=tensor),
|
||||
"rank_statuses": [{"rank": 0, "ok": True}],
|
||||
}
|
||||
|
||||
packed = pack_diffusion_output_shm(envelope)
|
||||
|
||||
assert packed is envelope
|
||||
result = packed["result"]
|
||||
assert isinstance(result, DiffusionOutput)
|
||||
assert result.output["__tensor_shm__"] is True
|
||||
assert packed["rank_statuses"] == [{"rank": 0, "ok": True}]
|
||||
|
||||
unpacked = unpack_diffusion_output_shm(packed)
|
||||
|
||||
assert unpacked is envelope
|
||||
result = unpacked["result"]
|
||||
assert isinstance(result, DiffusionOutput)
|
||||
torch.testing.assert_close(result.output, tensor)
|
||||
assert unpacked["rank_statuses"] == [{"rank": 0, "ok": True}]
|
||||
|
||||
|
||||
def test_pack_value_keeps_tensor_at_threshold_inline() -> None:
|
||||
tensor = torch.arange(
|
||||
_SHM_TENSOR_THRESHOLD // torch.empty((), dtype=torch.float32).element_size(),
|
||||
|
||||
@@ -17,8 +17,10 @@ from vllm.v1.engine.exceptions import EngineDeadError
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
|
||||
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
|
||||
from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE
|
||||
from vllm_omni.diffusion.sched import RequestScheduler
|
||||
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import WorkerProc
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
@@ -305,6 +307,80 @@ class TestSerialEngineOperations:
|
||||
assert len(results) == 1
|
||||
assert results[0].error == "rank0"
|
||||
|
||||
def test_collective_rpc_all_rank_status_error_propagation(self):
|
||||
engine, _, _, res_q = _make_engine(num_gpus=2)
|
||||
|
||||
res_q.put(
|
||||
{
|
||||
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
"method": "add_lora",
|
||||
"result": True,
|
||||
"rank_statuses": [
|
||||
{"rank": 0, "ok": True, "bool_result": True},
|
||||
{
|
||||
"rank": 1,
|
||||
"ok": False,
|
||||
"error": "rank1 boom",
|
||||
"error_type": "RuntimeError",
|
||||
"traceback": "rank1 traceback",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
engine.collective_rpc("add_lora")
|
||||
error = str(excinfo.value)
|
||||
assert "rank 1" in error
|
||||
assert "rank1 boom" in error
|
||||
assert "rank1 traceback" in error
|
||||
|
||||
def test_collective_rpc_all_rank_bool_false_is_aggregated(self):
|
||||
engine, _, _, res_q = _make_engine(num_gpus=2)
|
||||
|
||||
res_q.put(
|
||||
{
|
||||
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
"method": "remove_lora",
|
||||
"result": True,
|
||||
"rank_statuses": [
|
||||
{"rank": 0, "ok": True, "bool_result": True},
|
||||
{"rank": 1, "ok": True, "bool_result": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert engine.collective_rpc("remove_lora") == [False]
|
||||
|
||||
def test_collective_rpc_collects_rank_status_only_for_control_plane_all_rank_rpc(self):
|
||||
executor, req_q, res_q = _make_executor(num_gpus=2)
|
||||
|
||||
res_q.put(_tagged_output("forward"))
|
||||
result = executor.collective_rpc(
|
||||
"execute_stepwise",
|
||||
unique_reply_rank=0,
|
||||
exec_all_ranks=True,
|
||||
)
|
||||
forward_rpc = req_q.get_nowait()
|
||||
|
||||
assert result.error == "forward"
|
||||
assert forward_rpc["exec_all_ranks"] is True
|
||||
assert forward_rpc["collect_rank_status"] is False
|
||||
|
||||
res_q.put(
|
||||
{
|
||||
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
"method": "remove_lora",
|
||||
"result": True,
|
||||
"rank_statuses": [{"rank": 0, "ok": True, "bool_result": True}],
|
||||
}
|
||||
)
|
||||
assert executor.collective_rpc("remove_lora") == [True]
|
||||
control_rpc = req_q.get_nowait()
|
||||
|
||||
assert control_rpc["exec_all_ranks"] is True
|
||||
assert control_rpc["collect_rank_status"] is True
|
||||
|
||||
def test_serial_add_req_then_collective_rpc(self):
|
||||
engine, _, req_q, res_q = _make_engine()
|
||||
wt = _start_worker(req_q, res_q, count=2)
|
||||
@@ -348,6 +424,115 @@ class TestSerialEngineOperations:
|
||||
engine.collective_rpc("anything")
|
||||
|
||||
|
||||
class TestWorkerProcRpcRankStatus:
|
||||
def _make_worker_proc(self, has_result_mq: bool = True):
|
||||
proc = object.__new__(WorkerProc)
|
||||
proc.gpu_id = 0
|
||||
proc.result_mq = object() if has_result_mq else None
|
||||
proc.worker = SimpleNamespace(execute_method=Mock(return_value=True))
|
||||
return proc
|
||||
|
||||
def test_execute_rpc_returns_rank_status_envelope(self, monkeypatch):
|
||||
proc = self._make_worker_proc()
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "get_world_size", lambda: 2)
|
||||
|
||||
def _all_gather_object(out, local):
|
||||
out[0] = local
|
||||
out[1] = {
|
||||
"rank": 1,
|
||||
"ok": False,
|
||||
"error": "rank1 boom",
|
||||
"error_type": "RuntimeError",
|
||||
"traceback": "trace",
|
||||
"bool_result": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "all_gather_object", _all_gather_object)
|
||||
|
||||
result, should_reply = proc.execute_rpc(
|
||||
{
|
||||
"method": "remove_lora",
|
||||
"args": (),
|
||||
"kwargs": {},
|
||||
"output_rank": 0,
|
||||
"exec_all_ranks": True,
|
||||
"collect_rank_status": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert should_reply is True
|
||||
assert result["type"] == DIFFUSION_RPC_RESULT_ENVELOPE
|
||||
assert result["result"] is True
|
||||
assert result["rank_statuses"][0]["rank"] == 0
|
||||
assert result["rank_statuses"][1]["rank"] == 1
|
||||
assert result["rank_statuses"][1]["ok"] is False
|
||||
|
||||
def test_execute_rpc_local_exception_is_reported_in_envelope(self, monkeypatch):
|
||||
proc = self._make_worker_proc()
|
||||
proc.worker.execute_method = Mock(side_effect=RuntimeError("local boom"))
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False)
|
||||
|
||||
result, should_reply = proc.execute_rpc(
|
||||
{
|
||||
"method": "add_lora",
|
||||
"args": (),
|
||||
"kwargs": {},
|
||||
"output_rank": 0,
|
||||
"exec_all_ranks": True,
|
||||
"collect_rank_status": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert should_reply is True
|
||||
assert result["type"] == DIFFUSION_RPC_RESULT_ENVELOPE
|
||||
assert len(result["rank_statuses"]) == 1
|
||||
status = result["rank_statuses"][0]
|
||||
assert status["rank"] == 0
|
||||
assert status["ok"] is False
|
||||
assert status["error"] == "local boom"
|
||||
assert status["error_type"] == "RuntimeError"
|
||||
assert status["bool_result"] is None
|
||||
assert "local boom" in status["traceback"]
|
||||
|
||||
def test_execute_rpc_rejects_collect_rank_status_without_all_ranks(self):
|
||||
proc = self._make_worker_proc()
|
||||
|
||||
with pytest.raises(ValueError, match="collect_rank_status requires exec_all_ranks=True"):
|
||||
proc.execute_rpc(
|
||||
{
|
||||
"method": "ping",
|
||||
"args": (),
|
||||
"kwargs": {},
|
||||
"output_rank": 0,
|
||||
"exec_all_ranks": False,
|
||||
"collect_rank_status": True,
|
||||
}
|
||||
)
|
||||
|
||||
proc.worker.execute_method.assert_not_called()
|
||||
|
||||
def test_execute_rpc_non_collect_exception_preserves_original_type(self):
|
||||
proc = self._make_worker_proc()
|
||||
original = ValueError("local boom")
|
||||
proc.worker.execute_method = Mock(side_effect=original)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
proc.execute_rpc(
|
||||
{
|
||||
"method": "bad",
|
||||
"args": (),
|
||||
"kwargs": {},
|
||||
"output_rank": 0,
|
||||
"exec_all_ranks": False,
|
||||
"collect_rank_status": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert excinfo.value is original
|
||||
|
||||
|
||||
# ───────── error handling: EngineDeadError propagation through layers ─────
|
||||
|
||||
|
||||
|
||||
@@ -200,9 +200,9 @@ def test_save_async(build_adapter):
|
||||
request = _req("req-1", RequestStatus.WAITING, external_req_id="external-1")
|
||||
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: {"x": [1], "finished": False}
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: {}
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
|
||||
task = adapter._pending_save_reqs.popleft()
|
||||
assert task["is_finished"] is False
|
||||
@@ -214,7 +214,7 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad
|
||||
request.num_computed_tokens = 10
|
||||
request.num_output_placeholders = 2
|
||||
|
||||
adapter.save_async(pooling_output=None, request=request)
|
||||
adapter.save_async(multimodal_output=None, request=request)
|
||||
|
||||
assert adapter.requests_num_chunks_sent["external-async"] == 8
|
||||
assert len(adapter._pending_save_reqs) == 1
|
||||
@@ -236,7 +236,7 @@ def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, m
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
)
|
||||
|
||||
assert cleanup_calls == [] # no terminal cleanup; meta.finished is false
|
||||
@@ -256,7 +256,7 @@ def test_send_single_request_empty_struct_goes_on_wire(build_adapter, monkeypatc
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": False}
|
||||
)
|
||||
|
||||
assert connector.put.called
|
||||
@@ -274,7 +274,7 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
|
||||
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
|
||||
)
|
||||
|
||||
sent_payload = connector.put.call_args.kwargs["data"]
|
||||
@@ -289,13 +289,13 @@ def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapt
|
||||
request.num_computed_tokens = 0
|
||||
adapter.requests_num_chunks_sent["ext-stream"] = 111
|
||||
|
||||
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
|
||||
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=False)
|
||||
|
||||
assert len(adapter._pending_save_reqs) == 0
|
||||
assert adapter.requests_num_chunks_sent["ext-stream"] == 111
|
||||
|
||||
adapter.requests_num_chunks_sent.pop("ext-stream")
|
||||
adapter.save_async(pooling_output=None, request=request, is_segment_finished=False)
|
||||
adapter.save_async(multimodal_output=None, request=request, is_segment_finished=False)
|
||||
|
||||
assert len(adapter._pending_save_reqs) == 1
|
||||
assert adapter.requests_num_chunks_sent["ext-stream"] == 0
|
||||
@@ -312,7 +312,7 @@ def test_send_single_request_cleans_up_after_finished_payload(build_adapter, mon
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
|
||||
|
||||
adapter._send_single_request(
|
||||
{"pooling_output": None, "request": request, "is_finished": True, "is_segment_finished": True}
|
||||
{"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": True}
|
||||
)
|
||||
|
||||
assert len(cleanup_calls) == 1
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Stage config for Step-Audio2 CI tests
|
||||
# Single Thinker stage (audio/text understanding → text). ASR/text-output
|
||||
# tests don't need Token2Wav; a two-stage config would deadlock on stage-1 skip.
|
||||
|
||||
# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: thinker
|
||||
model_arch: StepAudio2ThinkerForConditionalGeneration
|
||||
tensor_parallel_size: 1
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
max_model_len: 4096
|
||||
max_num_batched_tokens: 4096
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.8
|
||||
skip_mm_profiling: true
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: text # Output text + audio tokens
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.7
|
||||
top_p: 0.9
|
||||
top_k: -1
|
||||
max_tokens: 256
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.05
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -102,8 +104,8 @@ def _flatten_audio(audio) -> torch.Tensor:
|
||||
return torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu()
|
||||
|
||||
|
||||
def _extract_audio(multimodal_output: dict) -> torch.Tensor:
|
||||
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
|
||||
def _extract_audio(multimodal_output: Mapping[str, Any]) -> torch.Tensor:
|
||||
assert isinstance(multimodal_output, Mapping), f"Expected Mapping, got {type(multimodal_output)}"
|
||||
audio = multimodal_output.get("audio", multimodal_output.get("model_outputs"))
|
||||
assert audio is not None, f"No audio output found, keys={list(multimodal_output.keys())}"
|
||||
waveform = _flatten_audio(audio)
|
||||
@@ -112,7 +114,7 @@ def _extract_audio(multimodal_output: dict) -> torch.Tensor:
|
||||
return waveform
|
||||
|
||||
|
||||
def _extract_sample_rate(multimodal_output: dict) -> int:
|
||||
def _extract_sample_rate(multimodal_output: Mapping[str, Any]) -> int:
|
||||
sample_rate = multimodal_output.get("sr")
|
||||
if sample_rate is None:
|
||||
raise RuntimeError("Expected multimodal_output['sr']")
|
||||
@@ -133,29 +135,29 @@ def _extract_final_audio_outputs(outputs):
|
||||
if request_output is None:
|
||||
continue
|
||||
multimodal_output = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
final_outputs.append(item)
|
||||
continue
|
||||
completions = getattr(request_output, "outputs", None) or []
|
||||
if any(isinstance(getattr(completion, "multimodal_output", None), dict) for completion in completions):
|
||||
if any(isinstance(getattr(completion, "multimodal_output", None), Mapping) for completion in completions):
|
||||
final_outputs.append(item)
|
||||
return final_outputs
|
||||
|
||||
|
||||
def _extract_multimodal_output(output) -> dict:
|
||||
def _extract_multimodal_output(output) -> Mapping[str, Any]:
|
||||
multimodal_output = getattr(output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return multimodal_output
|
||||
|
||||
request_output = getattr(output, "request_output", None)
|
||||
if request_output is not None:
|
||||
multimodal_output = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return multimodal_output
|
||||
completions = getattr(request_output, "outputs", None) or []
|
||||
for completion in completions:
|
||||
multimodal_output = getattr(completion, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return multimodal_output
|
||||
|
||||
raise AssertionError("No multimodal audio output found in Ming generate results")
|
||||
|
||||
@@ -32,6 +32,7 @@ _OMNI_RUNNER_PARAM = (
|
||||
)
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skip(reason="issue#4361"),
|
||||
pytest.mark.full_model,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
E2E tests for Step-Audio2 model with audio input and audio output.
|
||||
|
||||
These tests verify:
|
||||
1. Processor loads correctly and handles audio input
|
||||
2. Field configuration for vLLM batching (flat_from_sizes)
|
||||
3. Tensor shapes are correct (3D not 4D for profiling)
|
||||
4. Basic E2E inference works with audio input
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
|
||||
models = ["stepfun-ai/Step-Audio-2-mini"]
|
||||
|
||||
stage_configs = [str(Path(__file__).parent / "stage_configs" / "step_audio2_ci.yaml")]
|
||||
|
||||
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
|
||||
|
||||
|
||||
def create_dummy_audio(sample_rate: int = 16000, duration_sec: float = 1.0) -> tuple[np.ndarray, int]:
|
||||
"""Create a dummy audio signal (sine wave) for testing."""
|
||||
t = np.linspace(0, duration_sec, int(sample_rate * duration_sec), dtype=np.float32)
|
||||
audio = 0.5 * np.sin(2 * np.pi * 440 * t)
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
class StepAudio2OmniRunner(OmniRunner):
|
||||
"""Test runner specialized for Step-Audio2 model."""
|
||||
|
||||
def get_omni_inputs(
|
||||
self,
|
||||
prompts: list[str] | str,
|
||||
system_prompt: str | None = None,
|
||||
audios=None,
|
||||
**kwargs,
|
||||
) -> list[dict]:
|
||||
"""Construct Omni input format for Step-Audio2."""
|
||||
if system_prompt is None:
|
||||
system_prompt = "你是一个语音对话助手,能够理解音频输入并生成语音回复。"
|
||||
|
||||
audio_placeholder = "<audio_patch>"
|
||||
|
||||
if isinstance(prompts, str):
|
||||
prompts = [prompts]
|
||||
|
||||
num_prompts = len(prompts)
|
||||
|
||||
if audios is None:
|
||||
audios_list = [None] * num_prompts
|
||||
elif isinstance(audios, list):
|
||||
audios_list = audios if len(audios) == num_prompts else [audios[0]] * num_prompts
|
||||
else:
|
||||
audios_list = [audios] * num_prompts
|
||||
|
||||
omni_inputs = []
|
||||
for i, prompt_text in enumerate(prompts):
|
||||
user_content = ""
|
||||
multi_modal_data = {}
|
||||
|
||||
audio = audios_list[i]
|
||||
if audio is not None:
|
||||
user_content += audio_placeholder
|
||||
multi_modal_data["audio"] = audio
|
||||
|
||||
user_content += prompt_text
|
||||
|
||||
full_prompt = (
|
||||
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
|
||||
f"<|im_start|>user\n{user_content}<|im_end|>\n"
|
||||
f"<|im_start|>assistant\n"
|
||||
)
|
||||
|
||||
input_dict = {"prompt": full_prompt}
|
||||
if multi_modal_data:
|
||||
input_dict["multi_modal_data"] = multi_modal_data
|
||||
|
||||
omni_inputs.append(input_dict)
|
||||
|
||||
return omni_inputs
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def step_audio2_runner():
|
||||
return StepAudio2OmniRunner
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_processor_loads_correctly():
|
||||
"""Test that Step-Audio2 processor can be imported and initialized."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
StepAudio2MultiModalProcessor,
|
||||
StepAudio2ProcessingInfo,
|
||||
StepAudio2Processor,
|
||||
)
|
||||
|
||||
assert StepAudio2MultiModalProcessor is not None
|
||||
assert StepAudio2ProcessingInfo is not None
|
||||
assert StepAudio2Processor is not None
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_audio_preprocessing():
|
||||
"""Test that audio preprocessing produces correct tensor shapes."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
log_mel_spectrogram,
|
||||
padding_mels,
|
||||
)
|
||||
|
||||
audio, sample_rate = create_dummy_audio(sample_rate=16000, duration_sec=1.0)
|
||||
|
||||
mel = log_mel_spectrogram(audio)
|
||||
|
||||
assert mel.ndim == 2, f"Expected 2D tensor, got {mel.ndim}D"
|
||||
assert mel.shape[0] == 128, f"Expected 128 mel bins, got {mel.shape[0]}"
|
||||
|
||||
mels = [mel, mel]
|
||||
padded_mels, mel_lens = padding_mels(mels)
|
||||
|
||||
assert padded_mels.ndim == 3, f"Expected 3D tensor, got {padded_mels.ndim}D"
|
||||
assert padded_mels.shape[0] == 2, f"Expected batch size 2, got {padded_mels.shape[0]}"
|
||||
assert padded_mels.shape[1] == 128, f"Expected 128 mel bins, got {padded_mels.shape[1]}"
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_feature_length_calculation():
|
||||
"""Test that audio feature length calculation is correct."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
calculate_audio_feature_length,
|
||||
)
|
||||
|
||||
assert calculate_audio_feature_length(1000) == 125
|
||||
|
||||
assert calculate_audio_feature_length(100) > 0
|
||||
|
||||
assert calculate_audio_feature_length(1) >= 1
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_token_config_constants():
|
||||
"""Test that token configuration constants are set correctly."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_constants import (
|
||||
DEFAULT_TOKEN_CONFIG,
|
||||
STEP_AUDIO2_AUDIO_END,
|
||||
STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID,
|
||||
STEP_AUDIO2_AUDIO_START,
|
||||
STEP_AUDIO2_AUDIO_VOCAB_SIZE,
|
||||
STEP_AUDIO2_TEXT_MAX,
|
||||
)
|
||||
|
||||
assert STEP_AUDIO2_TEXT_MAX < STEP_AUDIO2_AUDIO_START, "Text tokens should come before audio tokens"
|
||||
assert STEP_AUDIO2_AUDIO_END == STEP_AUDIO2_AUDIO_START + STEP_AUDIO2_AUDIO_VOCAB_SIZE - 1
|
||||
assert STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID > STEP_AUDIO2_TEXT_MAX
|
||||
assert STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID < STEP_AUDIO2_AUDIO_START
|
||||
|
||||
assert DEFAULT_TOKEN_CONFIG.text_max == STEP_AUDIO2_TEXT_MAX
|
||||
assert DEFAULT_TOKEN_CONFIG.audio_start == STEP_AUDIO2_AUDIO_START
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_mm_field_config_structure():
|
||||
"""Test that multimodal field config is properly structured for vLLM batching."""
|
||||
from vllm.multimodal.inputs import MultiModalFieldConfig
|
||||
|
||||
audio_lens = torch.tensor([100, 200, 150], dtype=torch.int32)
|
||||
|
||||
field_config = MultiModalFieldConfig.flat_from_sizes("audio", audio_lens)
|
||||
|
||||
assert field_config is not None
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_audio_encoder_output_shape():
|
||||
"""Test that audio encoder produces correct output shapes."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
Adaptor,
|
||||
AudioEncoder,
|
||||
)
|
||||
|
||||
encoder = AudioEncoder(n_mels=128, n_ctx=1500, n_state=512, n_head=8, n_layer=6)
|
||||
|
||||
batch_size = 2
|
||||
n_mels = 128
|
||||
time_steps = 400
|
||||
x = torch.randn(batch_size, n_mels, time_steps)
|
||||
x_len = torch.tensor([time_steps, time_steps // 2], dtype=torch.int32)
|
||||
|
||||
encoded, encoded_lens = encoder(x, x_len)
|
||||
|
||||
assert encoded.ndim == 3, f"Expected 3D tensor, got {encoded.ndim}D"
|
||||
assert encoded.shape[0] == batch_size
|
||||
assert encoded.shape[2] == 512
|
||||
|
||||
adapter = Adaptor(n_state=512, n_hidden=4096, kernel_size=3, stride=2)
|
||||
adapted = adapter(encoded)
|
||||
|
||||
assert adapted.ndim == 3
|
||||
assert adapted.shape[0] == batch_size
|
||||
assert adapted.shape[2] == 4096
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_token_separation():
|
||||
"""Test that token separation works correctly."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
StepAudio2ThinkerForConditionalGeneration,
|
||||
)
|
||||
|
||||
token_ids = [100, 200, 151700, 300, 151800, 400]
|
||||
|
||||
text_tokens, audio_tokens = StepAudio2ThinkerForConditionalGeneration.separate_tokens(token_ids)
|
||||
|
||||
assert text_tokens == [100, 200, 300, 400]
|
||||
assert audio_tokens == [4, 104]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
def test_has_audio_output():
|
||||
"""Test detection of audio tokens in output."""
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_thinker import (
|
||||
StepAudio2ThinkerForConditionalGeneration,
|
||||
)
|
||||
|
||||
text_only = [100, 200, 300]
|
||||
assert not StepAudio2ThinkerForConditionalGeneration.has_audio_output(text_only)
|
||||
|
||||
with_audio = [100, 200, 151700, 300]
|
||||
assert StepAudio2ThinkerForConditionalGeneration.has_audio_output(with_audio)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
def test_audio_to_text_and_audio(step_audio2_runner: type[StepAudio2OmniRunner], test_config: tuple[str, str]) -> None:
|
||||
"""Test processing audio input and generating text + audio output."""
|
||||
model, stage_config_path = test_config
|
||||
|
||||
with step_audio2_runner(model, seed=42, stage_configs_path=stage_config_path) as runner:
|
||||
audio = create_dummy_audio(sample_rate=16000, duration_sec=2.0)
|
||||
|
||||
outputs = runner.generate_multimodal(
|
||||
prompts="请复述这段音频的内容。",
|
||||
audios=audio,
|
||||
)
|
||||
|
||||
assert len(outputs) > 0
|
||||
|
||||
text_output = None
|
||||
for stage_output in outputs:
|
||||
if stage_output.final_output_type == "text":
|
||||
text_output = stage_output
|
||||
break
|
||||
|
||||
assert text_output is not None
|
||||
assert text_output.request_output is not None
|
||||
text_content = text_output.request_output.outputs[0].text
|
||||
assert text_content is not None
|
||||
assert len(text_content.strip()) > 0
|
||||
|
||||
audio_output = None
|
||||
for stage_output in outputs:
|
||||
if stage_output.final_output_type == "audio":
|
||||
audio_output = stage_output
|
||||
break
|
||||
|
||||
if audio_output is not None:
|
||||
assert audio_output.request_output is not None
|
||||
audio_tensor = audio_output.request_output.outputs[0].multimodal_output.get("audio")
|
||||
if audio_tensor is not None:
|
||||
assert audio_tensor.numel() > 0
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.parametrize("test_config", test_params)
|
||||
def test_text_only_input(step_audio2_runner: type[StepAudio2OmniRunner], test_config: tuple[str, str]) -> None:
|
||||
"""Test processing text-only input (no audio)."""
|
||||
model, stage_config_path = test_config
|
||||
|
||||
with step_audio2_runner(model, seed=42, stage_configs_path=stage_config_path) as runner:
|
||||
outputs = runner.generate_multimodal(
|
||||
prompts="你好,请用中文回答。",
|
||||
audios=None,
|
||||
)
|
||||
|
||||
assert len(outputs) > 0
|
||||
|
||||
text_output = None
|
||||
for stage_output in outputs:
|
||||
if stage_output.final_output_type == "text":
|
||||
text_output = stage_output
|
||||
break
|
||||
|
||||
assert text_output is not None
|
||||
assert text_output.request_output is not None
|
||||
text_content = text_output.request_output.outputs[0].text
|
||||
assert text_content is not None
|
||||
assert len(text_content.strip()) > 0
|
||||
@@ -1,6 +1,7 @@
|
||||
"""E2E test for VoxCPM2 native AR offline inference."""
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -23,7 +24,7 @@ def voxcpm2_engine():
|
||||
|
||||
def _extract_audio(multimodal_output: dict) -> torch.Tensor:
|
||||
"""Extract the final complete audio tensor from multimodal output."""
|
||||
assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}"
|
||||
assert isinstance(multimodal_output, (dict, Mapping)), f"Expected dict/Mapping, got {type(multimodal_output)}"
|
||||
|
||||
# Output processor accumulates per-step audio chunks under "audio".
|
||||
audio = multimodal_output.get("audio")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Stage config for Step-Audio2 CI tests
|
||||
# Stage 0: Thinker (audio understanding → text + audio tokens)
|
||||
# Stage 1: Token2Wav (audio tokens → waveform)
|
||||
|
||||
# The following config has been verified on 2x 24GB GPU (L4/RTX3090/RTX4090).
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: thinker
|
||||
model_arch: StepAudio2ThinkerForConditionalGeneration
|
||||
tensor_parallel_size: 1
|
||||
hf_overrides:
|
||||
architectures: ["StepAudio2ThinkerForConditionalGeneration"]
|
||||
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
max_model_len: 1024
|
||||
max_num_batched_tokens: 1024
|
||||
max_num_seqs: 1
|
||||
gpu_memory_utilization: 0.7
|
||||
skip_mm_profiling: true
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: text # Output text + audio tokens
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.7
|
||||
top_p: 0.9
|
||||
top_k: -1
|
||||
max_tokens: 256
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.05
|
||||
|
||||
- stage_id: 1
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
|
||||
model_stage: token2wav
|
||||
model_arch: StepAudio2Token2WavModel
|
||||
worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
hf_overrides:
|
||||
architectures: ["StepAudio2Token2WavModel"]
|
||||
gpu_memory_utilization: 0.2
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 1024
|
||||
engine_output_type: audio # Output waveform
|
||||
engine_input_source: [0]
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.step_audio2.thinker2token2wav
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: false
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 1
|
||||
edges:
|
||||
- from: 0
|
||||
to: 1
|
||||
window_size: -1
|
||||
@@ -25,11 +25,11 @@ from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
# ``omni`` for all tests; 001/002 also get ``full_model`` via ``TestMossTtsNanoFull`` (003 is core+advanced only, no full_model).
|
||||
pytestmark = [pytest.mark.full_model, pytest.mark.tts]
|
||||
|
||||
_SKIP_ISSUE_3168 = pytest.mark.skip(
|
||||
reason="https://github.com/vllm-project/vllm-omni/issues/3168",
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.skip(reason="issue#4361"),
|
||||
pytest.mark.full_model,
|
||||
pytest.mark.tts,
|
||||
]
|
||||
|
||||
MODEL = "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||
REF_AUDIO_URL = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
E2E tests for Step-Audio2 online serving with concurrent requests.
|
||||
|
||||
Tests:
|
||||
1. Single request via API
|
||||
2. Concurrent throughput (1/2/4/8 concurrent requests)
|
||||
3. Latency percentiles (p50/p95/p99)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
import pytest
|
||||
from vllm.utils.network_utils import get_open_port
|
||||
|
||||
MODEL = "stepfun-ai/Step-Audio-2-mini"
|
||||
STAGE_CONFIG = str(Path(__file__).parent / "stage_configs" / "step_audio2_ci.yaml")
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
|
||||
class OmniServer:
|
||||
"""Omniserver for vLLM-Omni tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
serve_args: list[str],
|
||||
*,
|
||||
env_dict: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.serve_args = serve_args
|
||||
self.env_dict = env_dict
|
||||
self.proc: subprocess.Popen | None = None
|
||||
self.host = "127.0.0.1"
|
||||
self.port = get_open_port()
|
||||
|
||||
def _start_server(self) -> None:
|
||||
"""Start the vLLM-Omni server subprocess."""
|
||||
env = os.environ.copy()
|
||||
env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
if self.env_dict is not None:
|
||||
env.update(self.env_dict)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"vllm_omni.entrypoints.cli.main",
|
||||
"serve",
|
||||
self.model,
|
||||
"--omni",
|
||||
"--host",
|
||||
self.host,
|
||||
"--port",
|
||||
str(self.port),
|
||||
] + self.serve_args
|
||||
|
||||
print(f"Launching OmniServer with: {' '.join(cmd)}")
|
||||
self.proc = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
)
|
||||
|
||||
max_wait = 600 # 10 minutes
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex((self.host, self.port))
|
||||
if result == 0:
|
||||
print(f"Server ready on {self.host}:{self.port}")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
raise RuntimeError(f"Server failed to start within {max_wait} seconds")
|
||||
|
||||
def __enter__(self):
|
||||
self._start_server()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.proc:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.proc.wait()
|
||||
|
||||
|
||||
def create_dummy_audio_base64(duration_sec: float = 5.0, sample_rate: int = 16000) -> str:
|
||||
"""Create dummy audio as base64."""
|
||||
import base64
|
||||
import io
|
||||
import wave
|
||||
|
||||
num_samples = int(duration_sec * sample_rate)
|
||||
t = np.linspace(0, duration_sec, num_samples, dtype=np.float32)
|
||||
audio = (0.5 * np.sin(2 * np.pi * 440 * t) * 32767).astype(np.int16)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(sample_rate)
|
||||
wav_file.writeframes(audio.tobytes())
|
||||
|
||||
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def create_s2st_request(audio_base64: str) -> dict:
|
||||
"""Create S2ST request payload."""
|
||||
# Use audio_url format with base64 data URL
|
||||
audio_url = f"data:audio/wav;base64,{audio_base64}"
|
||||
return {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": "请仔细聆听这段语音,然后复述其内容。"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": audio_url},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "<tts_start>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
}
|
||||
|
||||
|
||||
async def send_request(session: aiohttp.ClientSession, url: str, payload: dict) -> tuple[float, int, str]:
|
||||
"""Send single request and return (latency, num_tokens, response_text)."""
|
||||
start = time.perf_counter()
|
||||
async with session.post(url, json=payload) as response:
|
||||
result = await response.json()
|
||||
latency = time.perf_counter() - start
|
||||
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
text = result["choices"][0].get("message", {}).get("content", "")
|
||||
tokens = result.get("usage", {}).get("completion_tokens", len(text.split()))
|
||||
else:
|
||||
text = str(result)
|
||||
tokens = 0
|
||||
|
||||
return latency, tokens, text
|
||||
|
||||
|
||||
async def benchmark_concurrent(
|
||||
url: str,
|
||||
payload: dict,
|
||||
num_concurrent: int,
|
||||
num_requests: int,
|
||||
warmup: int = 2,
|
||||
) -> dict:
|
||||
"""Run concurrent benchmark with true sustained concurrency.
|
||||
|
||||
Uses semaphore to maintain exactly num_concurrent requests in flight at all times,
|
||||
rather than batch-and-wait approach. Measures real wall clock time.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(num_concurrent)
|
||||
|
||||
async def bounded_request(session: aiohttp.ClientSession):
|
||||
async with semaphore:
|
||||
return await send_request(session, url, payload)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if warmup > 0:
|
||||
print(f" Warming up ({warmup} requests)...", end=" ", flush=True)
|
||||
for _ in range(warmup):
|
||||
try:
|
||||
await send_request(session, url, payload)
|
||||
except Exception as e:
|
||||
print(f"Warmup failed: {e}")
|
||||
print("done")
|
||||
|
||||
print(f" Running {num_requests} requests with concurrency={num_concurrent}...", end=" ", flush=True)
|
||||
wall_start = time.perf_counter()
|
||||
|
||||
tasks = [bounded_request(session) for _ in range(num_requests)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
wall_time = time.perf_counter() - wall_start
|
||||
print(f"done ({wall_time:.2f}s)")
|
||||
|
||||
all_latencies = []
|
||||
all_tokens = []
|
||||
errors = 0
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
print(f" Request failed: {result}")
|
||||
errors += 1
|
||||
continue
|
||||
latency, tokens, _ = result
|
||||
all_latencies.append(latency)
|
||||
all_tokens.append(tokens)
|
||||
|
||||
if not all_latencies:
|
||||
return {"error": "All requests failed"}
|
||||
|
||||
latencies = np.array(all_latencies)
|
||||
total_tokens = sum(all_tokens)
|
||||
successful_requests = len(all_latencies)
|
||||
|
||||
return {
|
||||
"num_concurrent": num_concurrent,
|
||||
"num_requests": num_requests,
|
||||
"successful_requests": successful_requests,
|
||||
"errors": errors,
|
||||
"wall_time": wall_time,
|
||||
"p50_latency": float(np.percentile(latencies, 50)),
|
||||
"p95_latency": float(np.percentile(latencies, 95)),
|
||||
"p99_latency": float(np.percentile(latencies, 99)),
|
||||
"mean_latency": float(np.mean(latencies)),
|
||||
"min_latency": float(np.min(latencies)),
|
||||
"max_latency": float(np.max(latencies)),
|
||||
"throughput_req_per_sec": successful_requests / wall_time,
|
||||
"throughput_tokens_per_sec": total_tokens / wall_time,
|
||||
}
|
||||
|
||||
|
||||
class TestStepAudio2OnlineServing:
|
||||
"""Online serving tests for Step-Audio2."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def server(self):
|
||||
"""Start vLLM-Omni server."""
|
||||
with OmniServer(MODEL, ["--stage-configs-path", STAGE_CONFIG]) as omni_server:
|
||||
yield f"http://{omni_server.host}:{omni_server.port}/v1/chat/completions"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_request(self, server):
|
||||
"""Test single request works."""
|
||||
audio_base64 = create_dummy_audio_base64(duration_sec=2.0)
|
||||
payload = create_s2st_request(audio_base64)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
latency, tokens, text = await send_request(session, server, payload)
|
||||
|
||||
assert latency > 0
|
||||
assert tokens >= 0
|
||||
print(f"Single request: latency={latency:.2f}s, tokens={tokens}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("num_concurrent", [1, 2, 4])
|
||||
async def test_concurrent_throughput(self, server, num_concurrent):
|
||||
"""Test concurrent throughput at different concurrency levels."""
|
||||
audio_base64 = create_dummy_audio_base64(duration_sec=5.0)
|
||||
payload = create_s2st_request(audio_base64)
|
||||
|
||||
num_requests = num_concurrent * 3
|
||||
|
||||
stats = await benchmark_concurrent(server, payload, num_concurrent, num_requests)
|
||||
|
||||
assert "error" not in stats
|
||||
print(f"\nConcurrency={num_concurrent}:")
|
||||
print(f" p50={stats['p50_latency']:.2f}s, p95={stats['p95_latency']:.2f}s")
|
||||
print(f" throughput={stats['throughput_req_per_sec']:.2f} req/s")
|
||||
|
||||
|
||||
async def run_throughput_benchmark(
|
||||
server_url: str,
|
||||
audio_duration: float = 5.0,
|
||||
concurrency_levels: list[int] = [1, 2, 4, 8],
|
||||
requests_per_level: int = 10,
|
||||
warmup: int = 2,
|
||||
):
|
||||
"""Run full throughput benchmark."""
|
||||
audio_base64 = create_dummy_audio_base64(duration_sec=audio_duration)
|
||||
payload = create_s2st_request(audio_base64)
|
||||
|
||||
print(f"{'=' * 70}")
|
||||
print("Step-Audio2 Online Serving Throughput Benchmark")
|
||||
print(f"{'=' * 70}")
|
||||
print(f"Server: {server_url}")
|
||||
print(f"Audio duration: {audio_duration}s")
|
||||
print(f"Requests per level: {requests_per_level}")
|
||||
print(f"Warmup requests: {warmup}")
|
||||
print(f"Concurrency levels: {concurrency_levels}")
|
||||
print(f"{'=' * 70}")
|
||||
|
||||
results = []
|
||||
for num_concurrent in concurrency_levels:
|
||||
print(f"\n[Concurrency={num_concurrent}]")
|
||||
stats = await benchmark_concurrent(server_url, payload, num_concurrent, requests_per_level, warmup)
|
||||
results.append(stats)
|
||||
|
||||
if "error" not in stats:
|
||||
print(
|
||||
f" Result: p50={stats['p50_latency']:.2f}s, "
|
||||
f"p95={stats['p95_latency']:.2f}s, "
|
||||
f"throughput={stats['throughput_req_per_sec']:.2f} req/s, "
|
||||
f"{stats['throughput_tokens_per_sec']:.1f} tok/s"
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print("SUMMARY")
|
||||
print(f"{'=' * 80}")
|
||||
print(
|
||||
f"{'Conc':<6} {'Reqs':<6} {'Err':<5} {'Wall(s)':<9} {'p50':<8} {'p95':<8} {'p99':<8} {'Req/s':<8} {'Tok/s':<8}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
for stats in results:
|
||||
if "error" in stats:
|
||||
print(f"{stats.get('num_concurrent', '?'):<6} ERROR: {stats['error']}")
|
||||
continue
|
||||
print(
|
||||
f"{stats['num_concurrent']:<6} "
|
||||
f"{stats['successful_requests']:<6} "
|
||||
f"{stats['errors']:<5} "
|
||||
f"{stats['wall_time']:<9.2f} "
|
||||
f"{stats['p50_latency']:<8.2f} "
|
||||
f"{stats['p95_latency']:<8.2f} "
|
||||
f"{stats['p99_latency']:<8.2f} "
|
||||
f"{stats['throughput_req_per_sec']:<8.2f} "
|
||||
f"{stats['throughput_tokens_per_sec']:<8.1f}"
|
||||
)
|
||||
|
||||
print("=" * 80)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Step-Audio2 Online Serving Benchmark")
|
||||
parser.add_argument(
|
||||
"--server-url", type=str, default="http://localhost:8000/v1/chat/completions", help="vLLM server URL"
|
||||
)
|
||||
parser.add_argument("--audio-duration", type=float, default=5.0, help="Input audio duration in seconds")
|
||||
parser.add_argument("--concurrency", type=str, default="1,2,4,8", help="Comma-separated concurrency levels to test")
|
||||
parser.add_argument("--requests-per-level", type=int, default=10, help="Number of requests per concurrency level")
|
||||
parser.add_argument("--warmup", type=int, default=2, help="Number of warmup requests before each benchmark")
|
||||
|
||||
args = parser.parse_args()
|
||||
concurrency_levels = [int(x) for x in args.concurrency.split(",")]
|
||||
|
||||
asyncio.run(
|
||||
run_throughput_benchmark(
|
||||
args.server_url,
|
||||
args.audio_duration,
|
||||
concurrency_levels,
|
||||
args.requests_per_level,
|
||||
args.warmup,
|
||||
)
|
||||
)
|
||||
@@ -73,6 +73,7 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
@pytest.mark.skip(reason="#4335")
|
||||
def test_response_format_001(omni_server, openai_client) -> None:
|
||||
"""
|
||||
Test voice-clone non-stream PCM output via OpenAI API.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for _ensure_tensor_values boundary enforcement."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker.gpu_ar_model_runner import _ensure_tensor_values
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_tensors_pass_through():
|
||||
"""Tensor values pass through unchanged."""
|
||||
t = torch.randn(10)
|
||||
result = _ensure_tensor_values({"audio": t})
|
||||
assert torch.equal(result["audio"], t)
|
||||
|
||||
|
||||
def test_scalar_int_wrapped():
|
||||
"""Integer scalars are wrapped in torch.tensor."""
|
||||
result = _ensure_tensor_values({"sr": 24000})
|
||||
assert isinstance(result["sr"], torch.Tensor)
|
||||
assert result["sr"].item() == 24000
|
||||
|
||||
|
||||
def test_scalar_float_wrapped():
|
||||
"""Float scalars are wrapped in torch.tensor."""
|
||||
result = _ensure_tensor_values({"gain": 0.5})
|
||||
assert isinstance(result["gain"], torch.Tensor)
|
||||
assert result["gain"].item() == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_list_converted():
|
||||
"""Lists of numbers are converted to tensors."""
|
||||
result = _ensure_tensor_values({"ids": [1, 2, 3]})
|
||||
assert isinstance(result["ids"], torch.Tensor)
|
||||
assert list(result["ids"]) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_non_tensorizable_dropped():
|
||||
"""Values that cannot be tensorized are dropped with warning."""
|
||||
result = _ensure_tensor_values({"ok": torch.randn(5), "bad": {"nested": "dict"}})
|
||||
assert "ok" in result
|
||||
assert "bad" not in result
|
||||
|
||||
|
||||
def test_mixed_payload():
|
||||
"""Mixed tensor + scalar + list payload is properly sanitized."""
|
||||
payload = {
|
||||
"audio": torch.randn(100),
|
||||
"sr": 24000,
|
||||
"codes": [1, 2, 3],
|
||||
"hidden": torch.randn(10, 64),
|
||||
}
|
||||
result = _ensure_tensor_values(payload)
|
||||
assert len(result) == 4
|
||||
for v in result.values():
|
||||
assert isinstance(v, torch.Tensor)
|
||||
@@ -81,7 +81,7 @@ def test_multimodal_payload_and_completion_output():
|
||||
assert p is not None
|
||||
assert "waveform" in p.tensors and torch.equal(p.primary_tensor, data["waveform"])
|
||||
assert p.metadata["sample_rate"] == 16000
|
||||
assert not p.is_empty and len(p) == 1
|
||||
assert not p.is_empty and len(p) == 2 # 1 tensor + 1 metadata
|
||||
|
||||
# None/empty returns None
|
||||
assert MultimodalPayload.from_dict(None) is None
|
||||
@@ -125,7 +125,7 @@ def test_output_modality_printed_examples(capsys):
|
||||
print(f" tensors keys : {list(p.tensors.keys())}")
|
||||
print(f" primary_tensor: shape={p.primary_tensor.shape}, dtype={p.primary_tensor.dtype}")
|
||||
print(f" metadata : {p.metadata}")
|
||||
print(f" is_empty={p.is_empty}, len={len(p)}")
|
||||
print(f" is_empty={p.is_empty}, len={len(p)}") # len = tensors + metadata
|
||||
print(f" from_dict(None) -> {MultimodalPayload.from_dict(None)}")
|
||||
print(f" from_dict({{}}) -> {MultimodalPayload.from_dict({})}")
|
||||
|
||||
|
||||
@@ -209,3 +209,64 @@ def test_cumulative_token_ids_is_a_copy():
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
out = s._new_completion_output([42], None, None)
|
||||
assert out.cumulative_token_ids is not _DETOK.output_token_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-detokenizer regression tests (Concern 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NO_DETOK_STATE_KWARGS = {
|
||||
**_DEFAULT_STATE_KWARGS,
|
||||
"detokenizer": None,
|
||||
"logprobs_processor": None,
|
||||
}
|
||||
|
||||
|
||||
def _make_no_detok_state(output_kind: RequestOutputKind):
|
||||
return OmniRequestState(**_NO_DETOK_STATE_KWARGS, output_kind=output_kind)
|
||||
|
||||
|
||||
def test_no_detokenizer_completion_output():
|
||||
"""_new_completion_output works when detokenizer is None (generation stages)."""
|
||||
s = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
out = s._new_completion_output([1, 2], None, None)
|
||||
# Should still produce output with empty text and the token_ids as-is
|
||||
assert out.text == ""
|
||||
assert list(out.token_ids) == [1, 2]
|
||||
assert hasattr(out, "cumulative_token_ids")
|
||||
assert list(out.cumulative_token_ids) == [1, 2]
|
||||
# Multimodal data should be attached
|
||||
assert AUDIO in out.multimodal_output
|
||||
|
||||
|
||||
def test_no_detokenizer_make_request_output():
|
||||
"""make_request_output works without detokenizer when multimodal data is present."""
|
||||
s = _make_no_detok_state(RequestOutputKind.CUMULATIVE)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
assert not isinstance(result, PoolingRequestOutput)
|
||||
assert AUDIO in result.outputs[0].multimodal_output
|
||||
|
||||
|
||||
def test_no_detokenizer_stream_interval_skipped():
|
||||
"""stream_interval > 1 does not crash when detokenizer is None."""
|
||||
kwargs = {**_NO_DETOK_STATE_KWARGS, "stream_interval": 5}
|
||||
s = OmniRequestState(**kwargs, output_kind=RequestOutputKind.DELTA)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
# Should not assert; stream_interval logic is simply skipped
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_no_detokenizer_final_only():
|
||||
"""FINAL_ONLY with no detokenizer returns None until finish."""
|
||||
s = _make_no_detok_state(RequestOutputKind.FINAL_ONLY)
|
||||
s.add_multimodal_tensor(torch.randn(10), mm_type=AUDIO)
|
||||
# Non-finish step should return None
|
||||
assert s.make_request_output([], None, None, None) is None
|
||||
# Finish step should return output
|
||||
result = s.make_request_output([], None, FinishReason.STOP, None)
|
||||
assert result is not None
|
||||
assert AUDIO in result.outputs[0].multimodal_output
|
||||
@@ -0,0 +1,88 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Msgpack roundtrip tests for OmniEngineCoreOutputs with multimodal_output.
|
||||
|
||||
Validates that tensor-only payloads survive msgspec encode/decode and that
|
||||
non-tensor values fail decoding (enforcing the wire invariant).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder
|
||||
|
||||
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _roundtrip(outputs: OmniEngineCoreOutputs) -> OmniEngineCoreOutputs:
|
||||
encoder = MsgpackEncoder()
|
||||
encoded = encoder.encode(outputs)
|
||||
decoder = MsgpackDecoder(OmniEngineCoreOutputs)
|
||||
return decoder.decode(encoded)
|
||||
|
||||
|
||||
def test_tensor_only_roundtrip():
|
||||
"""Tensor-only multimodal_output survives msgpack roundtrip."""
|
||||
audio = torch.randn(16000)
|
||||
sr = torch.tensor(24000)
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-1",
|
||||
new_token_ids=[1, 2, 3],
|
||||
finish_reason=None,
|
||||
multimodal_output={"audio": audio, "sr": sr},
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
assert len(decoded.outputs) == 1
|
||||
out = decoded.outputs[0]
|
||||
assert out.request_id == "req-1"
|
||||
assert isinstance(out.multimodal_output, dict)
|
||||
assert torch.allclose(out.multimodal_output["audio"], audio)
|
||||
assert out.multimodal_output["sr"].item() == 24000
|
||||
|
||||
|
||||
def test_empty_multimodal_roundtrip():
|
||||
"""None multimodal_output roundtrips correctly."""
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-2",
|
||||
new_token_ids=[4],
|
||||
finish_reason=None,
|
||||
multimodal_output=None,
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
assert decoded.outputs[0].multimodal_output is None
|
||||
|
||||
|
||||
def test_multiple_tensor_keys_roundtrip():
|
||||
"""Multiple tensor keys survive roundtrip."""
|
||||
hidden = torch.randn(10, 4096)
|
||||
codes = torch.randint(0, 1024, (1, 100))
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-3",
|
||||
new_token_ids=[],
|
||||
finish_reason=None,
|
||||
multimodal_output={"hidden": hidden, "codes": codes},
|
||||
)
|
||||
decoded = _roundtrip(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
out = decoded.outputs[0]
|
||||
assert torch.allclose(out.multimodal_output["hidden"], hidden)
|
||||
assert torch.equal(out.multimodal_output["codes"], codes)
|
||||
|
||||
|
||||
def test_non_tensor_value_fails_decode():
|
||||
"""Non-tensor values in multimodal_output cause decode failure.
|
||||
|
||||
This validates that the wire field must be tensor-only and that
|
||||
the _ensure_tensor_values boundary in model runners is necessary.
|
||||
"""
|
||||
eco = OmniEngineCoreOutput(
|
||||
request_id="req-bad",
|
||||
new_token_ids=[],
|
||||
finish_reason=None,
|
||||
multimodal_output={"audio": torch.randn(10), "sr": 24000}, # scalar!
|
||||
)
|
||||
encoder = MsgpackEncoder()
|
||||
encoded = encoder.encode(OmniEngineCoreOutputs(outputs=[eco]))
|
||||
decoder = MsgpackDecoder(OmniEngineCoreOutputs)
|
||||
with pytest.raises(Exception):
|
||||
decoder.decode(encoded)
|
||||
@@ -0,0 +1,101 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Unit tests for the TTS serving adapter registry (RFC #4327).
|
||||
|
||||
Pure-Python registry/resolution logic; no model or GPU resources are loaded.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import (
|
||||
TTS_ADAPTER_REGISTRY,
|
||||
ARTTSAdapter,
|
||||
DiffusionTTSAdapter,
|
||||
all_tts_model_types,
|
||||
resolve_adapter,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter
|
||||
|
||||
# Every dedicated TTS model-type must have an adapter so the orchestrator's
|
||||
# uniform ``self._adapter.build(...)`` dispatch covers it.
|
||||
EXPECTED_MODEL_TYPES = {
|
||||
"qwen3_tts",
|
||||
"voxcpm2",
|
||||
"voxtral_tts",
|
||||
"fish_tts",
|
||||
"cosyvoice3",
|
||||
"omnivoice",
|
||||
"covo_audio",
|
||||
"ming_tts",
|
||||
"moss_tts_nano",
|
||||
"moss_tts",
|
||||
"higgs_audio_v2",
|
||||
"higgs_audio_v3",
|
||||
"glm_tts",
|
||||
"step_audio2",
|
||||
}
|
||||
|
||||
|
||||
def test_all_model_types_registered():
|
||||
assert EXPECTED_MODEL_TYPES <= all_tts_model_types()
|
||||
|
||||
|
||||
def test_registry_keyed_by_name():
|
||||
for name, cls in TTS_ADAPTER_REGISTRY.items():
|
||||
assert cls.name == name
|
||||
|
||||
|
||||
def test_resolve_each_model_type():
|
||||
for model_type in EXPECTED_MODEL_TYPES:
|
||||
cls = resolve_adapter(model_type)
|
||||
assert cls is not None, model_type
|
||||
assert cls.name == model_type
|
||||
|
||||
|
||||
def test_resolve_qwen3_tts_class():
|
||||
assert resolve_adapter("qwen3_tts") is Qwen3TTSAdapter
|
||||
|
||||
|
||||
def test_resolve_unknown_returns_none():
|
||||
assert resolve_adapter("not_a_real_model") is None
|
||||
assert resolve_adapter(None) is None
|
||||
|
||||
|
||||
def test_ming_flash_omni_not_migrated():
|
||||
"""ming_flash_omni is intentionally excluded from the adapter migration in
|
||||
this PR; it stays on the legacy inline dispatch in serving_speech.py."""
|
||||
assert resolve_adapter("ming_flash_omni_tts") is None
|
||||
|
||||
|
||||
def test_voxcpm2_resolves():
|
||||
"""VoxCPM2 (the served ``latent_generator`` model) resolves cleanly.
|
||||
|
||||
Detection never returns the legacy ``voxcpm`` type, so there is no shared
|
||||
stage-key ambiguity to resolve.
|
||||
"""
|
||||
assert resolve_adapter("voxcpm2") is not None
|
||||
assert resolve_adapter("voxcpm") is None
|
||||
|
||||
|
||||
def test_all_adapters_are_ar_or_diffusion():
|
||||
for cls in TTS_ADAPTER_REGISTRY.values():
|
||||
assert issubclass(cls, (ARTTSAdapter, DiffusionTTSAdapter))
|
||||
assert cls.backend in ("ar", "diffusion")
|
||||
|
||||
|
||||
def test_qwen3_tts_metadata():
|
||||
assert Qwen3TTSAdapter.backend == "ar"
|
||||
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
|
||||
|
||||
|
||||
def test_diffusion_adapter_extra_body_params_fallback():
|
||||
class _DiffAdapter(DiffusionTTSAdapter):
|
||||
name = "diff_probe"
|
||||
|
||||
async def build(self, request, sampling_params_list): # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
assert _DiffAdapter.extra_body_params() == frozenset()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -481,12 +481,32 @@ def assert_omni_response(response: Any, request_config: dict[str, Any], run_leve
|
||||
if "text" in modalities:
|
||||
transcript = (response.audio_content or "").strip()
|
||||
text_output = (response.text_content or "").strip()
|
||||
similarity = cosine_similarity_text(
|
||||
transcript.lower(),
|
||||
text_output.lower(),
|
||||
)
|
||||
print(f"similarity is: {similarity}")
|
||||
assert similarity > similarity_threshold, "The audio content is not same as the text"
|
||||
# For very short outputs (e.g. one-word answers), n-gram cosine
|
||||
# similarity with length penalty is unreliable because Whisper
|
||||
# may hallucinate extra context around the short utterance. Use
|
||||
# a containment check instead: the shorter text must appear in
|
||||
# the longer one (after preprocessing removes punctuation).
|
||||
_SHORT_TEXT_THRESHOLD = 15
|
||||
if len(text_output) <= _SHORT_TEXT_THRESHOLD or len(transcript) <= _SHORT_TEXT_THRESHOLD:
|
||||
shorter = text_output.lower() if len(text_output) <= len(transcript) else transcript.lower()
|
||||
longer = transcript.lower() if len(text_output) <= len(transcript) else text_output.lower()
|
||||
import re as _re
|
||||
|
||||
shorter_clean = _re.sub(r"[^\w\s]", "", shorter).strip()
|
||||
longer_clean = _re.sub(r"[^\w\s]", "", longer).strip()
|
||||
assert shorter_clean and (shorter_clean in longer_clean), (
|
||||
f"The audio content is not same as the text "
|
||||
f"(short-text containment check failed: "
|
||||
f"text={text_output!r}, transcript={transcript!r})"
|
||||
)
|
||||
print(f"short-text containment check passed: {shorter_clean!r} in {longer_clean!r}")
|
||||
else:
|
||||
similarity = cosine_similarity_text(
|
||||
transcript.lower(),
|
||||
text_output.lower(),
|
||||
)
|
||||
print(f"similarity is: {similarity}")
|
||||
assert similarity > similarity_threshold, "The audio content is not same as the text"
|
||||
if audio_ref_text:
|
||||
audio_similarity = cosine_similarity_text(
|
||||
response.audio_content.lower(),
|
||||
@@ -500,9 +520,25 @@ def assert_omni_response(response: Any, request_config: dict[str, Any], run_leve
|
||||
def assert_audio_speech_response(response: Any, request_config: dict[str, Any], run_level: str) -> None:
|
||||
"""Validate speech API results from :class:`~tests.helpers.runtime.OmniResponse`.
|
||||
|
||||
Success path only. Use :meth:`OpenAIClientHandler.send_audio_speech_http_request` with ``err_code`` /
|
||||
``err_message`` for expected HTTP errors.
|
||||
When ``request_config`` carries ``status_code`` and/or ``err_message``, the
|
||||
request is expected to be rejected: assert it failed and that the HTTP status
|
||||
/ error text match. Otherwise the normal success-path checks run.
|
||||
"""
|
||||
expected_status = request_config.get("status_code")
|
||||
expected_err = request_config.get("err_message")
|
||||
if expected_status is not None or expected_err is not None:
|
||||
assert not response.success, "Expected an error response, but the request succeeded."
|
||||
if expected_status is not None:
|
||||
allowed = expected_status if isinstance(expected_status, (list, tuple)) else (expected_status,)
|
||||
assert response.status_code in allowed, f"Expected HTTP status in {allowed}, got {response.status_code}"
|
||||
if expected_err is not None:
|
||||
alternatives = expected_err if isinstance(expected_err, (list, tuple)) else (expected_err,)
|
||||
error_text = response.error_message or ""
|
||||
assert any(alt in error_text for alt in alternatives), (
|
||||
f"Expected one of {alternatives} in error text, got: {error_text!r}"
|
||||
)
|
||||
return
|
||||
|
||||
assert response.success, "The request failed."
|
||||
|
||||
# Optional floor on decoded audio size (models with very short clips may use a lower value).
|
||||
|
||||
@@ -762,6 +762,10 @@ class OmniResponse:
|
||||
prompt_tokens: int | None = None
|
||||
cached_tokens: int | None = None
|
||||
logprobs: list | None = None
|
||||
#: HTTP status + error text for the error-handling path (e.g. validator
|
||||
#: rejections); populated when the OpenAI client raises an APIError.
|
||||
status_code: int | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for cross-request ``codes.ref`` leakage (#4370).
|
||||
|
||||
``make_omni_output`` used to collapse every request's reference codec
|
||||
frames into a single last-writer-wins slot and emit it as a length-1
|
||||
list. ``to_payload_element`` indexes list payloads with
|
||||
``element[idx] if idx < len(element) else element[0]``, so that length-1
|
||||
list was broadcast to every request in the batch: each utterance's first
|
||||
vocoder chunks then decoded with another request's reference voice as
|
||||
context (audible onset timbre deformation at any concurrency > 1).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
Qwen3TTSTalkerForConditionalGeneration,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_NUM_CODE_GROUPS = 16
|
||||
|
||||
|
||||
def _make_talker() -> Qwen3TTSTalkerForConditionalGeneration:
|
||||
# make_omni_output reads no instance state, so skip __init__ (no real
|
||||
# config / checkpoint needed), same as the preprocess tests.
|
||||
return Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
|
||||
|
||||
def _info(span_frames: int, ref_code: torch.Tensor | None) -> dict:
|
||||
codes: dict = {"audio": torch.zeros((span_frames, _NUM_CODE_GROUPS), dtype=torch.long)}
|
||||
meta: dict = {}
|
||||
if ref_code is not None:
|
||||
codes["ref"] = ref_code
|
||||
meta["ref_code_len"] = int(ref_code.shape[0])
|
||||
return {"codes": codes, "meta": meta}
|
||||
|
||||
|
||||
def test_make_omni_output_keeps_ref_codes_per_request() -> None:
|
||||
ref_a = torch.ones((3, _NUM_CODE_GROUPS), dtype=torch.long)
|
||||
ref_b = torch.full((5, _NUM_CODE_GROUPS), 2, dtype=torch.long)
|
||||
|
||||
out = _make_talker().make_omni_output(
|
||||
torch.zeros((4, 8)),
|
||||
model_intermediate_buffer=[_info(2, ref_a), _info(2, ref_b)],
|
||||
)
|
||||
|
||||
ref_list = out.multimodal_outputs["codes"]["ref"]
|
||||
assert len(ref_list) == 2, "codes.ref must stay batch-aligned (one entry per request)"
|
||||
assert torch.equal(ref_list[0], ref_a)
|
||||
assert torch.equal(ref_list[1], ref_b)
|
||||
|
||||
|
||||
def test_make_omni_output_pads_requests_without_ref_code() -> None:
|
||||
ref_b = torch.full((5, _NUM_CODE_GROUPS), 2, dtype=torch.long)
|
||||
|
||||
out = _make_talker().make_omni_output(
|
||||
torch.zeros((4, 8)),
|
||||
model_intermediate_buffer=[_info(2, None), _info(2, ref_b)],
|
||||
)
|
||||
|
||||
ref_list = out.multimodal_outputs["codes"]["ref"]
|
||||
assert len(ref_list) == 2
|
||||
assert ref_list[0].numel() == 0, "no-ref request must get an empty placeholder, not a neighbor's ref"
|
||||
assert torch.equal(ref_list[1], ref_b)
|
||||
|
||||
|
||||
def test_make_omni_output_omits_ref_key_when_no_request_has_one() -> None:
|
||||
out = _make_talker().make_omni_output(
|
||||
torch.zeros((4, 8)),
|
||||
model_intermediate_buffer=[_info(2, None), _info(2, None)],
|
||||
)
|
||||
|
||||
assert "ref" not in out.multimodal_outputs["codes"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
# Token2Wav imports the optional `step-audio2` extras (flashcosyvoice / s3tokenizer
|
||||
# / hyperpyyaml) at module load. Skip instead of erroring at collection time on CI
|
||||
# images that do not install them (e.g. the CPU core_model runner); a genuine
|
||||
# import error (e.g. vLLM API drift) still surfaces. See
|
||||
# examples/offline_inference/step_audio2/README.md.
|
||||
try:
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_token2wav import (
|
||||
StepAudio2Token2WavForConditionalGeneration,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(
|
||||
f"Step-Audio2 Token2Wav extras not installed ({exc.name})",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _DummyModelConfig:
|
||||
def __init__(self) -> None:
|
||||
self.hf_config = SimpleNamespace(
|
||||
token2wav_path="/tmp/fake-token2wav",
|
||||
token2wav_float16=False,
|
||||
)
|
||||
self.model = "/tmp/fake-model"
|
||||
|
||||
def get_hidden_size(self) -> int:
|
||||
return 8
|
||||
|
||||
|
||||
def _build_model() -> StepAudio2Token2WavForConditionalGeneration:
|
||||
vllm_config = SimpleNamespace(
|
||||
model_config=_DummyModelConfig(),
|
||||
device_config=SimpleNamespace(device="cpu"),
|
||||
)
|
||||
return StepAudio2Token2WavForConditionalGeneration(vllm_config=vllm_config)
|
||||
|
||||
|
||||
def test_step_audio2_token2wav_sync_path_not_misdetected_by_empty_runtime_info(monkeypatch):
|
||||
model = _build_model()
|
||||
|
||||
monkeypatch.setattr("os.path.exists", lambda _: True)
|
||||
monkeypatch.setattr(
|
||||
model.token2wav,
|
||||
"forward",
|
||||
lambda *args, **kwargs: torch.zeros(1, 16, dtype=torch.float32),
|
||||
)
|
||||
|
||||
out = model.forward(
|
||||
input_ids=torch.tensor([1, 2, 3]),
|
||||
positions=torch.tensor([0, 1, 2]),
|
||||
runtime_additional_information=[{}],
|
||||
)
|
||||
|
||||
audio = out.multimodal_outputs["model_outputs"]
|
||||
# sync path returns the full waveform wrapped in a single-element list,
|
||||
# same contract as the async-chunk path (see tests below)
|
||||
assert isinstance(audio, list)
|
||||
assert len(audio) == 1
|
||||
assert isinstance(audio[0], torch.Tensor)
|
||||
assert model._stream_states == []
|
||||
|
||||
|
||||
def test_step_audio2_token2wav_async_chunk_batch_guard():
|
||||
model = _build_model()
|
||||
|
||||
with pytest.raises(RuntimeError, match="only supports batch=1"):
|
||||
model._forward_async_chunk(
|
||||
input_ids=torch.tensor([1, 2, 3]),
|
||||
runtime_additional_information=[
|
||||
{"left_context_size": 0},
|
||||
{"left_context_size": 1},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_step_audio2_token2wav_async_chunk_last_chunk_resets_state(monkeypatch):
|
||||
model = _build_model()
|
||||
|
||||
monkeypatch.setattr("os.path.exists", lambda _: True)
|
||||
calls = {"setup": 0, "stream": 0, "reset": 0}
|
||||
|
||||
def _setup_stream_for(prompt_wav, state):
|
||||
calls["setup"] += 1
|
||||
state.setup_done = True
|
||||
state.stream_cache = {}
|
||||
state.hift_cache_dict = {
|
||||
"mel": torch.zeros(1, 1, 0),
|
||||
"source": torch.zeros(1, 1, 0),
|
||||
"speech": torch.zeros(1, 0),
|
||||
}
|
||||
|
||||
def _stream_chunk_for(audio_tokens, prompt_wav, last_chunk, state):
|
||||
calls["stream"] += 1
|
||||
return torch.tensor([0.1, 0.2, 0.3], dtype=torch.float32)
|
||||
|
||||
def _reset_stream_for(state):
|
||||
calls["reset"] += 1
|
||||
state.setup_done = False
|
||||
state.finished = True
|
||||
state.stream_cache = None
|
||||
state.hift_cache_dict = {}
|
||||
|
||||
monkeypatch.setattr(model.token2wav, "setup_stream_for", _setup_stream_for)
|
||||
monkeypatch.setattr(model.token2wav, "stream_chunk_for", _stream_chunk_for)
|
||||
monkeypatch.setattr(model.token2wav, "reset_stream_for", _reset_stream_for)
|
||||
|
||||
out = model.forward(
|
||||
input_ids=torch.tensor([10, 11, 12]),
|
||||
positions=torch.tensor([0, 1, 2]),
|
||||
runtime_additional_information=[{"left_context_size": 1}],
|
||||
)
|
||||
|
||||
audio_list = out.multimodal_outputs["model_outputs"]
|
||||
assert isinstance(audio_list, list)
|
||||
assert len(audio_list) == 1
|
||||
assert torch.allclose(audio_list[0], torch.tensor([0.1, 0.2, 0.3], dtype=torch.float32))
|
||||
assert calls == {"setup": 1, "stream": 1, "reset": 1}
|
||||
|
||||
|
||||
def test_step_audio2_token2wav_async_chunk_empty_eof_returns_zero_chunk():
|
||||
model = _build_model()
|
||||
|
||||
out = model._forward_async_chunk(
|
||||
input_ids=torch.tensor([], dtype=torch.int64),
|
||||
runtime_additional_information=[{"left_context_size": 1}],
|
||||
)
|
||||
|
||||
audio_list = out.multimodal_outputs["model_outputs"]
|
||||
assert isinstance(audio_list, list)
|
||||
assert len(audio_list) == 1
|
||||
assert torch.equal(audio_list[0], torch.zeros(1, dtype=torch.float32))
|
||||
assert model._stream_states
|
||||
assert model._stream_states[0].finished is True
|
||||
+12
-12
@@ -140,7 +140,7 @@ def test_talker2code2wav_async_chunk_final_payload_uses_absolute_token_offset():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -167,7 +167,7 @@ def test_talker2code2wav_async_chunk_emits_eof_when_finished_without_valid_codes
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -188,13 +188,13 @@ def test_talker2code2wav_async_chunk_does_not_reemit_without_new_tokens():
|
||||
|
||||
payload1 = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
payload2 = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -216,14 +216,14 @@ def test_talker2code2wav_async_chunk_waits_for_prelookahead_and_emits_cumulative
|
||||
|
||||
payload_pending = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [1, 2, 3]
|
||||
payload_ready = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -246,14 +246,14 @@ def test_talker2code2wav_async_chunk_final_flush_uses_previous_token_offset():
|
||||
|
||||
payload_stream = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [3, 4, 5, 6]
|
||||
payload_final = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -281,14 +281,14 @@ def test_talker2code2wav_async_chunk_respects_prompt_token_pad_on_first_chunk():
|
||||
|
||||
payload_pending = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
request.output_token_ids = [8, 9, 10, 11]
|
||||
payload_ready = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -310,13 +310,13 @@ def test_talker2code2wav_async_chunk_emits_terminal_eof_without_duplicate_audio(
|
||||
|
||||
payload_stream = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
payload_final = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
is_finished=True,
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ def _call(tm, rid, *, n_frames, finished=False, req_ic=None):
|
||||
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(n_frames)]
|
||||
return talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
request=_req(rid, finished=finished, initial_codec_chunk_frames=req_ic),
|
||||
is_finished=finished,
|
||||
)
|
||||
@@ -66,7 +66,7 @@ def test_empty_returns_none():
|
||||
tm = _tm()
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
|
||||
request=_req("r", finished=False),
|
||||
)
|
||||
assert p is None
|
||||
@@ -76,7 +76,7 @@ def test_eof_marker_when_finished_empty():
|
||||
tm = _tm()
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=_req("r", finished=True),
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -89,7 +89,7 @@ def test_flush_on_finish():
|
||||
tm.code_prompt_token_ids["r"] = [_FRAME[:] for _ in range(24)]
|
||||
p = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=_req("r", finished=True),
|
||||
is_finished=True,
|
||||
)
|
||||
@@ -262,7 +262,7 @@ def test_first_streaming_chunk_prepends_ref_code_context():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -286,7 +286,7 @@ def test_followup_ref_code_context_is_sent_as_metadata_handle():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -319,7 +319,7 @@ def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
|
||||
request=_req(rid, finished=False),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -338,7 +338,7 @@ def test_ref_code_context_can_be_buffered_before_first_emit():
|
||||
|
||||
first_payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]]), "ref": ref_code}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]]), "ref": ref_code}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
@@ -348,14 +348,14 @@ def test_ref_code_context_can_be_buffered_before_first_emit():
|
||||
for _ in range(8):
|
||||
talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
pooling_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
multimodal_output={"codes": {"audio": torch.tensor([[1, 2, 3, 4]])}},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=10),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_constants import (
|
||||
DEFAULT_STREAM_CONFIG,
|
||||
DEFAULT_TOKEN_CONFIG,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.step_audio2 import (
|
||||
thinker2token2wav_async_chunk,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _req(external_req_id: str, *, prompt_token_ids: list[int], all_token_ids: list[int], finished: bool):
|
||||
return SimpleNamespace(
|
||||
external_req_id=external_req_id,
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
all_token_ids=all_token_ids,
|
||||
is_finished=lambda: finished,
|
||||
)
|
||||
|
||||
|
||||
def test_step_audio2_async_chunk_uses_decode_only_tokens_not_prompt_history():
|
||||
audio_start = DEFAULT_TOKEN_CONFIG.audio_start
|
||||
audio_eos = DEFAULT_TOKEN_CONFIG.audio_eos
|
||||
transfer_manager = SimpleNamespace(code_prompt_token_ids=defaultdict(list))
|
||||
|
||||
prompt = [11, 12, audio_start + 5] # historical prompt audio token
|
||||
generated = [99, audio_start + 1, audio_start + 2, audio_start + audio_eos]
|
||||
request = _req(
|
||||
"rid-decode-only",
|
||||
prompt_token_ids=prompt,
|
||||
all_token_ids=prompt + generated,
|
||||
finished=True,
|
||||
)
|
||||
|
||||
payload = thinker2token2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["code_predictor_codes"] == [1, 2]
|
||||
assert payload["left_context_size"] == 1
|
||||
assert payload["finished"].item() is True
|
||||
|
||||
|
||||
def test_step_audio2_async_chunk_returns_none_when_not_enough_tokens():
|
||||
audio_start = DEFAULT_TOKEN_CONFIG.audio_start
|
||||
required = DEFAULT_STREAM_CONFIG.chunk_size + DEFAULT_STREAM_CONFIG.pre_lookahead_len
|
||||
transfer_manager = SimpleNamespace(code_prompt_token_ids=defaultdict(list))
|
||||
|
||||
generated = [audio_start + i for i in range(required - 1)]
|
||||
request = _req(
|
||||
"rid-not-ready",
|
||||
prompt_token_ids=[1, 2, 3],
|
||||
all_token_ids=[1, 2, 3] + generated,
|
||||
finished=False,
|
||||
)
|
||||
|
||||
payload = thinker2token2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert payload is None
|
||||
|
||||
|
||||
def test_step_audio2_async_chunk_emits_non_last_chunk_and_advances_consumed_by_chunk_size():
|
||||
audio_start = DEFAULT_TOKEN_CONFIG.audio_start
|
||||
chunk_size = DEFAULT_STREAM_CONFIG.chunk_size
|
||||
required = chunk_size + DEFAULT_STREAM_CONFIG.pre_lookahead_len
|
||||
transfer_manager = SimpleNamespace(code_prompt_token_ids=defaultdict(list))
|
||||
|
||||
generated = [audio_start + i for i in range(required + 10)]
|
||||
request = _req(
|
||||
"rid-ready",
|
||||
prompt_token_ids=[7, 8],
|
||||
all_token_ids=[7, 8] + generated,
|
||||
finished=False,
|
||||
)
|
||||
|
||||
payload = thinker2token2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["left_context_size"] == 0
|
||||
assert payload["finished"].item() is False
|
||||
assert payload["code_predictor_codes"] == list(range(required))
|
||||
assert transfer_manager.code_prompt_token_ids["rid-ready"] == list(range(chunk_size))
|
||||
|
||||
|
||||
def test_step_audio2_async_chunk_emits_eof_when_finished_with_no_remaining_audio():
|
||||
transfer_manager = SimpleNamespace(code_prompt_token_ids=defaultdict(list))
|
||||
transfer_manager.code_prompt_token_ids["rid-eof"] = [1, 2, 3]
|
||||
|
||||
request = _req(
|
||||
"rid-eof",
|
||||
prompt_token_ids=[10, 11],
|
||||
all_token_ids=[
|
||||
10,
|
||||
11,
|
||||
DEFAULT_TOKEN_CONFIG.audio_start + 1,
|
||||
DEFAULT_TOKEN_CONFIG.audio_start + 2,
|
||||
DEFAULT_TOKEN_CONFIG.audio_start + 3,
|
||||
],
|
||||
finished=True,
|
||||
)
|
||||
|
||||
payload = thinker2token2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"code_predictor_codes": [],
|
||||
"left_context_size": 1,
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
}
|
||||
@@ -92,7 +92,7 @@ def test_empty_chunk_when_not_finished():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output={"audio": torch.zeros((0,))},
|
||||
multimodal_output={"audio": torch.zeros((0,))},
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ def test_flush_tail_when_finished():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None, # e.g. EOS step with no audio
|
||||
multimodal_output=None, # e.g. EOS step with no audio
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ def test_eof_marker_when_finished_with_no_frames():
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -153,10 +153,10 @@ def test_normal_chunk_emission():
|
||||
|
||||
# Feed 25 frames one by one
|
||||
for i in range(25):
|
||||
pooling_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -181,10 +181,10 @@ def test_small_initial_chunks():
|
||||
|
||||
# Feed 5 frames (should trigger emission because codec_chunk_frames_at_begin=5)
|
||||
for i in range(5):
|
||||
pooling_output = {"audio": torch.tensor([float(i + 1)] * 3)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i + 1)] * 3)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -208,10 +208,10 @@ def test_no_emission_between_boundaries():
|
||||
|
||||
# Feed 3 frames (3 % 5 != 0, should not emit)
|
||||
for i in range(3):
|
||||
pooling_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
multimodal_output = {"audio": torch.tensor([float(i)] * 4)}
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -230,10 +230,10 @@ def test_context_handling_format():
|
||||
|
||||
# Feed 5 frames to trigger a chunk (chunk_size_at_begin=5)
|
||||
for i in range(5):
|
||||
pooling_output = {"audio": torch.tensor([float(i + 10)] * 2)} # codebook_dim=2
|
||||
multimodal_output = {"audio": torch.tensor([float(i + 10)] * 2)} # codebook_dim=2
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -250,14 +250,14 @@ def test_context_handling_format():
|
||||
assert flat_codes.numel() == total_window_frames * 2 # codebook_dim=2
|
||||
|
||||
|
||||
def test_none_pooling_output_not_finished_returns_none():
|
||||
"""None pooling_output when not finished returns None."""
|
||||
def test_none_multimodal_output_not_finished_returns_none():
|
||||
"""None multimodal_output when not finished returns None."""
|
||||
transfer_manager = _make_transfer_manager()
|
||||
request = _req("rid-none", finished=False)
|
||||
|
||||
payload = generator2tokenizer_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
pooling_output=None,
|
||||
multimodal_output=None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for 310P patch wiring.
|
||||
|
||||
The tests load patch modules from source with fake Qwen3-TTS dependencies, so
|
||||
they validate the patch contract without loading real model or NPU kernels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
marker = Path("vllm_omni") / "platforms" / "npu" / "_310p" / "patch"
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
if (parent / marker).is_dir():
|
||||
return parent
|
||||
raise FileNotFoundError(f"could not locate repo root containing {marker}")
|
||||
|
||||
|
||||
def _load_source_module(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load import spec for {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _install_fake_module(monkeypatch: pytest.MonkeyPatch, name: str, **attrs):
|
||||
module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(module, key, value)
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
return module
|
||||
|
||||
|
||||
def _install_qwen3_tts_patch_fakes(monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeAudioResampler:
|
||||
def __init__(self, *, target_sr: int):
|
||||
self.target_sr = target_sr
|
||||
|
||||
def resample(self, wav, *, orig_sr: int):
|
||||
del orig_sr
|
||||
return wav
|
||||
|
||||
class FakeCodePredictorAttention(torch.nn.Module):
|
||||
def __init__(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
super().__init__()
|
||||
self.register_buffer("_fusion_causal_mask", torch.ones(1), persistent=False)
|
||||
|
||||
class FakeCodePredictorDecoderLayer(torch.nn.Module):
|
||||
pass
|
||||
|
||||
class FakeCodePredictorBaseModel(torch.nn.Module):
|
||||
pass
|
||||
|
||||
class FakeMimiEuclideanCodebook(torch.nn.Module):
|
||||
@property
|
||||
def embed(self):
|
||||
return self._embed
|
||||
|
||||
class FakeEncoder:
|
||||
def __init__(self):
|
||||
self.to_calls: list[dict] = []
|
||||
self.last_input_dtype = None
|
||||
|
||||
def to(self, **kwargs):
|
||||
self.to_calls.append(kwargs)
|
||||
return self
|
||||
|
||||
def encode(self, *, input_values, return_dict: bool):
|
||||
assert return_dict
|
||||
self.last_input_dtype = input_values.dtype
|
||||
return SimpleNamespace(audio_codes=torch.arange(12, dtype=torch.long).reshape(1, 3, 4))
|
||||
|
||||
class FakeFeatureBatch(dict):
|
||||
def to(self, target):
|
||||
if isinstance(target, torch.dtype):
|
||||
for key, value in list(self.items()):
|
||||
if torch.is_floating_point(value):
|
||||
self[key] = value.to(dtype=target)
|
||||
self.dtype = target
|
||||
else:
|
||||
self.device = torch.device(target)
|
||||
for key, value in list(self.items()):
|
||||
self[key] = value.to(device=self.device)
|
||||
return self
|
||||
|
||||
class FakeFeatureExtractor:
|
||||
sampling_rate = 24000
|
||||
|
||||
def __call__(self, *, raw_audio, sampling_rate: int, return_tensors: str):
|
||||
assert len(raw_audio) == 1
|
||||
assert sampling_rate == self.sampling_rate
|
||||
assert return_tensors == "pt"
|
||||
return FakeFeatureBatch(
|
||||
input_values=torch.ones(1, 1, 8, dtype=torch.float32),
|
||||
padding_mask=torch.ones(1, 1, 8, dtype=torch.long),
|
||||
)
|
||||
|
||||
class FakeTalkerBase(torch.nn.Module):
|
||||
def __init__(self, *, vllm_config, prefix: str = ""):
|
||||
del vllm_config, prefix
|
||||
super().__init__()
|
||||
self._embedding_dtype = torch.bfloat16
|
||||
self._prompt_builder = SimpleNamespace(_embedding_dtype=torch.bfloat16)
|
||||
self.encoder = FakeEncoder()
|
||||
self._encoder_feature_extractor = FakeFeatureExtractor()
|
||||
self._encoder_valid_num_quantizers = 2
|
||||
self._encoder_downsample_rate = 2
|
||||
|
||||
def load_weights(self, weights):
|
||||
del weights
|
||||
self.encoder.to(dtype=torch.bfloat16)
|
||||
return {"loaded"}
|
||||
|
||||
class FakePromptEmbedsBuilder:
|
||||
pass
|
||||
|
||||
fake_qwen3_code_predictor = _install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.model_executor.models.common.qwen3_code_predictor",
|
||||
CodePredictorAttention=FakeCodePredictorAttention,
|
||||
CodePredictorDecoderLayer=FakeCodePredictorDecoderLayer,
|
||||
CodePredictorBaseModel=FakeCodePredictorBaseModel,
|
||||
_rotate_half=lambda x: x,
|
||||
)
|
||||
fake_prompt_builder = _install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder",
|
||||
Qwen3TTSPromptEmbedsBuilder=FakePromptEmbedsBuilder,
|
||||
mel_spectrogram=lambda *_args, **_kwargs: torch.empty(0),
|
||||
)
|
||||
fake_talker = _install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker",
|
||||
Qwen3TTSTalkerForConditionalGeneration=FakeTalkerBase,
|
||||
Qwen3TTSPromptEmbedsBuilder=FakePromptEmbedsBuilder,
|
||||
)
|
||||
fake_modeling_mimi = _install_fake_module(
|
||||
monkeypatch,
|
||||
"transformers.models.mimi.modeling_mimi",
|
||||
MimiEuclideanCodebook=FakeMimiEuclideanCodebook,
|
||||
)
|
||||
fake_mimi = _install_fake_module(
|
||||
monkeypatch,
|
||||
"transformers.models.mimi",
|
||||
modeling_mimi=fake_modeling_mimi,
|
||||
)
|
||||
|
||||
_install_fake_module(monkeypatch, "vllm")
|
||||
_install_fake_module(monkeypatch, "vllm.multimodal")
|
||||
_install_fake_module(monkeypatch, "vllm.multimodal.audio", AudioResampler=FakeAudioResampler)
|
||||
_install_fake_module(monkeypatch, "transformers")
|
||||
_install_fake_module(monkeypatch, "transformers.models", mimi=fake_mimi)
|
||||
_install_fake_module(monkeypatch, "vllm_omni")
|
||||
_install_fake_module(monkeypatch, "vllm_omni.model_executor")
|
||||
_install_fake_module(monkeypatch, "vllm_omni.model_executor.models")
|
||||
_install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.model_executor.models.common",
|
||||
qwen3_code_predictor=fake_qwen3_code_predictor,
|
||||
)
|
||||
_install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.model_executor.models.qwen3_tts",
|
||||
prompt_embeds_builder=fake_prompt_builder,
|
||||
qwen3_tts_talker=fake_talker,
|
||||
)
|
||||
return fake_qwen3_code_predictor, fake_prompt_builder, fake_talker
|
||||
|
||||
|
||||
def _load_qwen3_tts_patch(monkeypatch: pytest.MonkeyPatch):
|
||||
fakes = _install_qwen3_tts_patch_fakes(monkeypatch)
|
||||
path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "_310p" / "patch" / "qwen3_tts.py"
|
||||
module = _load_source_module("vllm_omni_test_310p_qwen3_tts_patch", path)
|
||||
return module, fakes
|
||||
|
||||
|
||||
def test_registry_applies_worker_once_and_model_patch_lazily(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
registry_path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "_310p" / "patch" / "__init__.py"
|
||||
registry = _load_source_module("vllm_omni_test_310p_patch_registry", registry_path)
|
||||
calls = {"worker": 0, "talker": 0}
|
||||
|
||||
_install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.platforms.npu._310p.patch.worker",
|
||||
apply_patch=lambda: calls.__setitem__("worker", calls["worker"] + 1),
|
||||
)
|
||||
_install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.platforms.npu._310p.patch.qwen3_tts",
|
||||
apply_talker_patches=lambda: calls.__setitem__("talker", calls["talker"] + 1),
|
||||
)
|
||||
|
||||
registry.apply_patches()
|
||||
registry.apply_patches()
|
||||
registry.apply_model_patches(SimpleNamespace(model_arch="OtherModel"))
|
||||
registry.apply_model_patches(SimpleNamespace(model_arch="Qwen3TTSTalkerForConditionalGeneration"))
|
||||
|
||||
assert calls == {"worker": 1, "talker": 1}
|
||||
|
||||
|
||||
def test_worker_patch_replaces_base_and_runs_disable_jit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeOmniNPUWorkerBase:
|
||||
def _init_device(self):
|
||||
calls.append("parent")
|
||||
return "npu:0"
|
||||
|
||||
fake_worker_base = _install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.platforms.npu.worker.base",
|
||||
OmniNPUWorkerBase=FakeOmniNPUWorkerBase,
|
||||
)
|
||||
_install_fake_module(monkeypatch, "vllm_omni")
|
||||
_install_fake_module(monkeypatch, "vllm_omni.platforms")
|
||||
_install_fake_module(monkeypatch, "vllm_omni.platforms.npu")
|
||||
_install_fake_module(
|
||||
monkeypatch,
|
||||
"vllm_omni.platforms.npu._310p",
|
||||
disable_jit_compile=lambda: calls.append("disable_jit"),
|
||||
)
|
||||
_install_fake_module(monkeypatch, "vllm_omni.platforms.npu.worker", base=fake_worker_base)
|
||||
|
||||
path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "_310p" / "patch" / "worker.py"
|
||||
module = _load_source_module("vllm_omni_test_310p_worker_patch", path)
|
||||
module.apply_patch()
|
||||
|
||||
assert fake_worker_base.OmniNPUWorkerBase is module._OmniNPUWorkerBase310P
|
||||
assert fake_worker_base.OmniNPUWorkerBase()._init_device() == "npu:0"
|
||||
assert calls == ["parent", "disable_jit"]
|
||||
|
||||
|
||||
def test_qwen3_tts_patch_replaces_target_classes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module, (fake_code_predictor, fake_prompt_builder, fake_talker) = _load_qwen3_tts_patch(monkeypatch)
|
||||
|
||||
module.apply_talker_patches()
|
||||
|
||||
assert fake_talker.Qwen3TTSTalkerForConditionalGeneration is module._Qwen3TTSTalker310P
|
||||
assert fake_talker.Qwen3TTSPromptEmbedsBuilder is module._Qwen3TTSPromptEmbedsBuilder310P
|
||||
assert fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder is module._Qwen3TTSPromptEmbedsBuilder310P
|
||||
assert fake_code_predictor.CodePredictorAttention is module._Qwen3CodePredictorAttention310P
|
||||
assert fake_code_predictor.CodePredictorDecoderLayer is module._Qwen3CodePredictorDecoderLayer310P
|
||||
assert fake_code_predictor.CodePredictorBaseModel is module._Qwen3CodePredictorBaseModel310P
|
||||
|
||||
|
||||
def test_qwen3_tts_talker_patch_uses_fp16_runtime_dtype(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module, _ = _load_qwen3_tts_patch(monkeypatch)
|
||||
talker = module._Qwen3TTSTalker310P(vllm_config=object())
|
||||
|
||||
assert talker._embedding_dtype is torch.float16
|
||||
assert talker._prompt_builder._embedding_dtype is torch.float16
|
||||
assert talker.load_weights([]) == {"loaded"}
|
||||
assert talker.encoder.to_calls[-1] == {"dtype": torch.float16}
|
||||
|
||||
codes = talker._encode_ref_audio_batch([np.zeros(8, dtype=np.float32)], 24000, device=torch.device("cpu"))
|
||||
|
||||
assert talker.encoder.last_input_dtype is torch.float16
|
||||
assert len(codes) == 1
|
||||
assert codes[0].dtype is torch.long
|
||||
assert codes[0].shape == (4, 2)
|
||||
|
||||
|
||||
def test_qwen3_tts_prompt_patch_runs_stft_frontend_on_cpu(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module, _ = _load_qwen3_tts_patch(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_mel_spectrogram(wav_tensor, **kwargs):
|
||||
captured["wav_device"] = wav_tensor.device
|
||||
captured["wav_dtype"] = wav_tensor.dtype
|
||||
captured["kwargs"] = kwargs
|
||||
return torch.ones(1, 128, 3, dtype=torch.float32)
|
||||
|
||||
class FakeSpeakerEncoder(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.param = torch.nn.Parameter(torch.zeros(1, dtype=torch.float16))
|
||||
|
||||
def forward(self, mels):
|
||||
captured["speaker_input_dtype"] = mels.dtype
|
||||
return (torch.ones(4, dtype=mels.dtype),)
|
||||
|
||||
monkeypatch.setattr(module.prompt_embeds_builder, "mel_spectrogram", fake_mel_spectrogram)
|
||||
builder = object.__new__(module._Qwen3TTSPromptEmbedsBuilder310P)
|
||||
builder._device = lambda: torch.device("cpu")
|
||||
builder._embedding_dtype = torch.float16
|
||||
builder._speaker_encoder = FakeSpeakerEncoder()
|
||||
builder._config = SimpleNamespace(speaker_encoder_config=SimpleNamespace(sample_rate=24000))
|
||||
|
||||
speaker = builder.extract_speaker_embedding(np.zeros(16, dtype=np.float32), 24000)
|
||||
|
||||
assert captured["wav_device"] == torch.device("cpu")
|
||||
assert captured["wav_dtype"] is torch.float32
|
||||
assert captured["kwargs"]["sampling_rate"] == 24000
|
||||
assert captured["speaker_input_dtype"] is torch.float16
|
||||
assert speaker.dtype is torch.float16
|
||||
|
||||
|
||||
def test_qwen3_tts_mimi_codebook_quantize_uses_cpu_fp32_cdist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module, _ = _load_qwen3_tts_patch(monkeypatch)
|
||||
real_cdist = torch.cdist
|
||||
captured = {}
|
||||
|
||||
def fake_cdist(x1, x2, p=2):
|
||||
captured["x1_device"] = x1.device
|
||||
captured["x2_device"] = x2.device
|
||||
captured["x1_dtype"] = x1.dtype
|
||||
captured["x2_dtype"] = x2.dtype
|
||||
return real_cdist(x1, x2, p=p)
|
||||
|
||||
monkeypatch.setattr(torch, "cdist", fake_cdist)
|
||||
codebook = object.__new__(module._MimiEuclideanCodebook310P)
|
||||
codebook._embed = torch.tensor([[0.0, 0.0], [2.0, 0.0]], dtype=torch.float16)
|
||||
|
||||
indices = codebook.quantize(torch.tensor([[0.1, 0.0], [1.8, 0.0]], dtype=torch.float16))
|
||||
|
||||
assert captured == {
|
||||
"x1_device": torch.device("cpu"),
|
||||
"x2_device": torch.device("cpu"),
|
||||
"x1_dtype": torch.float32,
|
||||
"x2_dtype": torch.float32,
|
||||
}
|
||||
assert indices.tolist() == [0, 1]
|
||||
@@ -62,6 +62,16 @@ class TestParallelConfigPropagation:
|
||||
assert od.parallel_config.ulysses_degree == 2
|
||||
assert od.parallel_config.ring_degree == 1
|
||||
|
||||
def test_mask_sp_padding_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(ulysses_degree=2, mask_sp_padding=True)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.mask_sp_padding is True
|
||||
|
||||
def test_mask_sp_padding_defaults_false(self):
|
||||
pc = DiffusionParallelConfig(ulysses_degree=2)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.mask_sp_padding is False
|
||||
|
||||
def test_cfg_parallel_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(cfg_parallel_size=2)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
|
||||
@@ -8,7 +8,8 @@ without requiring the actual checkpoint or GPU.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -1156,6 +1157,31 @@ class TestStageInputProcessor:
|
||||
assert result[0, 1].item() == 200
|
||||
assert result[0, 2].item() == 300
|
||||
|
||||
def test_async_chunk_accepts_multimodal_output_keyword(self):
|
||||
from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import (
|
||||
talker2code2wav_async_chunk,
|
||||
)
|
||||
|
||||
request_id = "req-higgs"
|
||||
transfer_manager = SimpleNamespace(
|
||||
code_prompt_token_ids=defaultdict(list),
|
||||
connector=SimpleNamespace(config={"extra": {"codec_chunk_frames": 1, "codec_right_holdback_frames": 0}}),
|
||||
)
|
||||
request = SimpleNamespace(external_req_id=request_id, is_finished=lambda: False)
|
||||
|
||||
result = None
|
||||
for step in range(8):
|
||||
result = talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
multimodal_output={"codes": {"audio": torch.full((1, 8), step, dtype=torch.long)}},
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.codes is not None
|
||||
assert result.codes.audio.numel() > 0
|
||||
|
||||
|
||||
# ---- AC-10: Registry ----
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monk
|
||||
runner.supports_mm_inputs = False
|
||||
runner.use_async_scheduling = False
|
||||
runner._omni_num_scheduled_tokens_np = None
|
||||
runner.vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(engine_output_type="audio"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
GPUARModelRunner, "_sample", lambda self, logits, spec_decode_metadata: SimpleNamespace(sampled_token_ids=[])
|
||||
@@ -125,8 +128,8 @@ def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monk
|
||||
|
||||
output = GPUARModelRunner.sample_tokens(runner, grammar_output=None)
|
||||
|
||||
assert torch.equal(output.pooler_output[0]["hidden"], torch.tensor([[1.0, 10.0]]))
|
||||
assert torch.equal(output.multimodal_outputs[0]["hidden"], torch.tensor([[1.0, 10.0]]))
|
||||
assert torch.equal(
|
||||
output.pooler_output[1]["hidden"],
|
||||
output.multimodal_outputs[1]["hidden"],
|
||||
torch.tensor([[2.0, 20.0], [3.0, 30.0]]),
|
||||
)
|
||||
@@ -49,8 +49,8 @@ def test_sample_tokens_tensor_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"].shape == (2, 3)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"].shape == (2, 3)
|
||||
|
||||
|
||||
def test_sample_tokens_list_output():
|
||||
@@ -59,8 +59,8 @@ def test_sample_tokens_list_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"].shape == (2, 1)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"].shape == (2, 1)
|
||||
|
||||
|
||||
def test_sample_tokens_list_allows_none_output():
|
||||
@@ -69,8 +69,8 @@ def test_sample_tokens_list_allows_none_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert output.pooler_output[0]["model_outputs"] is None
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert output.multimodal_outputs[0]["model_outputs"] is None
|
||||
|
||||
|
||||
def test_sample_tokens_dict_output():
|
||||
@@ -79,7 +79,7 @@ def test_sample_tokens_dict_output():
|
||||
|
||||
output = GPUGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert len(output.pooler_output) == 1
|
||||
assert "audio" in output.pooler_output[0]
|
||||
assert "unused" not in output.pooler_output[0]
|
||||
assert output.pooler_output[0]["audio"].shape == (1, 4)
|
||||
assert len(output.multimodal_outputs) == 1
|
||||
assert "audio" in output.multimodal_outputs[0]
|
||||
assert "unused" not in output.multimodal_outputs[0]
|
||||
assert output.multimodal_outputs[0]["audio"].shape == (1, 4)
|
||||
@@ -288,6 +288,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_logprobs_dict = model_runner_output.prompt_logprobs_dict
|
||||
num_scheduled_tokens = scheduler_output.num_scheduled_tokens
|
||||
pooler_outputs = model_runner_output.pooler_output
|
||||
mm_outputs = getattr(model_runner_output, "multimodal_outputs", None)
|
||||
num_nans_in_logits = model_runner_output.num_nans_in_logits
|
||||
kv_connector_output = model_runner_output.kv_connector_output
|
||||
cudagraph_stats: CUDAGraphStat | None = model_runner_output.cudagraph_stats
|
||||
@@ -389,6 +390,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs = None
|
||||
new_token_ids = generated_token_ids
|
||||
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
|
||||
mm_output = mm_outputs[req_index] if mm_outputs else None
|
||||
kv_transfer_params = None
|
||||
status_before_stop = request.status
|
||||
finish_reason = None
|
||||
@@ -463,7 +465,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# Get prompt logprobs for this request.
|
||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||
if new_token_ids or pooler_output is not None or kv_transfer_params or stopped:
|
||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||
# Add EngineCoreOutput for this Request.
|
||||
outputs[request.client_index].append(
|
||||
OmniEngineCoreOutput(
|
||||
@@ -473,6 +475,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
events=request.take_events(),
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
@@ -485,7 +488,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
)
|
||||
)
|
||||
if self.chunk_transfer_adapter is not None:
|
||||
self.chunk_transfer_adapter.save_async(pooler_output, request, is_segment_finished)
|
||||
self.chunk_transfer_adapter.save_async(mm_output, request, is_segment_finished)
|
||||
else:
|
||||
# Invariant: EngineCore returns no partial prefill outputs.
|
||||
assert not prompt_logprobs_tensors
|
||||
|
||||
@@ -420,6 +420,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
prompt_logprobs_dict = model_runner_output.prompt_logprobs_dict
|
||||
num_scheduled_tokens = scheduler_output.num_scheduled_tokens
|
||||
pooler_outputs = model_runner_output.pooler_output
|
||||
mm_outputs = getattr(model_runner_output, "multimodal_outputs", None)
|
||||
num_nans_in_logits = model_runner_output.num_nans_in_logits
|
||||
kv_connector_output = model_runner_output.kv_connector_output
|
||||
|
||||
@@ -498,6 +499,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_token_ids = generated_token_ids
|
||||
kv_transfer_params = None
|
||||
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
|
||||
mm_output = mm_outputs[req_index] if mm_outputs else None
|
||||
status_before_stop = request.status
|
||||
finish_reason = None
|
||||
routed_experts = None
|
||||
@@ -558,7 +560,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# Get prompt logprobs for this request.
|
||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||
if new_token_ids or pooler_output is not None or kv_transfer_params or stopped:
|
||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||
# Add EngineCoreOutput for this Request.
|
||||
outputs[request.client_index].append(
|
||||
OmniEngineCoreOutput(
|
||||
request_id=req_id,
|
||||
@@ -567,6 +570,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
events=request.take_events(),
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
|
||||
@@ -130,6 +130,14 @@ class DiffusionParallelConfig:
|
||||
use_hsdp: bool = False
|
||||
"""Enable Hybrid Sharded Data Parallel (HSDP) for model weight sharding."""
|
||||
|
||||
mask_sp_padding: bool = False
|
||||
"""If True, generate a boolean attention mask for zero-padded SP tokens
|
||||
when sequence length is not divisible by the SP world size. The mask
|
||||
routes attention through the varlen path (unpad→kernel→repad), which is
|
||||
correct but carries additional overhead. When False (default), padding
|
||||
tokens are left unmasked; since _shard_with_auto_pad always pads with
|
||||
zeros, their contribution to attention output is negligible."""
|
||||
|
||||
hsdp_shard_size: int = -1
|
||||
"""Number of GPUs to shard weights across within each replica group. -1 means auto-calculate."""
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
|
||||
@@ -69,11 +68,6 @@ class DiffusionExecutor(ABC):
|
||||
"""Initialize the executor (e.g., launch workers, setup IPC)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_req(self, requests: OmniDiffusionRequest) -> DiffusionOutput:
|
||||
"""Add requests to the execution queue."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute_request(self, scheduler_output: DiffusionSchedulerOutput) -> BaseRunnerOutput:
|
||||
"""Execute request-mode work from a scheduler output."""
|
||||
|
||||
@@ -16,8 +16,7 @@ from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
from vllm_omni.diffusion.data import SHUTDOWN_MESSAGE, DiffusionOutput
|
||||
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
|
||||
from vllm_omni.diffusion.ipc import unpack_diffusion_output_shm
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE, unpack_diffusion_output_shm
|
||||
from vllm_omni.diffusion.worker import WorkerProc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -133,6 +132,57 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
raise EngineDeadError()
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_rpc_error_dict(response: Any) -> None:
|
||||
if isinstance(response, dict) and response.get("status") == "error":
|
||||
raise RuntimeError(
|
||||
f"Worker failed with error '{response.get('error')}', "
|
||||
"please check the stack trace above for the root cause"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_rpc_result_envelope(response: Any) -> Any:
|
||||
if not (isinstance(response, dict) and response.get("type") == DIFFUSION_RPC_RESULT_ENVELOPE):
|
||||
return response
|
||||
|
||||
rank_statuses = response.get("rank_statuses") or []
|
||||
failed = [status for status in rank_statuses if not status.get("ok", False)]
|
||||
if failed:
|
||||
details = "; ".join(
|
||||
f"rank {status.get('rank')}: {status.get('error_type') or 'Error'}: {status.get('error')}"
|
||||
for status in failed
|
||||
)
|
||||
tracebacks = "\n\n".join(
|
||||
f"rank {status.get('rank')} traceback:\n{status['traceback']}"
|
||||
for status in failed
|
||||
if status.get("traceback")
|
||||
)
|
||||
if tracebacks:
|
||||
details = f"{details}\n\n{tracebacks}"
|
||||
method = response.get("method", "<unknown>")
|
||||
raise RuntimeError(f"RPC '{method}' failed on worker rank(s): {details}")
|
||||
|
||||
result = response.get("result")
|
||||
if isinstance(result, bool):
|
||||
# Only bool-returning RPCs participate in the all-rank AND.
|
||||
# Non-bool results leave bool_result unset and are ignored here.
|
||||
bool_results = [
|
||||
status.get("bool_result") for status in rank_statuses if status.get("bool_result") is not None
|
||||
]
|
||||
if bool_results and not all(bool_results):
|
||||
return False
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _handle_rpc_response(response: Any) -> Any:
|
||||
MultiprocDiffusionExecutor._raise_for_rpc_error_dict(response)
|
||||
response = MultiprocDiffusionExecutor._unwrap_rpc_result_envelope(response)
|
||||
# After unwrapping, a worker method result may itself be the same
|
||||
# {"status": "error"} shape produced by worker_busy_loop transport
|
||||
# failures. Preserve the pre-envelope error handling for that case.
|
||||
MultiprocDiffusionExecutor._raise_for_rpc_error_dict(response)
|
||||
return response
|
||||
|
||||
def _launch_workers(self, broadcast_handle, wake_events):
|
||||
od_config = self.od_config
|
||||
logger.info("Starting server...")
|
||||
@@ -257,38 +307,6 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
"""Register a callback invoked when a worker process dies."""
|
||||
self._failure_callbacks.append(callback)
|
||||
|
||||
def add_req(self, request: OmniDiffusionRequest) -> DiffusionOutput:
|
||||
self._ensure_open()
|
||||
rpc_request = {
|
||||
"type": "rpc",
|
||||
"method": "generate",
|
||||
"args": (request,),
|
||||
"kwargs": {},
|
||||
"output_rank": 0,
|
||||
"exec_all_ranks": True,
|
||||
}
|
||||
|
||||
try:
|
||||
self._broadcast_mq.enqueue(rpc_request)
|
||||
response = self._result_mq.dequeue()
|
||||
|
||||
try:
|
||||
unpack_diffusion_output_shm(response)
|
||||
except Exception as e:
|
||||
logger.warning("SHM unpack failed (data may already be inline): %s", e)
|
||||
|
||||
if isinstance(response, dict) and response.get("status") == "error":
|
||||
raise RuntimeError(
|
||||
f"Worker failed with error '{response.get('error')}', "
|
||||
"please check the stack trace above for the root cause"
|
||||
)
|
||||
if not isinstance(response, DiffusionOutput):
|
||||
raise RuntimeError(f"Unexpected response type for generate: {type(response)!r}")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Generate call failed: {e}")
|
||||
raise
|
||||
|
||||
def execute_request(self, scheduler_output: DiffusionSchedulerOutput) -> BaseRunnerOutput:
|
||||
"""Adapt request-mode scheduler output to worker execute_model RPC."""
|
||||
from vllm_omni.diffusion.worker.utils import RunnerOutput
|
||||
@@ -358,16 +376,21 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
kwargs = kwargs or {}
|
||||
|
||||
# Prepare RPC request message
|
||||
# When unique_reply_rank is None, all workers must execute the RPC
|
||||
# but only rank 0 can reply (it's the only one with a result_mq).
|
||||
# Prepare RPC request message. When unique_reply_rank is None, all
|
||||
# workers must execute the RPC but only rank 0 can reply (it's the
|
||||
# only one with a result_mq). Collect detailed rank statuses only for
|
||||
# this control-plane all-rank path; forward-path exec_all_ranks RPCs
|
||||
# avoid the per-step host object gather.
|
||||
execute_all_ranks = unique_reply_rank is None or exec_all_ranks
|
||||
collect_rank_status = unique_reply_rank is None
|
||||
rpc_request = {
|
||||
"type": "rpc",
|
||||
"method": method,
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
"output_rank": unique_reply_rank if unique_reply_rank is not None else 0,
|
||||
"exec_all_ranks": unique_reply_rank is None or exec_all_ranks,
|
||||
"exec_all_ranks": execute_all_ranks,
|
||||
"collect_rank_status": collect_rank_status,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -386,12 +409,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
except Exception as e:
|
||||
logger.warning("SHM unpack failed (data may already be inline): %s", e)
|
||||
|
||||
# Check if response indicates an error
|
||||
if isinstance(response, dict) and response.get("status") == "error":
|
||||
raise RuntimeError(
|
||||
f"Worker failed with error '{response.get('error')}', "
|
||||
"please check the stack trace above for the root cause"
|
||||
)
|
||||
response = MultiprocDiffusionExecutor._handle_rpc_response(response)
|
||||
|
||||
responses.append(response)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import torch
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
|
||||
_SHM_TENSOR_THRESHOLD = 1_000_000 # 1 MB
|
||||
DIFFUSION_RPC_RESULT_ENVELOPE = "diffusion_rpc_result"
|
||||
|
||||
|
||||
def _tensor_to_shm(tensor: torch.Tensor) -> dict[str, Any]:
|
||||
@@ -130,15 +131,26 @@ def _pack_diffusion_fields(output: DiffusionOutput) -> DiffusionOutput:
|
||||
return output
|
||||
|
||||
|
||||
def _is_rpc_result_envelope(output: object) -> bool:
|
||||
return isinstance(output, dict) and output.get("type") == DIFFUSION_RPC_RESULT_ENVELOPE
|
||||
|
||||
|
||||
def pack_diffusion_output_shm(output: object) -> object:
|
||||
"""Replace large tensors in diffusion worker outputs with SHM handles.
|
||||
|
||||
Supports either a bare ``DiffusionOutput`` or a wrapper object carrying one
|
||||
in ``.result`` (for example ``RunnerOutput``).
|
||||
in ``.result`` (for example ``RunnerOutput``), or an RPC result envelope
|
||||
carrying the diffusion output in ``["result"]``.
|
||||
"""
|
||||
if isinstance(output, DiffusionOutput):
|
||||
return _pack_diffusion_fields(output)
|
||||
|
||||
if _is_rpc_result_envelope(output):
|
||||
result = output.get("result")
|
||||
if isinstance(result, DiffusionOutput):
|
||||
output["result"] = _pack_diffusion_fields(result)
|
||||
return output
|
||||
|
||||
result = getattr(output, "result", None)
|
||||
if isinstance(result, DiffusionOutput):
|
||||
output.result = _pack_diffusion_fields(result)
|
||||
@@ -158,6 +170,12 @@ def unpack_diffusion_output_shm(output: object) -> object:
|
||||
if isinstance(output, DiffusionOutput):
|
||||
return _unpack_diffusion_fields(output)
|
||||
|
||||
if _is_rpc_result_envelope(output):
|
||||
result = output.get("result")
|
||||
if isinstance(result, DiffusionOutput):
|
||||
output["result"] = _unpack_diffusion_fields(result)
|
||||
return output
|
||||
|
||||
result = getattr(output, "result", None)
|
||||
if isinstance(result, DiffusionOutput):
|
||||
output.result = _unpack_diffusion_fields(result)
|
||||
|
||||
@@ -1009,28 +1009,43 @@ class WanTransformer3DModel(nn.Module):
|
||||
if encoder_hidden_states_image is not None:
|
||||
encoder_hidden_states = torch.concat([encoder_hidden_states_image, encoder_hidden_states], dim=1)
|
||||
|
||||
# Check for SP auto_pad: create attention mask dynamically if padding was applied
|
||||
hidden_states_mask = None # default
|
||||
config = get_forward_context().omni_diffusion_config
|
||||
parallel_config = config.parallel_config
|
||||
if parallel_config is not None and parallel_config.sequence_parallel_size > 1:
|
||||
ctx = get_forward_context()
|
||||
if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0:
|
||||
# Create mask for the full (padded) sequence
|
||||
# valid positions = True, padding positions = False
|
||||
batch_size = hidden_states.shape[0]
|
||||
padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
|
||||
hidden_states_mask = torch.ones(
|
||||
batch_size,
|
||||
padded_seq_len,
|
||||
dtype=torch.bool,
|
||||
device=hidden_states.device,
|
||||
)
|
||||
hidden_states_mask[:, ctx.sp_original_seq_len :] = False
|
||||
|
||||
# if mask is all true, set it to None
|
||||
if hidden_states_mask is not None and hidden_states_mask.all():
|
||||
hidden_states_mask = None
|
||||
hidden_states_mask = None
|
||||
ctx = get_forward_context()
|
||||
parallel_config = ctx.omni_diffusion_config.parallel_config
|
||||
if (
|
||||
parallel_config is not None
|
||||
and parallel_config.sequence_parallel_size > 1
|
||||
and parallel_config.mask_sp_padding
|
||||
and ctx.sp_original_seq_len is not None
|
||||
and ctx.sp_padding_size > 0
|
||||
):
|
||||
batch_size = hidden_states.shape[0]
|
||||
padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
|
||||
hidden_states_mask = torch.ones(
|
||||
batch_size,
|
||||
padded_seq_len,
|
||||
dtype=torch.bool,
|
||||
device=hidden_states.device,
|
||||
)
|
||||
hidden_states_mask[:, ctx.sp_original_seq_len :] = False
|
||||
if hidden_states_mask.all():
|
||||
hidden_states_mask = None
|
||||
elif (
|
||||
parallel_config is not None
|
||||
and parallel_config.sequence_parallel_size > 1
|
||||
and not parallel_config.mask_sp_padding
|
||||
and ctx.sp_original_seq_len is not None
|
||||
and ctx.sp_padding_size > 0
|
||||
):
|
||||
logger.warning_once(
|
||||
"SP auto-padding applied %d token(s) (seq_len=%d, ulysses_degree=%d). "
|
||||
"Padding tokens are not masked from attention (mask_sp_padding=False), "
|
||||
"which avoids the varlen attention path but may produce minor numerical differences. "
|
||||
"Set parallel_config.mask_sp_padding=True to restore strict masking.",
|
||||
ctx.sp_padding_size,
|
||||
ctx.sp_original_seq_len,
|
||||
parallel_config.sequence_parallel_size,
|
||||
)
|
||||
|
||||
# Transformer blocks
|
||||
for block in self.blocks[self.start_layer : self.end_layer]:
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
|
||||
|
||||
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput
|
||||
@@ -21,6 +22,8 @@ from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import (
|
||||
WanTransformerBlock,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class VaceWanTransformerBlock(WanTransformerBlock):
|
||||
"""VACE variant of WanTransformerBlock with proj_in/proj_out for skip connections."""
|
||||
@@ -214,12 +217,16 @@ class WanVACETransformer3DModel(WanTransformer3DModel):
|
||||
# Shard hidden_states via _sp_plan hook (before VACE, not at blocks.0)
|
||||
hidden_states = self._sp_shard_point(hidden_states)
|
||||
|
||||
# SP state and attention mask for padding
|
||||
hidden_states_mask = None
|
||||
ctx = get_forward_context()
|
||||
parallel_config = ctx.omni_diffusion_config.parallel_config
|
||||
sp_size = parallel_config.sequence_parallel_size if parallel_config is not None else 1
|
||||
if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0:
|
||||
if (
|
||||
parallel_config is not None
|
||||
and parallel_config.mask_sp_padding
|
||||
and ctx.sp_original_seq_len is not None
|
||||
and ctx.sp_padding_size > 0
|
||||
):
|
||||
padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
|
||||
hidden_states_mask = torch.ones(
|
||||
batch_size,
|
||||
@@ -228,6 +235,21 @@ class WanVACETransformer3DModel(WanTransformer3DModel):
|
||||
device=hidden_states.device,
|
||||
)
|
||||
hidden_states_mask[:, ctx.sp_original_seq_len :] = False
|
||||
elif (
|
||||
parallel_config is not None
|
||||
and not parallel_config.mask_sp_padding
|
||||
and ctx.sp_original_seq_len is not None
|
||||
and ctx.sp_padding_size > 0
|
||||
):
|
||||
logger.warning_once(
|
||||
"SP auto-padding applied %d token(s) (seq_len=%d, ulysses_degree=%d). "
|
||||
"Padding tokens are not masked from attention (mask_sp_padding=False), "
|
||||
"which avoids the varlen attention path but may produce minor numerical differences. "
|
||||
"Set parallel_config.mask_sp_padding=True to restore strict masking.",
|
||||
ctx.sp_padding_size,
|
||||
ctx.sp_original_seq_len,
|
||||
sp_size,
|
||||
)
|
||||
|
||||
# VACE: embed context and run conditioning blocks
|
||||
vace_hints = None
|
||||
|
||||
@@ -12,6 +12,7 @@ import gc
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import signal
|
||||
import traceback
|
||||
from collections.abc import Iterable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager, nullcontext
|
||||
from dataclasses import dataclass
|
||||
@@ -41,7 +42,7 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import set_forward_context
|
||||
from vllm_omni.diffusion.ipc import pack_diffusion_output_shm
|
||||
from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE, pack_diffusion_output_shm
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
|
||||
@@ -740,6 +741,18 @@ class WorkerProc:
|
||||
"""Receive messages from broadcast queue."""
|
||||
return self.mq.dequeue(indefinite=True)
|
||||
|
||||
def _gather_rpc_rank_statuses(self, status: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not torch.distributed.is_initialized():
|
||||
return [status]
|
||||
|
||||
world_size = torch.distributed.get_world_size()
|
||||
statuses: list[dict[str, Any] | None] = [None] * world_size
|
||||
torch.distributed.all_gather_object(statuses, status)
|
||||
missing_ranks = [rank for rank, rank_status in enumerate(statuses) if rank_status is None]
|
||||
if missing_ranks:
|
||||
logger.warning("RPC rank status gather returned missing entries for ranks: %s", missing_ranks)
|
||||
return [s for s in statuses if s is not None]
|
||||
|
||||
def execute_rpc(self, rpc_request: dict) -> tuple[object | None, bool]:
|
||||
"""Execute an RPC request and indicate whether to reply."""
|
||||
method = rpc_request["method"]
|
||||
@@ -747,6 +760,10 @@ class WorkerProc:
|
||||
kwargs = rpc_request.get("kwargs", {})
|
||||
output_rank = rpc_request.get("output_rank")
|
||||
exec_all_ranks = rpc_request.get("exec_all_ranks", False)
|
||||
collect_rank_status = rpc_request.get("collect_rank_status", False)
|
||||
|
||||
if collect_rank_status and not exec_all_ranks:
|
||||
raise ValueError("collect_rank_status requires exec_all_ranks=True so all ranks enter the status gather")
|
||||
|
||||
should_execute = exec_all_ranks or output_rank is None or output_rank == self.gpu_id
|
||||
should_reply = (output_rank is None or output_rank == self.gpu_id) and self.result_mq is not None
|
||||
@@ -754,13 +771,52 @@ class WorkerProc:
|
||||
if not should_execute:
|
||||
return None, False
|
||||
|
||||
result = None
|
||||
rpc_exception: Exception | None = None
|
||||
status: dict[str, Any] = {
|
||||
"rank": self.gpu_id,
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"error_type": None,
|
||||
"traceback": None,
|
||||
"bool_result": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Use execute_method from WorkerWrapperBase for consistent method resolution
|
||||
result = self.worker.execute_method(method, *args, **kwargs)
|
||||
return result, should_reply
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing RPC: {e}", exc_info=True)
|
||||
raise e
|
||||
rpc_exception = e
|
||||
status.update(
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(result, bool):
|
||||
status["bool_result"] = result
|
||||
|
||||
if collect_rank_status:
|
||||
rank_statuses = self._gather_rpc_rank_statuses(status)
|
||||
if should_reply:
|
||||
return (
|
||||
{
|
||||
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
|
||||
"method": method,
|
||||
"result": result,
|
||||
"rank_statuses": rank_statuses,
|
||||
},
|
||||
True,
|
||||
)
|
||||
return None, False
|
||||
|
||||
if rpc_exception is not None:
|
||||
raise rpc_exception
|
||||
return result, should_reply
|
||||
|
||||
def worker_busy_loop(self) -> None:
|
||||
"""Main busy loop for Multiprocessing Workers."""
|
||||
@@ -801,7 +857,7 @@ class WorkerProc:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing RPC: {e}", exc_info=True)
|
||||
if self.result_mq is not None:
|
||||
self.return_result(DiffusionOutput.from_exception(e))
|
||||
self.return_result({"status": "error", "error": str(e)})
|
||||
|
||||
elif isinstance(msg, dict) and msg.get("type") == "shutdown":
|
||||
logger.info("Worker %s: Received shutdown message", self.gpu_id)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import importlib
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -146,7 +146,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
|
||||
def save_async(
|
||||
self,
|
||||
pooling_output: torch.Tensor | None = None,
|
||||
multimodal_output: dict[str, Any] | None = None,
|
||||
request: Request | None = None,
|
||||
is_segment_finished: bool = False,
|
||||
):
|
||||
@@ -163,7 +163,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
as ``request.is_finished()``.
|
||||
|
||||
Args:
|
||||
pooling_output: Partial pooling output dictionary
|
||||
multimodal_output: Per-request multimodal output dictionary
|
||||
request: Request object
|
||||
is_segment_finished: whether the segment of request is finished
|
||||
"""
|
||||
@@ -183,7 +183,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
|
||||
self.requests_num_chunks_sent[request.external_req_id] = confirmed_num_computed_tokens
|
||||
task = {
|
||||
"pooling_output": pooling_output,
|
||||
"multimodal_output": multimodal_output,
|
||||
"request": request,
|
||||
"is_finished": is_finished,
|
||||
"is_segment_finished": is_segment_finished,
|
||||
@@ -292,8 +292,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
return False
|
||||
|
||||
def _send_single_request(self, task: dict):
|
||||
raw_po = task["pooling_output"]
|
||||
pooling_output = unflatten_payload(raw_po) if isinstance(raw_po, dict) else raw_po
|
||||
raw_mm = task["multimodal_output"]
|
||||
multimodal_output = unflatten_payload(raw_mm) if isinstance(raw_mm, Mapping) else raw_mm
|
||||
request = task["request"]
|
||||
is_finished = task["is_finished"]
|
||||
is_segment_finished = task["is_segment_finished"]
|
||||
@@ -308,7 +308,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
try:
|
||||
payload_data = self.custom_process_next_stage_input_func(
|
||||
transfer_manager=self,
|
||||
pooling_output=pooling_output,
|
||||
multimodal_output=multimodal_output,
|
||||
request=request,
|
||||
# Existing processors use is_finished as a flush signal.
|
||||
is_finished=is_segment_finished,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -150,10 +151,13 @@ class OmniMsgpackEncoder:
|
||||
"multi_modal_placeholders": getattr(obj, "multi_modal_placeholders", None),
|
||||
"kv_transfer_params": obj.kv_transfer_params,
|
||||
}
|
||||
# Handle dynamically added multimodal_output attribute
|
||||
# Handle multimodal_output attribute (MultimodalPayload or dict)
|
||||
mm_output = getattr(obj, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
result["multimodal_output"] = mm_output
|
||||
if isinstance(mm_output, Mapping):
|
||||
result["multimodal_output"] = dict(mm_output)
|
||||
else:
|
||||
result["multimodal_output"] = mm_output
|
||||
return result
|
||||
|
||||
def _encode_completion_output(self, obj: CompletionOutput) -> dict[str, Any]:
|
||||
@@ -161,7 +165,11 @@ class OmniMsgpackEncoder:
|
||||
result = asdict(obj)
|
||||
mm_output = getattr(obj, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
result["multimodal_output"] = mm_output
|
||||
# Convert MultimodalPayload to plain dict for wire format
|
||||
if isinstance(mm_output, Mapping):
|
||||
result["multimodal_output"] = dict(mm_output)
|
||||
else:
|
||||
result["multimodal_output"] = mm_output
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -117,7 +117,10 @@ class OmniEngineCoreRequest(EngineCoreRequest):
|
||||
|
||||
|
||||
class OmniEngineCoreOutput(EngineCoreOutput):
|
||||
pooling_output: dict[str, torch.Tensor] | None = None
|
||||
# Dedicated channel for multimodal outputs (image/audio/latent).
|
||||
# pooling_output is inherited from EngineCoreOutput as torch.Tensor | None
|
||||
# and retains its original vLLM semantics for pooling/embedding tasks.
|
||||
multimodal_output: dict[str, torch.Tensor] | None = None
|
||||
# Finished flag for streaming input segment
|
||||
is_segment_finished: bool | None = False
|
||||
# Streaming update prompt length
|
||||
|
||||
@@ -1480,7 +1480,7 @@ class AsyncOmniEngine:
|
||||
return
|
||||
|
||||
mm_data = prompt.get("multi_modal_data")
|
||||
if not isinstance(mm_data, dict) or not mm_data:
|
||||
if not isinstance(mm_data, Mapping) or not mm_data:
|
||||
return
|
||||
|
||||
from vllm.multimodal.hasher import MultiModalHasher
|
||||
|
||||
@@ -6,6 +6,7 @@ This module defines structured types for multimodal outputs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -13,10 +14,15 @@ import torch
|
||||
from vllm.outputs import CompletionOutput
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultimodalPayload:
|
||||
@dataclass(eq=False)
|
||||
class MultimodalPayload(Mapping):
|
||||
"""Structured multimodal output payload.
|
||||
|
||||
Implements ``collections.abc.Mapping`` so that ``isinstance(payload, dict)``
|
||||
style checks in downstream code can be replaced with duck-typing, and
|
||||
``payload.get(key)``, ``payload[key]``, ``key in payload``, ``len(payload)``
|
||||
all work seamlessly for both tensors and metadata.
|
||||
|
||||
Attributes:
|
||||
tensors: Dictionary mapping modality/key names to their tensors.
|
||||
metadata: Optional dictionary for non-tensor metadata
|
||||
@@ -35,18 +41,49 @@ class MultimodalPayload:
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return True if the payload has no tensors."""
|
||||
return len(self.tensors) == 0
|
||||
"""Return True if the payload has no tensors and no metadata."""
|
||||
return len(self.tensors) == 0 and len(self.metadata) == 0
|
||||
|
||||
def get(self, key: str) -> torch.Tensor | None:
|
||||
"""Get a tensor by key, returning None if not found."""
|
||||
return self.tensors.get(key)
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value by key, searching tensors first then metadata."""
|
||||
if key in self.tensors:
|
||||
return self.tensors[key]
|
||||
return self.metadata.get(key, default)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self.tensors
|
||||
return key in self.tensors or key in self.metadata
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Dict-like indexing: search tensors first, then metadata."""
|
||||
if key in self.tensors:
|
||||
return self.tensors[key]
|
||||
if key in self.metadata:
|
||||
return self.metadata[key]
|
||||
raise KeyError(key)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.tensors)
|
||||
return len(self.tensors) + len(self.metadata)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
yield from self.tensors
|
||||
yield from self.metadata
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.tensors) or bool(self.metadata)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Support equality with plain dicts and other Mappings."""
|
||||
if isinstance(other, MultimodalPayload):
|
||||
return self.tensors == other.tensors and self.metadata == other.metadata
|
||||
if isinstance(other, Mapping):
|
||||
return dict(self) == dict(other)
|
||||
return NotImplemented
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert back to a plain dict (tensors + metadata merged)."""
|
||||
result: dict[str, Any] = dict(self.tensors)
|
||||
result.update(self.metadata)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> MultimodalPayload | None:
|
||||
|
||||
@@ -21,6 +21,8 @@ _MODALITY_ALIASES: dict[str, str] = {
|
||||
"waveform": "audio",
|
||||
"pixel_values": "image",
|
||||
"pixels": "image",
|
||||
"token_ids": "text",
|
||||
"tokens": "text",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from dataclasses import fields as dataclass_fields
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.outputs import PoolingRequestOutput
|
||||
from vllm.outputs import CompletionOutput, PoolingRequestOutput, RequestOutput
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason
|
||||
@@ -16,12 +17,98 @@ from vllm.v1.engine.parallel_sampling import ParentRequest
|
||||
from vllm.v1.metrics.stats import IterationStats, RequestStateStats
|
||||
|
||||
from vllm_omni.data_entry_keys import unflatten_payload
|
||||
from vllm_omni.engine.output_modality import DRAINABLE_MODALITIES
|
||||
from vllm_omni.engine.mm_outputs import MultimodalCompletionOutput, MultimodalPayload
|
||||
from vllm_omni.engine.output_modality import (
|
||||
DRAINABLE_MODALITIES,
|
||||
OutputModality,
|
||||
TensorAccumulationStrategy,
|
||||
get_accumulation_strategy,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_cpu(x: Any) -> Any:
|
||||
"""Move a tensor to CPU; pass through non-tensors unchanged."""
|
||||
if isinstance(x, torch.Tensor):
|
||||
try:
|
||||
return x.detach().to("cpu", non_blocking=True).contiguous()
|
||||
except (RuntimeError, AttributeError):
|
||||
return x
|
||||
if isinstance(x, dict):
|
||||
return {str(sk): _to_cpu(sv) for sk, sv in x.items()}
|
||||
return x
|
||||
|
||||
|
||||
def _merge_payload(target: MultimodalPayload, incoming: MultimodalPayload) -> None:
|
||||
"""Merge *incoming* into *target* using list-based deferred concatenation."""
|
||||
for k, v in incoming.tensors.items():
|
||||
if k not in target.tensors:
|
||||
target.tensors[k] = v
|
||||
else:
|
||||
existing = target.tensors[k]
|
||||
if isinstance(existing, list):
|
||||
existing.append(v)
|
||||
elif isinstance(existing, torch.Tensor) and isinstance(v, torch.Tensor):
|
||||
target.tensors[k] = [existing, v]
|
||||
else:
|
||||
target.tensors[k] = v
|
||||
|
||||
for k, v in incoming.metadata.items():
|
||||
if k not in target.metadata:
|
||||
target.metadata[k] = v
|
||||
else:
|
||||
existing = target.metadata[k]
|
||||
if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor):
|
||||
target.metadata[k] = [existing, v]
|
||||
elif isinstance(v, torch.Tensor) and isinstance(existing, list):
|
||||
existing.append(v)
|
||||
else:
|
||||
# Metadata: replace with latest value
|
||||
target.metadata[k] = v
|
||||
|
||||
|
||||
def _cat_tensors(
|
||||
tensors: list[torch.Tensor],
|
||||
strategy: TensorAccumulationStrategy,
|
||||
) -> torch.Tensor:
|
||||
"""Concatenate a list of tensors according to *strategy*."""
|
||||
if strategy == TensorAccumulationStrategy.CONCAT_LAST:
|
||||
return torch.cat(tensors, dim=-1)
|
||||
if strategy == TensorAccumulationStrategy.REPLACE:
|
||||
return tensors[-1]
|
||||
# CONCAT_DIM0 / APPEND_LIST / default
|
||||
return torch.cat(tensors, dim=0)
|
||||
|
||||
|
||||
def _modality_to_type_string(modality: OutputModality) -> str:
|
||||
"""Convert an OutputModality flag to a lowercase type string."""
|
||||
try:
|
||||
if OutputModality.AUDIO in modality:
|
||||
return "audio"
|
||||
if OutputModality.IMAGE in modality:
|
||||
return "image"
|
||||
if OutputModality.LATENT in modality:
|
||||
return "latent"
|
||||
except TypeError:
|
||||
# Flag identity mismatch (e.g. after module reload in tests).
|
||||
name = getattr(modality, "name", "") or ""
|
||||
lowered = name.lower()
|
||||
if "audio" in lowered:
|
||||
return "audio"
|
||||
if "image" in lowered:
|
||||
return "image"
|
||||
if "latent" in lowered:
|
||||
return "latent"
|
||||
return "text"
|
||||
|
||||
|
||||
class OmniRequestState(RequestState):
|
||||
"""Request state for omni models, tracking multimodal outputs.
|
||||
|
||||
@@ -41,10 +128,12 @@ class OmniRequestState(RequestState):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.native_text_stats = RequestStateStats(arrival_time=float(arrival_time or 0.0))
|
||||
# Omni-specific: multimodal output accumulation
|
||||
# NOTE: Keys in mm_accumulated matter, because they dictate which
|
||||
# outputs are drained (i.e., only drain modality keys, don't drain
|
||||
# hidden states).
|
||||
self.mm_accumulated: dict[str, Any] = {}
|
||||
# TODO: mm_type is per-request, not per-key. If a model ever produces
|
||||
# both audio and latent outputs, the modality type would flip on each
|
||||
# add_multimodal_tensor() call. Consider tracking per-key modality
|
||||
# types (e.g. dict[str, str]) for future multi-output models.
|
||||
self.mm_type: str | None = None
|
||||
self.mm_accumulated: MultimodalPayload = MultimodalPayload()
|
||||
|
||||
def apply_streaming_update(self, update) -> None:
|
||||
super().apply_streaming_update(update)
|
||||
@@ -53,8 +142,6 @@ class OmniRequestState(RequestState):
|
||||
@staticmethod
|
||||
def _to_cpu(x):
|
||||
"""Try to convert to CPU tensor if needed."""
|
||||
# TODO: Make this more robust and unify with other payload
|
||||
# building utils, we do this in multiple places.
|
||||
if isinstance(x, torch.Tensor):
|
||||
try:
|
||||
return x.detach().to("cpu", non_blocking=True).contiguous()
|
||||
@@ -63,109 +150,94 @@ class OmniRequestState(RequestState):
|
||||
return x
|
||||
|
||||
def add_multimodal_tensor(self, payload: Any | None, mm_type: str | None) -> None:
|
||||
"""Accumulate a multimodal tensor payload into the request state.
|
||||
|
||||
Normalizes incoming payloads (dict or raw tensor) into a
|
||||
MultimodalPayload and merges with any previously accumulated data.
|
||||
Uses list-based deferred concatenation to avoid O(n²) repeated
|
||||
torch.cat calls.
|
||||
"""
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
mm_type = (mm_type or "").lower()
|
||||
try:
|
||||
if mm_type:
|
||||
self.mm_type = (mm_type or "").lower()
|
||||
|
||||
target_key = self.mm_type or "hidden"
|
||||
|
||||
# Build incoming MultimodalPayload
|
||||
if isinstance(payload, dict):
|
||||
# Keep payload flat (dotted keys like "hidden_states.layer_0")
|
||||
# during accumulation so that all values are tensors/scalars and
|
||||
# the merge logic below works correctly. Unflatten happens
|
||||
# later in _consolidate_multimodal_tensors after concatenation.
|
||||
|
||||
incoming: dict[str, Any] = {}
|
||||
# TODO (Alex): Clean up and simplify key management
|
||||
target_key = mm_type or "hidden"
|
||||
|
||||
remapped: dict[str, Any] = {}
|
||||
for k, v in payload.items():
|
||||
# Normalize producer keys to the modality name.
|
||||
# AR runners produce {"hidden": ...} and generation
|
||||
# runners produce {"model_outputs": ...}; remap both
|
||||
# to the semantic modality key (e.g. "audio", "latent").
|
||||
if k == "model_outputs":
|
||||
k = target_key
|
||||
elif k == "hidden" and target_key != "hidden":
|
||||
k = target_key
|
||||
|
||||
incoming[k] = self._to_cpu(v)
|
||||
remapped[k] = _to_cpu(v)
|
||||
incoming = MultimodalPayload.from_dict(remapped)
|
||||
elif isinstance(payload, MultimodalPayload):
|
||||
incoming = payload
|
||||
else:
|
||||
key = mm_type or "hidden"
|
||||
incoming = {key: self._to_cpu(payload)}
|
||||
incoming = MultimodalPayload.from_dict({target_key: _to_cpu(payload)})
|
||||
|
||||
if not self.mm_accumulated:
|
||||
if incoming is None:
|
||||
return
|
||||
|
||||
if self.mm_accumulated.is_empty:
|
||||
self.mm_accumulated = incoming
|
||||
else:
|
||||
for k, v in incoming.items():
|
||||
if k not in self.mm_accumulated:
|
||||
self.mm_accumulated[k] = v
|
||||
else:
|
||||
existing = self.mm_accumulated[k]
|
||||
if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor):
|
||||
self.mm_accumulated[k] = [existing, v]
|
||||
elif isinstance(v, torch.Tensor) and isinstance(existing, list):
|
||||
existing.append(v)
|
||||
elif isinstance(v, dict) and isinstance(existing, dict):
|
||||
for sk, sv in v.items():
|
||||
if sk not in existing:
|
||||
existing[sk] = sv
|
||||
elif isinstance(sv, torch.Tensor) and isinstance(existing[sk], torch.Tensor):
|
||||
existing[sk] = [existing[sk], sv]
|
||||
elif isinstance(sv, torch.Tensor) and isinstance(existing[sk], list):
|
||||
existing[sk].append(sv)
|
||||
else:
|
||||
existing[sk] = sv
|
||||
else:
|
||||
self.mm_accumulated[k] = v
|
||||
except Exception:
|
||||
# Log and continue without crashing the output pipeline
|
||||
_merge_payload(self.mm_accumulated, incoming)
|
||||
except (ValueError, TypeError, RuntimeError):
|
||||
logger.exception("Error accumulating multimodal tensor")
|
||||
|
||||
def _consolidate_multimodal_tensors(self) -> None:
|
||||
"""Consolidate accumulated tensor lists into single tensors via concatenation.
|
||||
"""Consolidate accumulated tensor lists into single tensors.
|
||||
|
||||
Only DELTA drains modality keys per-step, so they will never be lists here and
|
||||
can be skipped. For CUMULATIVE and FINAL_ONLY, modality keys accumulate across
|
||||
steps and need consolidation.
|
||||
Uses TensorAccumulationStrategy derived from the output modality
|
||||
to determine concatenation behavior. Metadata values always use
|
||||
REPLACE (keep latest).
|
||||
"""
|
||||
if not self.mm_accumulated:
|
||||
if self.mm_accumulated.is_empty:
|
||||
return
|
||||
|
||||
skip_modality = self.output_kind == RequestOutputKind.DELTA
|
||||
try:
|
||||
for k, v in self.mm_accumulated.items():
|
||||
if skip_modality and k in DRAINABLE_MODALITIES:
|
||||
continue
|
||||
modality = OutputModality.from_string(self.mm_type)
|
||||
except (ValueError, KeyError):
|
||||
modality = OutputModality.TEXT
|
||||
strategy = get_accumulation_strategy(modality)
|
||||
|
||||
try:
|
||||
for k, v in list(self.mm_accumulated.tensors.items()):
|
||||
if isinstance(v, list) and v and isinstance(v[0], torch.Tensor):
|
||||
try:
|
||||
self.mm_accumulated.tensors[k] = _cat_tensors(v, strategy)
|
||||
except RuntimeError:
|
||||
if k == "audio":
|
||||
# Preserve channel dimension when chunks are compatible;
|
||||
# fall back to 1-D waveform concatenation for uneven chunks.
|
||||
try:
|
||||
self.mm_accumulated[k] = torch.cat(v, dim=-1)
|
||||
self.mm_accumulated.tensors[k] = torch.cat(v, dim=-1)
|
||||
except RuntimeError:
|
||||
self.mm_accumulated[k] = torch.cat([t.reshape(-1) for t in v], dim=0)
|
||||
elif k == "sr":
|
||||
# Sample rate is a constant scalar, keep last value.
|
||||
self.mm_accumulated[k] = v[-1]
|
||||
self.mm_accumulated.tensors[k] = torch.cat([t.reshape(-1) for t in v], dim=0)
|
||||
else:
|
||||
self.mm_accumulated[k] = torch.cat(v, dim=0)
|
||||
except Exception:
|
||||
# Keep last tensor on failure
|
||||
logger.warning(f"Error concatenating tensor for key {k}; keeping last tensor")
|
||||
self.mm_accumulated[k] = v[-1]
|
||||
elif isinstance(v, dict):
|
||||
for sk, sv in v.items():
|
||||
if isinstance(sv, list) and sv and isinstance(sv[0], torch.Tensor):
|
||||
try:
|
||||
v[sk] = torch.cat(sv, dim=0)
|
||||
except Exception:
|
||||
v[sk] = sv[-1]
|
||||
except Exception:
|
||||
logger.exception("Error consolidating multimodal tensors")
|
||||
logger.warning(
|
||||
"Error concatenating tensor for key %s; keeping last tensor",
|
||||
k,
|
||||
)
|
||||
self.mm_accumulated.tensors[k] = v[-1]
|
||||
|
||||
# Restore nested structure from flat dotted keys now that all tensor
|
||||
# lists have been concatenated into single tensors.
|
||||
try:
|
||||
self.mm_accumulated = unflatten_payload(self.mm_accumulated)
|
||||
except Exception:
|
||||
logger.exception("Error unflattening consolidated multimodal tensors")
|
||||
# Metadata: consolidate any deferred tensor lists (REPLACE semantics)
|
||||
for k, v in list(self.mm_accumulated.metadata.items()):
|
||||
if isinstance(v, list) and v and isinstance(v[0], torch.Tensor):
|
||||
self.mm_accumulated.metadata[k] = v[-1]
|
||||
elif isinstance(v, dict):
|
||||
for sk, sv in list(v.items()):
|
||||
if isinstance(sv, list) and sv and isinstance(sv[0], torch.Tensor):
|
||||
v[sk] = sv[-1]
|
||||
except (RuntimeError, TypeError, KeyError):
|
||||
logger.exception("Error consolidating multimodal tensors")
|
||||
|
||||
# Override: do not route to pooling-only path; always create completion
|
||||
# outputs, and attach pooling_result into the CompletionOutput.
|
||||
@@ -205,18 +277,18 @@ class OmniRequestState(RequestState):
|
||||
)
|
||||
|
||||
finished = finish_reason is not None
|
||||
is_final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
|
||||
final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
|
||||
is_delta = self.output_kind == RequestOutputKind.DELTA
|
||||
|
||||
if not finished and is_final_only:
|
||||
if not finished and final_only:
|
||||
return None
|
||||
|
||||
# Consolidate accumulated tensors when finishing, or for every
|
||||
# CUMULATIVE step so the consumer always sees a single tensor.
|
||||
if finished or not is_delta:
|
||||
self._consolidate_multimodal_tensors()
|
||||
|
||||
if self.stream_interval > 1:
|
||||
assert self.detokenizer is not None
|
||||
|
||||
if self.stream_interval > 1 and self.detokenizer is not None:
|
||||
# Send output request only when
|
||||
# 1. It has finished, or
|
||||
# 2. It is the first token, or
|
||||
@@ -228,7 +300,7 @@ class OmniRequestState(RequestState):
|
||||
):
|
||||
return None
|
||||
|
||||
if is_delta:
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
# Send tokens from the offset in DELTA mode, otherwise all
|
||||
# tokens are sent.
|
||||
new_token_ids = self.detokenizer.output_token_ids[self.sent_tokens_offset :]
|
||||
@@ -258,50 +330,127 @@ class OmniRequestState(RequestState):
|
||||
token_ids: list[int],
|
||||
finish_reason: FinishReason | None,
|
||||
stop_reason: int | str | None,
|
||||
) -> Any:
|
||||
# Reuse base text/logprobs logic, then annotate with pooling_result.
|
||||
# Note: upstream _new_completion_output no longer accepts routed_experts
|
||||
# as a parameter; it reads from ``self.routed_experts_chunks`` internally.
|
||||
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason)
|
||||
) -> MultimodalCompletionOutput | CompletionOutput:
|
||||
"""Create a completion output with multimodal data attached.
|
||||
|
||||
# Inter-stage processors need the full cumulative token sequence.
|
||||
# In DELTA mode, base_output.token_ids only has the latest step's
|
||||
# tokens, so we always store a cumulative copy here.
|
||||
base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids)
|
||||
Returns a MultimodalCompletionOutput when multimodal data has been
|
||||
accumulated, otherwise returns the base CompletionOutput. Snapshots
|
||||
the accumulated payload before draining DELTA-mode modality keys so
|
||||
that callers receive an immutable view.
|
||||
"""
|
||||
# When there is no detokenizer (generation stages), build a minimal
|
||||
# CompletionOutput directly since upstream asserts detokenizer != None.
|
||||
if self.detokenizer is None:
|
||||
finished = finish_reason is not None
|
||||
base_output = CompletionOutput(
|
||||
index=self.request_index,
|
||||
text="",
|
||||
token_ids=token_ids,
|
||||
logprobs=None,
|
||||
cumulative_logprob=None,
|
||||
finish_reason=str(finish_reason) if finished else None,
|
||||
stop_reason=stop_reason if finished else None,
|
||||
)
|
||||
else:
|
||||
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason)
|
||||
|
||||
# Always provide cumulative token IDs for inter-stage processors.
|
||||
if self.detokenizer is not None:
|
||||
base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids)
|
||||
else:
|
||||
base_output.cumulative_token_ids = list(token_ids)
|
||||
|
||||
# Attach cumulative_text only at the final step for inter-stage use.
|
||||
if finish_reason is not None and hasattr(self.detokenizer, "output_text"):
|
||||
base_output.cumulative_text = self.detokenizer.output_text
|
||||
|
||||
if not hasattr(base_output, "multimodal_output"):
|
||||
setattr(base_output, "multimodal_output", {})
|
||||
if self.mm_accumulated:
|
||||
mm_out = getattr(base_output, "multimodal_output")
|
||||
if isinstance(mm_out, dict):
|
||||
for k, v in self.mm_accumulated.items():
|
||||
mm_out[k] = v
|
||||
else:
|
||||
setattr(base_output, "multimodal_output", self.mm_accumulated)
|
||||
try:
|
||||
if self.mm_accumulated and not self.mm_accumulated.is_empty:
|
||||
# Snapshot: copy current tensors/metadata so drain doesn't
|
||||
# mutate the payload already handed to the caller.
|
||||
# Unflatten dotted keys (e.g. "hidden_states.layer_0") back
|
||||
# to nested dicts so downstream consumers (thinker2talker etc.)
|
||||
# can access with .get("hidden_states", {}).get("layers", {}).
|
||||
merged = {**self.mm_accumulated.tensors, **self.mm_accumulated.metadata}
|
||||
nested = unflatten_payload(merged)
|
||||
snapshot = MultimodalPayload.from_dict(nested) or MultimodalPayload()
|
||||
kwargs = {f.name: getattr(base_output, f.name) for f in dataclass_fields(CompletionOutput)}
|
||||
output = MultimodalCompletionOutput(
|
||||
multimodal_output=snapshot,
|
||||
**kwargs,
|
||||
)
|
||||
output.cumulative_token_ids = base_output.cumulative_token_ids
|
||||
if hasattr(base_output, "cumulative_text"):
|
||||
output.cumulative_text = base_output.cumulative_text
|
||||
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
for modality_key in DRAINABLE_MODALITIES:
|
||||
self.mm_accumulated.pop(modality_key, None)
|
||||
# DELTA mode: drain modality keys (e.g. audio) so the next
|
||||
# step only sees freshly accumulated data for those keys.
|
||||
if self.output_kind == RequestOutputKind.DELTA:
|
||||
for modality_key in DRAINABLE_MODALITIES:
|
||||
self.mm_accumulated.tensors.pop(modality_key, None)
|
||||
|
||||
return output
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
logger.exception("Error creating MultimodalCompletionOutput")
|
||||
return base_output
|
||||
|
||||
def _new_request_output(
|
||||
self,
|
||||
external_req_id: str,
|
||||
outputs: list,
|
||||
finished: bool,
|
||||
kv_transfer_params: dict[str, Any] | None = None,
|
||||
) -> RequestOutput | PoolingRequestOutput:
|
||||
"""Create request output, handling no-detokenizer generation stages.
|
||||
|
||||
Upstream asserts ``self.logprobs_processor is not None`` which fails
|
||||
for generation stages (talker, code2wav) that have no tokenizer.
|
||||
When the logprobs processor is absent we build the RequestOutput
|
||||
directly with ``prompt_logprobs=None``.
|
||||
"""
|
||||
if self.logprobs_processor is not None:
|
||||
return super()._new_request_output(
|
||||
external_req_id,
|
||||
outputs,
|
||||
finished,
|
||||
kv_transfer_params,
|
||||
)
|
||||
|
||||
# No-detokenizer path: build RequestOutput directly.
|
||||
prompt_token_ids = self.prompt_token_ids
|
||||
if prompt_token_ids is None and self.prompt_embeds is not None:
|
||||
prompt_token_ids = [0] * len(self.prompt_embeds)
|
||||
if prompt_token_ids is None:
|
||||
prompt_token_ids = []
|
||||
|
||||
return RequestOutput(
|
||||
request_id=external_req_id,
|
||||
lora_request=self.lora_request,
|
||||
prompt=self.prompt,
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
prompt_logprobs=None,
|
||||
outputs=outputs,
|
||||
finished=finished,
|
||||
kv_transfer_params=kv_transfer_params,
|
||||
num_cached_tokens=self.num_cached_tokens,
|
||||
metrics=self.stats,
|
||||
)
|
||||
|
||||
|
||||
class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
"""Handles multimodal output processing by capturing pooling_output
|
||||
from EngineCoreOutput and accumulating it as multimodal tensors,
|
||||
before delegating to the base vLLM OutputProcessor for text handling.
|
||||
"""Handles multimodal output processing.
|
||||
|
||||
The actual data flow is:
|
||||
1. For each EngineCoreOutput with pooling_output and a detokenizer:
|
||||
- Capture pooling_output into OmniRequestState.add_multimodal_tensor()
|
||||
- Clear eco.pooling_output to force text path in base processor
|
||||
Captures multimodal outputs from OmniEngineCoreOutput and accumulates
|
||||
them as MultimodalPayload in OmniRequestState, before delegating to
|
||||
the base vLLM OutputProcessor for text handling.
|
||||
|
||||
The data flow is:
|
||||
1. For each EngineCoreOutput with multimodal_output:
|
||||
- Capture into OmniRequestState.add_multimodal_tensor()
|
||||
2. Base vLLM OutputProcessor handles text detokenization
|
||||
3. On finish, _consolidate_multimodal_tensors() concatenates accumulated tensors
|
||||
4. _new_completion_output() attaches mm_accumulated to CompletionOutput
|
||||
3. On finish, _consolidate_multimodal_tensors() concatenates accumulated
|
||||
tensors using strategy-based dispatch
|
||||
4. _new_completion_output() returns MultimodalCompletionOutput
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -312,6 +461,7 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
stream_interval: int = 1,
|
||||
tracing_enabled: bool = False,
|
||||
engine_core_output_type: str | None = None,
|
||||
output_modality: OutputModality = OutputModality.TEXT,
|
||||
):
|
||||
"""Initialize the multimodal output processor.
|
||||
|
||||
@@ -319,9 +469,13 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
tokenizer: Tokenizer for detokenizing text outputs
|
||||
log_stats: Whether to log statistics
|
||||
stream_interval: Stream interval for output generation
|
||||
engine_core_output_type: Optional output type specification
|
||||
(e.g., "image", "audio", "latent"). Used to tag multimodal
|
||||
outputs with the correct modality key.
|
||||
engine_core_output_type: Optional output type string (e.g.,
|
||||
"image", "audio", "latent"). Converted to OutputModality
|
||||
internally. Kept for backward compatibility with
|
||||
stage_init_utils.
|
||||
output_modality: Type-safe output modality flag. Used to tag
|
||||
multimodal outputs with the correct modality key when
|
||||
per-output type info is unavailable.
|
||||
"""
|
||||
super().__init__(
|
||||
tokenizer=tokenizer,
|
||||
@@ -329,6 +483,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
stream_interval=stream_interval,
|
||||
tracing_enabled=tracing_enabled,
|
||||
)
|
||||
# Convert string-based engine_core_output_type to OutputModality
|
||||
if engine_core_output_type is not None:
|
||||
self.output_modality = OutputModality.from_string(engine_core_output_type)
|
||||
else:
|
||||
self.output_modality = output_modality
|
||||
self.engine_core_output_type = engine_core_output_type
|
||||
self._native_text_metrics_by_request: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -426,21 +585,78 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
engine_core_timestamp: float | None = None,
|
||||
iteration_stats: IterationStats | None = None,
|
||||
) -> OutputProcessorOutput:
|
||||
default_mm_type = _modality_to_type_string(self.output_modality)
|
||||
|
||||
# Separate outputs that upstream can handle (has detokenizer or
|
||||
# pooling) from multimodal-only outputs (no detokenizer, no pooling)
|
||||
# that would trigger upstream's `assert detokenizer is not None`.
|
||||
upstream_outputs: list[EngineCoreOutput] = []
|
||||
mm_only_outputs: list[EngineCoreOutput] = []
|
||||
|
||||
for eco in engine_core_outputs:
|
||||
req_state = self.request_states.get(eco.request_id)
|
||||
if req_state is None:
|
||||
continue
|
||||
|
||||
# Accumulate multimodal tensors regardless of path.
|
||||
if isinstance(req_state, OmniRequestState):
|
||||
mm_output = getattr(eco, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
mm_type = getattr(eco, "output_type", None) or default_mm_type
|
||||
req_state.add_multimodal_tensor(mm_output, mm_type)
|
||||
|
||||
# Route: if no detokenizer and no pooling output, handle locally
|
||||
# to avoid upstream's assert on detokenizer.
|
||||
if req_state.detokenizer is None and eco.pooling_output is None:
|
||||
mm_only_outputs.append(eco)
|
||||
else:
|
||||
upstream_outputs.append(eco)
|
||||
|
||||
# Handle multimodal-only outputs (generation stages) locally.
|
||||
self._process_mm_only_outputs(mm_only_outputs)
|
||||
|
||||
# Delegate text/pooling outputs to upstream.
|
||||
return super().process_outputs(
|
||||
upstream_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
|
||||
def _process_mm_only_outputs(
|
||||
self,
|
||||
engine_core_outputs: list[EngineCoreOutput],
|
||||
) -> None:
|
||||
"""Handle outputs from generation stages that have no detokenizer.
|
||||
|
||||
These cannot go through upstream process_outputs because it asserts
|
||||
detokenizer is not None when pooling_output is None.
|
||||
"""
|
||||
for eco in engine_core_outputs:
|
||||
req_state = self.request_states.get(eco.request_id)
|
||||
if req_state is None or not isinstance(req_state, OmniRequestState):
|
||||
continue
|
||||
if eco.pooling_output is not None and req_state.detokenizer is not None:
|
||||
mm_type = (getattr(eco, "output_type", self.engine_core_output_type) or "").lower()
|
||||
req_state.add_multimodal_tensor(eco.pooling_output, mm_type)
|
||||
# Force text path in base processor for multimodal outputs.
|
||||
eco.pooling_output = None
|
||||
|
||||
return super().process_outputs(
|
||||
engine_core_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
new_token_ids = eco.new_token_ids
|
||||
finish_reason = eco.finish_reason
|
||||
stop_reason = eco.stop_reason
|
||||
kv_transfer_params = eco.kv_transfer_params
|
||||
routed_experts = eco.routed_experts
|
||||
req_state.num_cached_tokens = eco.num_cached_tokens
|
||||
req_state.is_prefilling = False
|
||||
|
||||
if request_output := req_state.make_request_output(
|
||||
new_token_ids,
|
||||
None, # pooling_output
|
||||
finish_reason,
|
||||
stop_reason,
|
||||
kv_transfer_params,
|
||||
routed_experts,
|
||||
):
|
||||
if req_state.queue is not None:
|
||||
req_state.queue.put(request_output)
|
||||
|
||||
if finish_reason is not None:
|
||||
self._finish_request(req_state)
|
||||
|
||||
def _update_stats_from_output(
|
||||
self,
|
||||
|
||||
@@ -18,6 +18,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import psutil
|
||||
import vllm.v1.engine as _vllm_engine_module
|
||||
import vllm.v1.engine.core_client as _vllm_core_client_module
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
from vllm.v1.engine.core_client import AsyncMPClient, DPLBAsyncMPClient
|
||||
@@ -30,6 +32,7 @@ from vllm_omni.distributed.omni_connectors.utils.initialization import (
|
||||
KV_TRANSFER_PORT_OFFSET,
|
||||
)
|
||||
from vllm_omni.distributed.omni_connectors.utils.kv_utils import kv_zmq_port
|
||||
from vllm_omni.engine import OmniEngineCoreOutput, OmniEngineCoreOutputs
|
||||
from vllm_omni.engine.stage_client import StageClientBase
|
||||
from vllm_omni.engine.stage_init_utils import StageMetadata
|
||||
|
||||
@@ -174,6 +177,21 @@ class StageEngineCoreClientBase(StageClientBase):
|
||||
self.stage_id,
|
||||
self.replica_id,
|
||||
)
|
||||
|
||||
# Patch the output decoder type so the client decodes
|
||||
# OmniEngineCoreOutputs (which carries multimodal_output per
|
||||
# EngineCoreOutput) instead of the base EngineCoreOutputs.
|
||||
# Must happen BEFORE super().__init__() which creates the decoder.
|
||||
# TODO: Add a defensive assertion after super().__init__() to verify
|
||||
# the decoder uses OmniEngineCoreOutputs, catching import-order regressions.
|
||||
_vllm_engine_module.EngineCoreOutput = OmniEngineCoreOutput
|
||||
_vllm_engine_module.EngineCoreOutputs = OmniEngineCoreOutputs
|
||||
_vllm_core_client_module.EngineCoreOutputs = OmniEngineCoreOutputs
|
||||
logger.debug(
|
||||
"[StageEngineCoreClient] Patched EngineCoreOutputs -> %s",
|
||||
_vllm_core_client_module.EngineCoreOutputs,
|
||||
)
|
||||
|
||||
try:
|
||||
super().__init__(
|
||||
vllm_config,
|
||||
|
||||
@@ -14,6 +14,7 @@ from multiprocessing.process import BaseProcess
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import msgspec
|
||||
import vllm.v1.engine.core as _vllm_engine_core_module
|
||||
import zmq
|
||||
from vllm.logger import init_logger
|
||||
from vllm.transformers_utils.config import (
|
||||
@@ -36,6 +37,7 @@ from vllm.v1.engine.utils import (
|
||||
from vllm.v1.utils import shutdown
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import OmniCoordClientForStage
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.stage_init_utils import set_death_signal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -122,6 +124,18 @@ class StageEngineCoreProc(EngineCoreProc):
|
||||
os.environ.setdefault("FLASHINFER_DISABLE_VERSION_CHECK", "1")
|
||||
os.environ["VLLM_OMNI_REPLICA_ID"] = str(max(int(omni_replica_id), 0))
|
||||
|
||||
# Patch the decoder type so process_input_sockets (started
|
||||
# during __init__) decodes OmniEngineCoreRequest (which
|
||||
# carries additional_information) instead of the base
|
||||
# EngineCoreRequest. Must happen BEFORE __init__ because
|
||||
# the IO thread creates MsgpackDecoder(EngineCoreRequest)
|
||||
# during __init__.
|
||||
_vllm_engine_core_module.EngineCoreRequest = OmniEngineCoreRequest
|
||||
logger.debug(
|
||||
"[StageEngineCoreProc] Patched EngineCoreRequest -> OmniEngineCoreRequest: %s",
|
||||
_vllm_engine_core_module.EngineCoreRequest,
|
||||
)
|
||||
|
||||
engine_core = StageEngineCoreProc(
|
||||
*args,
|
||||
engine_index=dp_rank,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -82,7 +82,10 @@ class RealtimeConnection(VllmRealtimeConnection):
|
||||
|
||||
def _extract_audio_chunks(self, output) -> tuple[list[np.ndarray], int]:
|
||||
mm = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(mm, dict):
|
||||
if mm is None:
|
||||
return [], 24000
|
||||
# Support both MultimodalPayload and plain dict
|
||||
if not isinstance(mm, Mapping):
|
||||
return [], 24000
|
||||
|
||||
sr = mm.get("sr") or mm.get("sample_rate") or mm.get("audio_sample_rate") or 24000
|
||||
|
||||
@@ -44,6 +44,10 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
|
||||
SpeechBatchItem,
|
||||
SpeechBatchItemResult,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import (
|
||||
SpeechServingContext,
|
||||
resolve_adapter,
|
||||
)
|
||||
from vllm_omni.entrypoints.utils import coerce_param_message_types
|
||||
from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
|
||||
build_fish_text_only_prompt_ids,
|
||||
@@ -80,6 +84,7 @@ _MOSS_TTS_FULL_MODEL_STAGES = {"moss_tts", "moss_tts_codec"}
|
||||
_HIGGS_AUDIO_V2_TTS_MODEL_STAGES = {"higgs_audio_v2"}
|
||||
_HIGGS_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"}
|
||||
_GLM_TTS_MODEL_STAGES = {"glm_tts"}
|
||||
_STEP_AUDIO2_TTS_MODEL_STAGES = {"step_audio2_thinker"}
|
||||
_TTS_MODEL_STAGES: set[str] = (
|
||||
_VOXTRAL_TTS_MODEL_STAGES
|
||||
| _QWEN3_TTS_MODEL_STAGES
|
||||
@@ -94,6 +99,7 @@ _TTS_MODEL_STAGES: set[str] = (
|
||||
| _MOSS_TTS_MODEL_STAGES
|
||||
| _MOSS_TTS_FULL_MODEL_STAGES
|
||||
| _GLM_TTS_MODEL_STAGES
|
||||
| _STEP_AUDIO2_TTS_MODEL_STAGES
|
||||
)
|
||||
_SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = {
|
||||
"fish_tts",
|
||||
@@ -472,6 +478,36 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
self._build_fish_speech_prompt_async = make_async(self._build_fish_speech_prompt, executor=self._tts_executor)
|
||||
self._estimate_prompt_len_async = make_async(self._estimate_prompt_len, executor=self._tts_executor)
|
||||
|
||||
# Resolve the per-model serving adapter (RFC #4327), keyed on the
|
||||
# detected model-type. Every dedicated TTS model has an adapter; the
|
||||
# adapter owns request validation + prompt/param building. Sampling
|
||||
# overrides and the model-type label remain in the orchestrator tail
|
||||
# (keyed on ``_tts_model_type``) during this incremental migration.
|
||||
self._adapter = None
|
||||
if self._tts_stage is not None:
|
||||
adapter_cls = resolve_adapter(self._tts_model_type)
|
||||
if adapter_cls is not None:
|
||||
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
|
||||
self._adapter = adapter_cls(ctx)
|
||||
logger.info("Resolved TTS serving adapter: %s", adapter_cls.__name__)
|
||||
|
||||
def _get_tts_adapter(self):
|
||||
"""Return the per-model serving adapter for the current ``_tts_model_type``.
|
||||
|
||||
Resolved lazily (rebuilt if ``_tts_model_type`` changed since the cached
|
||||
instance was built) so callers that set ``_tts_model_type`` after
|
||||
construction still dispatch to the matching adapter. In production
|
||||
``_tts_model_type`` is fixed at init, so the cached instance is reused.
|
||||
"""
|
||||
adapter_cls = resolve_adapter(self._tts_model_type)
|
||||
if adapter_cls is None:
|
||||
self._adapter = None
|
||||
return None
|
||||
if self._adapter is None or type(self._adapter) is not adapter_cls:
|
||||
ctx = SpeechServingContext(server=self, engine_client=self.engine_client)
|
||||
self._adapter = adapter_cls(ctx)
|
||||
return self._adapter
|
||||
|
||||
async def warmup(self) -> None:
|
||||
"""Run a synthetic speech request to trigger all first-request warmup.
|
||||
|
||||
@@ -655,6 +691,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return "higgs_audio_v3"
|
||||
if model_stage in _GLM_TTS_MODEL_STAGES:
|
||||
return "glm_tts"
|
||||
if model_stage in _STEP_AUDIO2_TTS_MODEL_STAGES:
|
||||
return "step_audio2"
|
||||
return None
|
||||
|
||||
def _get_custom_voice_dir(self) -> str | None:
|
||||
@@ -1356,6 +1394,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
def _validate_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None:
|
||||
"""Validate TTS request parameters. Returns error message or None."""
|
||||
if self._tts_model_type == "step_audio2":
|
||||
# StepAudio2 only requires non-empty input text
|
||||
if not request.input or not request.input.strip():
|
||||
return "Input text cannot be empty"
|
||||
return None
|
||||
if self._tts_model_type == "voxtral_tts":
|
||||
return self._validate_voxtral_tts_request(request)
|
||||
if self._tts_model_type == "fish_tts":
|
||||
@@ -2858,6 +2901,33 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
"multi_modal_data": {"audio": [(audio.audio_array, audio.sampling_rate)]},
|
||||
}
|
||||
|
||||
# ---- Step-Audio2 helpers ----
|
||||
|
||||
def _build_step_audio2_prompt(
|
||||
self,
|
||||
request: OpenAICreateSpeechRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Build prompt for Step-Audio2 TTS.
|
||||
|
||||
Constructs the chat prompt with ``<tts_start>`` as the last token
|
||||
of the assistant turn (without ``<|im_end|>``), so the thinker
|
||||
continues generating audio tokens.
|
||||
|
||||
Prompt format::
|
||||
<|im_start|>system\\n{system_prompt}<|im_end|>\\n
|
||||
<|im_start|>user\\n{input_text}<|im_end|>\\n
|
||||
<|im_start|>assistant\\n<tts_start>
|
||||
"""
|
||||
system_prompt = getattr(request, "instructions", None) or "You are a voice assistant. Read the text aloud."
|
||||
text = request.input
|
||||
|
||||
raw_prompt = (
|
||||
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
|
||||
f"<|im_start|>user\n{text}<|im_end|>\n"
|
||||
f"<|im_start|>assistant\n<tts_start>"
|
||||
)
|
||||
return {"prompt": raw_prompt}
|
||||
|
||||
# ---- Fish Speech helpers ----
|
||||
|
||||
def _build_fish_speech_prompt(
|
||||
@@ -3176,6 +3246,44 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
# ---- Common speech generation helpers ----
|
||||
|
||||
async def _build_qwen3_tts_request(
|
||||
self,
|
||||
request: OpenAICreateSpeechRequest,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], str | None]:
|
||||
"""Build prompt + tts_params for Qwen3-TTS.
|
||||
|
||||
Called from ``Qwen3TTSAdapter.build``. Returns
|
||||
``(prompt, tts_params, warmup_artifact_key)`` where the warmup key is the
|
||||
Qwen3-TTS ref-audio artifact tracked after ``generate()``.
|
||||
"""
|
||||
qwen3_ref_audio_warmup_artifact_key: str | None = None
|
||||
tts_params = self._build_tts_params(request)
|
||||
# Resolve ref_audio (explicit or auto-set for uploaded voices)
|
||||
# to [[wav_list, sr]] so the model doesn't re-decode base64.
|
||||
ref_audio_source = request.ref_audio
|
||||
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
|
||||
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
|
||||
ref_audio_source = tts_params["ref_audio"][0]
|
||||
if ref_audio_source is not None and isinstance(ref_audio_source, str):
|
||||
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
|
||||
artifact_key = self._get_resolved_ref_audio_artifact_key(ref_audio_source)
|
||||
if artifact_key:
|
||||
tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key]
|
||||
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
|
||||
if ref_code_length is not None:
|
||||
tts_params["ref_code_length"] = [int(ref_code_length)]
|
||||
if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key):
|
||||
logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key)
|
||||
else:
|
||||
tts_params["ref_audio"] = [[wav_list, sr]]
|
||||
qwen3_ref_audio_warmup_artifact_key = artifact_key
|
||||
|
||||
ph_len = await self._estimate_prompt_len_async(tts_params)
|
||||
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
return prompt, tts_params, qwen3_ref_audio_warmup_artifact_key
|
||||
|
||||
async def _prepare_speech_generation(
|
||||
self,
|
||||
request: OpenAICreateSpeechRequest,
|
||||
@@ -3194,203 +3302,35 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
sampling_params_list = list(self.engine_client.default_sampling_params_list)
|
||||
sampling_params_list = coerce_param_message_types(sampling_params_list, request.stream)
|
||||
|
||||
# Resolve uploaded voice for non-Qwen3 models.
|
||||
# Qwen3 TTS has its own uploaded voice handling in _build_tts_params().
|
||||
# Build prompt + tts_params via the per-model adapter (RFC #4327). Every
|
||||
# dedicated TTS model resolves to an adapter that owns its validation,
|
||||
# uploaded-speaker handling, and prompt/param building. Sampling
|
||||
# overrides and the model-type label remain in the orchestrator tail
|
||||
# below (keyed on ``_tts_model_type``) during this incremental migration.
|
||||
# Non-TTS deployments (no adapter) fall through to the rejection below.
|
||||
# Capture inline-ref-audio status BEFORE validate(): several adapters
|
||||
# apply uploaded speakers inside validate(), which sets request.ref_audio
|
||||
# in place. The builders need to know whether the caller supplied audio
|
||||
# inline vs. via an uploaded voice.
|
||||
has_inline_ref_audio = request.ref_audio is not None
|
||||
if self._tts_model_type in ("fish_tts", "cosyvoice3", "moss_tts_nano", "glm_tts"):
|
||||
err = self._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
|
||||
if self._is_fish_speech:
|
||||
validation_error = self._validate_fish_tts_request(request)
|
||||
if self._tts_model_type == "ming_flash_omni_tts":
|
||||
# ming_flash_omni is intentionally NOT migrated onto the adapter
|
||||
# framework in this PR (it has no registered adapter); keep it on the
|
||||
# legacy inline dispatch so serving still works.
|
||||
validation_error = self._validate_ming_flash_omni_tts_request(request)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
ref_audio_data = None
|
||||
if request.ref_audio is not None:
|
||||
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
|
||||
ref_audio_data = (wav_list, sr)
|
||||
prompt = await self._build_fish_speech_prompt_async(
|
||||
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
|
||||
)
|
||||
prompt = self._build_ming_flash_omni_prompt(request)
|
||||
tts_params = {}
|
||||
# Fish Speech resolves conditioning entirely from raw request fields
|
||||
# (text / ref_audio / ref_text rebuilt into inputs_embeds); unlike
|
||||
# uploaded-voice models it has no resolved tts_params to fold in, so
|
||||
# the salt is derived from the request alone.
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
elif self._tts_model_type == "omnivoice":
|
||||
if not request.input or not request.input.strip():
|
||||
raise ValueError("Input text cannot be empty")
|
||||
err = self._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
tts_params = {}
|
||||
prompt: dict[str, Any] = {"input": request.input}
|
||||
if request.ref_audio:
|
||||
wav, sr = await self._resolve_ref_audio(request.ref_audio)
|
||||
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
|
||||
if request.ref_text:
|
||||
prompt["ref_text"] = request.ref_text
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
|
||||
prompt["voice_name"] = voice_lower
|
||||
prompt["voice_created_at"] = self._voice_created_at(voice_lower)
|
||||
if request.language:
|
||||
prompt["lang"] = request.language
|
||||
if request.instructions:
|
||||
prompt["instruct"] = request.instructions
|
||||
elif self._tts_model_type == "covo_audio":
|
||||
prompt = self._build_covo_audio_prompt(request)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "higgs_audio_v2":
|
||||
# Explicit higgs_audio_v2 branch: do NOT fall through to the
|
||||
# Qwen-style generic placeholder path; build prompt_token_ids
|
||||
# directly via the upstream processor + the project's plain-text
|
||||
# helper.
|
||||
#
|
||||
# Resolve uploaded voices first so a voice=<name> request (after
|
||||
# POST /v1/audio/voices) populates ref_audio + ref_text from the
|
||||
# cached speaker entry, mirroring the cosyvoice3 / voxcpm2 flow.
|
||||
err = self._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
validation_error = self._validate_higgs_audio_v2_request(request)
|
||||
qwen3_ref_audio_warmup_artifact_key = None
|
||||
elif (adapter := self._get_tts_adapter()) is not None:
|
||||
validation_error = adapter.validate(request)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
prompt = await self._build_higgs_audio_v2_params(request)
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
|
||||
additional = prompt.setdefault("additional_information", {})
|
||||
additional["voice_name"] = voice_lower
|
||||
additional["voice_created_at"] = self._voice_created_at(voice_lower)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "higgs_audio_v3":
|
||||
err = self._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
validation_error = self._validate_higgs_audio_v3_request(request)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
prompt = await self._build_higgs_audio_v3_params(request)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "voxcpm2":
|
||||
# voxcpm2 doesn't use `_apply_uploaded_speaker` because the prompt builder needs the
|
||||
# raw waveform tuple for prefill-length accounting, not a base64 data URL.
|
||||
validation_error = self._validate_voxcpm2_request(request)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
|
||||
uploaded_ref: tuple[np.ndarray, int] | None = None
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
|
||||
if self.uploaded_speakers[voice_lower].get("embedding_source") == "direct":
|
||||
raise ValueError(
|
||||
f"Uploaded voice '{request.voice}' uses a speaker embedding (Qwen3-only). "
|
||||
f"Re-upload with an audio file for VoxCPM2."
|
||||
)
|
||||
if request.ref_audio is None:
|
||||
uploaded_ref = self._load_uploaded_audio(voice_lower)
|
||||
prompt = await self._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref)
|
||||
tts_params = {}
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in self.uploaded_speakers or voice_lower in self.precomputed_speakers:
|
||||
additional = prompt.setdefault("additional_information", {})
|
||||
additional["voice_name"] = voice_lower
|
||||
additional["voice_created_at"] = self._voice_created_at(voice_lower)
|
||||
elif self._is_tts:
|
||||
validation_error = self._validate_tts_request(request)
|
||||
if validation_error:
|
||||
raise ValueError(validation_error)
|
||||
|
||||
if self._tts_model_type == "voxtral_tts":
|
||||
prompt = await self._build_voxtral_prompt_async(request)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "cosyvoice3":
|
||||
prompt = await self._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "glm_tts":
|
||||
prompt = await self._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "ming_flash_omni_tts":
|
||||
prompt = self._build_ming_flash_omni_prompt(request)
|
||||
tts_params = {}
|
||||
elif self._tts_model_type == "ming_tts":
|
||||
ref_audio_source = request.ref_audio
|
||||
voice_lower = request.voice.lower() if isinstance(request.voice, str) else None
|
||||
if ref_audio_source is None and voice_lower in self.uploaded_speakers:
|
||||
ref_audio_source = self._get_uploaded_audio_data(request.voice)
|
||||
if request.ref_text is None:
|
||||
request.ref_text = self.uploaded_speakers[voice_lower].get("ref_text")
|
||||
ref_audio_data = None
|
||||
if isinstance(ref_audio_source, list):
|
||||
ref_audio_data = await self._resolve_ref_audio_many(ref_audio_source)
|
||||
if request.speaker_embedding is None:
|
||||
request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio(ref_audio_data)
|
||||
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
|
||||
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
|
||||
ref_audio_data = (wav_list, sr)
|
||||
if request.speaker_embedding is None:
|
||||
request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio(
|
||||
[ref_audio_data]
|
||||
)[0]
|
||||
prompt = self._build_ming_dense_prompt(request, ref_audio_data=ref_audio_data)
|
||||
tts_params = prompt.get("additional_information", {})
|
||||
elif self._tts_model_type in ("moss_tts_nano", "moss_tts"):
|
||||
tts_params = await self._build_moss_tts_params(request)
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in self.uploaded_speakers and not has_inline_ref_audio:
|
||||
tts_params["voice_name"] = [voice_lower]
|
||||
tts_params["voice_created_at"] = [self._voice_created_at(voice_lower)]
|
||||
# Propagate seed from sampling params for deterministic generation.
|
||||
# MOSS-TTS-Nano uses its own internal sampling inside
|
||||
# inference_stream(), which reads seed from additional_information
|
||||
# (not SamplingParams). Without this the model uses ambient RNG
|
||||
# state and produces non-deterministic output.
|
||||
if sampling_params_list and getattr(sampling_params_list[0], "seed", None) is not None:
|
||||
tts_params["seed"] = [sampling_params_list[0].seed]
|
||||
# Full MOSS-TTS family returns real text_ids from the upstream
|
||||
# processor; use them as prompt_token_ids. Nano keeps the
|
||||
# ``[1]`` placeholder (it owns its own tokenisation internally).
|
||||
if isinstance(tts_params.get("prompt_token_ids"), list):
|
||||
prompt_token_ids = tts_params.pop("prompt_token_ids")
|
||||
prompt = tokens_input(prompt_token_ids=prompt_token_ids)
|
||||
else:
|
||||
prompt = tokens_input(prompt_token_ids=[1])
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
else:
|
||||
tts_params = self._build_tts_params(request)
|
||||
# Resolve ref_audio (explicit or auto-set for uploaded voices)
|
||||
# to [[wav_list, sr]] so the model doesn't re-decode base64.
|
||||
ref_audio_source = request.ref_audio
|
||||
if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list):
|
||||
# Uploaded voice: ref_audio was auto-set as [base64_data_url]
|
||||
ref_audio_source = tts_params["ref_audio"][0]
|
||||
if ref_audio_source is not None and isinstance(ref_audio_source, str):
|
||||
wav_list, sr = await self._resolve_ref_audio(ref_audio_source)
|
||||
artifact_key = self._get_resolved_ref_audio_artifact_key(ref_audio_source)
|
||||
if self._tts_model_type == "qwen3_tts" and artifact_key:
|
||||
tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key]
|
||||
ref_code_length = self._estimate_ref_code_len([wav_list, sr])
|
||||
if self._tts_model_type == "qwen3_tts" and ref_code_length is not None:
|
||||
tts_params["ref_code_length"] = [int(ref_code_length)]
|
||||
if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key):
|
||||
logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key)
|
||||
else:
|
||||
tts_params["ref_audio"] = [[wav_list, sr]]
|
||||
if self._tts_model_type == "qwen3_tts":
|
||||
qwen3_ref_audio_warmup_artifact_key = artifact_key
|
||||
|
||||
ph_len = await self._estimate_prompt_len_async(tts_params)
|
||||
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
prepared = await adapter.build(request, sampling_params_list, has_inline_ref_audio)
|
||||
prompt = prepared.prompt
|
||||
tts_params = prepared.tts_params
|
||||
qwen3_ref_audio_warmup_artifact_key = prepared.warmup_artifact_key
|
||||
else:
|
||||
# Qwen omni models (Qwen3-Omni, Qwen2.5-Omni) use a "talker"
|
||||
# stage whose preprocess requires chat-templated tokens. The
|
||||
@@ -3413,7 +3353,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
tts_params = {}
|
||||
prompt = {"prompt": request.input}
|
||||
|
||||
if self._is_fish_speech:
|
||||
if self._tts_model_type == "step_audio2":
|
||||
model_type = "step_audio2"
|
||||
elif self._is_fish_speech:
|
||||
model_type = "fish_speech"
|
||||
elif self._tts_model_type == "covo_audio":
|
||||
model_type = "covo_audio"
|
||||
@@ -4004,6 +3946,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
merged_requests = [self._merge_batch_item(batch_request, item) for item in batch_request.items]
|
||||
|
||||
async def _run_item(idx: int, req: OpenAICreateSpeechRequest) -> SpeechBatchItemResult:
|
||||
# Batch validation still goes through _validate_tts_request directly
|
||||
# (not the adapter). The single-request path validates via the
|
||||
# adapter; both ultimately call the same per-model validators, so the
|
||||
# surfaces stay in sync. Routing batch through the adapter (and its
|
||||
# uploaded-speaker handling) is a follow-up (RFC #4327).
|
||||
validation_error = self._validate_tts_request(req)
|
||||
if validation_error is not None:
|
||||
return SpeechBatchItemResult(index=idx, status="error", error=validation_error)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import Any, cast
|
||||
@@ -620,7 +621,7 @@ class OmniOpenAIServingVideo:
|
||||
def _resolve_fps(result: Any) -> int | None:
|
||||
"""Extract fps from multimodal_output if the model reported it."""
|
||||
multimodal_output = getattr(result, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
fps = multimodal_output.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -633,7 +634,7 @@ class OmniOpenAIServingVideo:
|
||||
request_output = getattr(result, "request_output", None)
|
||||
if isinstance(request_output, dict):
|
||||
mm = request_output.get("multimodal_output") or {}
|
||||
if isinstance(mm, dict):
|
||||
if isinstance(mm, Mapping):
|
||||
fps = mm.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -644,7 +645,7 @@ class OmniOpenAIServingVideo:
|
||||
pass
|
||||
elif hasattr(request_output, "multimodal_output"):
|
||||
mm = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(mm, dict):
|
||||
if isinstance(mm, Mapping):
|
||||
fps = mm.get("fps")
|
||||
if fps is not None:
|
||||
try:
|
||||
@@ -659,7 +660,7 @@ class OmniOpenAIServingVideo:
|
||||
@classmethod
|
||||
def _extract_audio_sample_rate_from_result(cls, result: Any) -> int | None:
|
||||
multimodal_output = getattr(result, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
sample_rate = cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
@@ -672,7 +673,7 @@ class OmniOpenAIServingVideo:
|
||||
request_output = getattr(result, "request_output", None)
|
||||
if isinstance(request_output, dict):
|
||||
multimodal_output = request_output.get("multimodal_output") or {}
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
@@ -681,7 +682,7 @@ class OmniOpenAIServingVideo:
|
||||
)
|
||||
elif hasattr(request_output, "multimodal_output"):
|
||||
multimodal_output = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
return cls._coerce_audio_sample_rate(
|
||||
multimodal_output.get("audio_sample_rate")
|
||||
or multimodal_output.get("sample_rate")
|
||||
|
||||
@@ -32,6 +32,7 @@ import json
|
||||
import time as _time
|
||||
import uuid
|
||||
import wave
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -796,7 +797,7 @@ class OmniStreamingVideoHandler:
|
||||
if not isinstance(outputs, list) or not outputs:
|
||||
return None
|
||||
mm_output = getattr(outputs[0], "multimodal_output", None)
|
||||
if not isinstance(mm_output, dict):
|
||||
if not isinstance(mm_output, Mapping):
|
||||
return None
|
||||
return mm_output.get("audio")
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Registry of TTS serving adapters.
|
||||
|
||||
Adapters register themselves by their ``name`` (the model-type discriminator
|
||||
returned by ``OmniOpenAIServingSpeech._detect_tts_model_type``) via
|
||||
``@register_tts_adapter``. Resolution is by that name: detection already encodes
|
||||
the (order-sensitive, partly ``model_arch``-based) stage logic, so keying the
|
||||
registry on the resolved type is both simpler and correct by construction.
|
||||
``stage_keys`` is retained on each adapter as documentation metadata.
|
||||
"""
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import (
|
||||
ARTTSAdapter,
|
||||
DiffusionTTSAdapter,
|
||||
OutputPolicy,
|
||||
PreparedRequest,
|
||||
SpeechServingContext,
|
||||
TTSModelAdapter,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
TTS_ADAPTER_REGISTRY: dict[str, type[TTSModelAdapter]] = {}
|
||||
|
||||
|
||||
def register_tts_adapter(cls: type[TTSModelAdapter]) -> type[TTSModelAdapter]:
|
||||
"""Class decorator: index ``cls`` under its ``name`` (model-type)."""
|
||||
if cls.name in TTS_ADAPTER_REGISTRY:
|
||||
raise ValueError(
|
||||
f"TTS adapter name {cls.name!r} already registered to "
|
||||
f"{TTS_ADAPTER_REGISTRY[cls.name].__qualname__}; {cls.__qualname__} conflicts."
|
||||
)
|
||||
TTS_ADAPTER_REGISTRY[cls.name] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def all_tts_model_types() -> frozenset[str]:
|
||||
"""All registered model-type names."""
|
||||
return frozenset(TTS_ADAPTER_REGISTRY)
|
||||
|
||||
|
||||
def resolve_adapter(model_type: str | None) -> type[TTSModelAdapter] | None:
|
||||
"""Return the adapter for a detected model-type, or ``None``."""
|
||||
if model_type is None:
|
||||
return None
|
||||
return TTS_ADAPTER_REGISTRY.get(model_type)
|
||||
|
||||
|
||||
# Import adapter modules for their registration side effects. Keep at the bottom
|
||||
# so the registry helpers above are defined first.
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
|
||||
cosyvoice3,
|
||||
covo_audio,
|
||||
fish_speech,
|
||||
glm_tts,
|
||||
higgs_audio_v2,
|
||||
higgs_audio_v3,
|
||||
ming_tts,
|
||||
moss_tts,
|
||||
omnivoice,
|
||||
qwen3_tts,
|
||||
step_audio2,
|
||||
voxcpm2,
|
||||
voxtral,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ARTTSAdapter",
|
||||
"DiffusionTTSAdapter",
|
||||
"OutputPolicy",
|
||||
"PreparedRequest",
|
||||
"SpeechServingContext",
|
||||
"TTSModelAdapter",
|
||||
"TTS_ADAPTER_REGISTRY",
|
||||
"all_tts_model_types",
|
||||
"register_tts_adapter",
|
||||
"resolve_adapter",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Base contract for per-model TTS serving adapters.
|
||||
|
||||
This package factors the per-model ``if self._tts_model_type == ...`` dispatch
|
||||
in ``serving_speech.py`` into one adapter class per model. Each adapter owns its
|
||||
model's request normalization, validation, prompt/param building, sampling
|
||||
overrides, and output policy, so adding a model means writing one adapter file
|
||||
instead of editing the shared serving module in ~10 scattered places.
|
||||
|
||||
See the RFC for the full design (issue #4327). This is the foundation landed in
|
||||
the first migration PR; Qwen3-TTS is the first model routed through it while the
|
||||
remaining models stay on the legacy path until individually migrated.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
_conditioning_cache_salt_fn: "Callable[..., str] | None" = None
|
||||
|
||||
|
||||
def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict) -> str:
|
||||
"""Return the conditioning cache salt for ``request`` + ``tts_params``.
|
||||
|
||||
Lazily imports and caches ``serving_speech._conditioning_cache_salt`` on first
|
||||
use: the import is deferred to break the adapters<->serving_speech import
|
||||
cycle, and cached so it resolves once instead of on every ``build()`` call.
|
||||
"""
|
||||
global _conditioning_cache_salt_fn
|
||||
if _conditioning_cache_salt_fn is None:
|
||||
from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt
|
||||
|
||||
_conditioning_cache_salt_fn = _conditioning_cache_salt
|
||||
return _conditioning_cache_salt_fn(request, tts_params)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputPolicy:
|
||||
"""How the orchestrator aggregates engine output for a model.
|
||||
|
||||
``accumulate_nonstreaming`` enables MOSS-style cross-step accumulation in the
|
||||
non-streaming path. Streaming cumulative/delta semantics stay engine-side,
|
||||
keyed by request id, and are unaffected by this flag.
|
||||
"""
|
||||
|
||||
accumulate_nonstreaming: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedRequest:
|
||||
"""Everything the generic orchestrator needs to call ``<engine>.generate()``.
|
||||
|
||||
The fields mirror what ``_prepare_speech_generation`` assembled inline:
|
||||
``prompt`` is the engine prompt dict, ``tts_params`` the per-model parameter
|
||||
dict, ``model_type`` the discriminator used for logging, and
|
||||
``output_policy`` controls non-streaming aggregation.
|
||||
"""
|
||||
|
||||
prompt: dict[str, Any]
|
||||
tts_params: dict[str, Any] = field(default_factory=dict)
|
||||
model_type: str = "generic"
|
||||
output_policy: OutputPolicy = field(default_factory=OutputPolicy)
|
||||
#: Cross-cutting per-request state the orchestrator still owns (e.g. the
|
||||
#: Qwen3-TTS ref-audio warmup artifact key tracked after ``generate()``).
|
||||
warmup_artifact_key: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeechServingContext:
|
||||
"""Shared state and helpers an adapter may use.
|
||||
|
||||
During the incremental migration this holds a back-reference to the owning
|
||||
``OmniOpenAIServingSpeech`` instance (``server``) so adapters can reuse the
|
||||
existing, battle-tested helper implementations (ref-audio resolution,
|
||||
uploaded-speaker handling, prompt-length estimation, speaker storage)
|
||||
without relocating them. Follow-up PRs may narrow this to explicit fields as
|
||||
more models migrate. Exactly one of ``engine_client`` / ``diffusion_engine``
|
||||
is set, matching the deployment's serving backend.
|
||||
"""
|
||||
|
||||
server: Any
|
||||
engine_client: Any | None = None
|
||||
diffusion_engine: Any | None = None
|
||||
|
||||
|
||||
class TTSModelAdapter(ABC):
|
||||
"""Mandatory base class for a TTS model served via ``/v1/audio/speech``.
|
||||
|
||||
One concrete subclass per model, registered by stage key. The serving layer
|
||||
resolves exactly one adapter at startup and dispatches all per-model
|
||||
decisions to it. Adapters reuse shared helpers through ``ctx`` rather than
|
||||
re-implementing them.
|
||||
"""
|
||||
|
||||
#: Stable discriminator string (the model-type from detection); registry key.
|
||||
name: ClassVar[str]
|
||||
#: Engine ``model_stage`` key(s) this model uses, for documentation only.
|
||||
stage_keys: ClassVar[frozenset[str]] = frozenset()
|
||||
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
|
||||
backend: ClassVar[str] = "ar"
|
||||
|
||||
def __init__(self, ctx: SpeechServingContext) -> None:
|
||||
self.ctx = ctx
|
||||
|
||||
def normalize(self, request: "OpenAICreateSpeechRequest") -> None:
|
||||
"""In-place request normalization/mutation (e.g. infer task type,
|
||||
lowercase voice). Default: no-op."""
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
"""Return an error string, or ``None`` if the request is valid.
|
||||
|
||||
Should be free of new side effects beyond what ``normalize`` did.
|
||||
"""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def build(
|
||||
self,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
sampling_params_list: list,
|
||||
has_inline_ref_audio: bool,
|
||||
) -> PreparedRequest:
|
||||
"""Build the engine prompt + tts_params for this request.
|
||||
|
||||
``sampling_params_list`` is passed read-only for models (e.g. MOSS) that
|
||||
fold the resolved seed into ``additional_information`` at build time.
|
||||
|
||||
``has_inline_ref_audio`` is captured by the orchestrator *before*
|
||||
``validate()`` runs, because ``_apply_uploaded_speaker`` (invoked inside
|
||||
several adapters' ``validate``) sets ``request.ref_audio`` in place.
|
||||
Recomputing it here would misclassify uploaded voices as inline and drop
|
||||
the ``voice_name`` / ``voice_created_at`` metadata.
|
||||
"""
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
) -> list:
|
||||
"""Apply model-specific sampling mutations.
|
||||
|
||||
The orchestrator guarantees the call order:
|
||||
stream-coercion -> extra_params -> THIS -> seed. Default: identity.
|
||||
"""
|
||||
return sampling_params_list
|
||||
|
||||
|
||||
class ARTTSAdapter(TTSModelAdapter):
|
||||
"""Adapter for models served through the AR ``engine_client`` path.
|
||||
|
||||
Covers pure-AR codec models as well as AR-base-LM + diffusion-side-computation
|
||||
hybrids (e.g. VoxCPM2, Ming) whose internal diffusion is invisible to the
|
||||
serving layer.
|
||||
"""
|
||||
|
||||
backend: ClassVar[str] = "ar"
|
||||
|
||||
|
||||
class DiffusionTTSAdapter(TTSModelAdapter):
|
||||
"""Adapter for pure-diffusion pipelines served through the diffusion engine.
|
||||
|
||||
In scope today: OmniVoice. Bridges to the diffusion pipeline parameter
|
||||
contract (#3572) when present; see ``extra_body_params``.
|
||||
"""
|
||||
|
||||
backend: ClassVar[str] = "diffusion"
|
||||
|
||||
#: Backing diffusion pipeline class (for EXTRA_BODY_PARAMS lookup).
|
||||
pipeline_cls: ClassVar[type | None] = None
|
||||
|
||||
@classmethod
|
||||
def extra_body_params(cls) -> frozenset[str]:
|
||||
"""Fallback-safe access to the pipeline's declared body params.
|
||||
|
||||
Returns the pipeline's ``EXTRA_BODY_PARAMS`` if the #3572 contract is
|
||||
present, else an empty frozenset (the adapter then uses its own inline
|
||||
parameter logic).
|
||||
"""
|
||||
params = getattr(cls.pipeline_cls, "EXTRA_BODY_PARAMS", None)
|
||||
return frozenset(params) if params is not None else frozenset()
|
||||
|
||||
|
||||
# Re-exported here to avoid import cycles at call sites.
|
||||
__all__ = [
|
||||
"ARTTSAdapter",
|
||||
"DiffusionTTSAdapter",
|
||||
"OutputPolicy",
|
||||
"PreparedRequest",
|
||||
"SpeechServingContext",
|
||||
"TTSModelAdapter",
|
||||
]
|
||||
|
||||
# Type alias kept for readability in adapter signatures.
|
||||
NormalizeFn = Callable[["OpenAICreateSpeechRequest"], None]
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CosyVoice3 TTS serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class CosyVoice3Adapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"cosyvoice3_talker"})
|
||||
name = "cosyvoice3"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_cosyvoice3_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = await self.ctx.server._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
|
||||
# NOTE: CosyVoice3 dynamic-token sampling stays in the orchestrator tail
|
||||
# (keyed on _tts_model_type) during this incremental migration.
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="cosyvoice3")
|
||||
@@ -0,0 +1,22 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CoVo-Audio serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class CovoAudioAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"fused_thinker_talker"})
|
||||
name = "covo_audio"
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = self.ctx.server._build_covo_audio_prompt(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="covo_audio")
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fish Speech serving adapter (retires the legacy ``_is_fish_speech`` flag)."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, conditioning_cache_salt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class FishSpeechAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"fish_speech_slow_ar"})
|
||||
name = "fish_tts"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_fish_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
ref_audio_data = None
|
||||
if request.ref_audio is not None:
|
||||
wav_list, sr = await server._resolve_ref_audio(request.ref_audio)
|
||||
ref_audio_data = (wav_list, sr)
|
||||
prompt = await server._build_fish_speech_prompt_async(
|
||||
request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio
|
||||
)
|
||||
tts_params = {}
|
||||
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="fish_speech")
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""GLM-TTS serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class GlmTTSAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"glm_tts"})
|
||||
name = "glm_tts"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_glm_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = await self.ctx.server._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio)
|
||||
# GLM-TTS dynamic-token sampling stays in the orchestrator tail
|
||||
# (keyed on _tts_model_type) during this incremental migration.
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="glm_tts")
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Higgs-Audio v2 serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class HiggsAudioV2Adapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"higgs_audio_v2"})
|
||||
name = "higgs_audio_v2"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_higgs_audio_v2_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
prompt = await server._build_higgs_audio_v2_params(request)
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
additional = prompt.setdefault("additional_information", {})
|
||||
additional["voice_name"] = voice_lower
|
||||
additional["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v2")
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Higgs-Audio v3 serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class HiggsAudioV3Adapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"higgs_audio_v3"})
|
||||
name = "higgs_audio_v3"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_higgs_audio_v3_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = await self.ctx.server._build_higgs_audio_v3_params(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v3")
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Ming-TTS (dense) serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class MingTTSAdapter(ARTTSAdapter):
|
||||
# Detected by model_arch (MingTTSForConditionalGeneration), not stage key.
|
||||
name = "ming_tts"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
return self.ctx.server._validate_ming_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
ref_audio_source = request.ref_audio
|
||||
voice_lower = request.voice.lower() if isinstance(request.voice, str) else None
|
||||
if ref_audio_source is None and voice_lower in server.uploaded_speakers:
|
||||
ref_audio_source = server._get_uploaded_audio_data(request.voice)
|
||||
if request.ref_text is None:
|
||||
request.ref_text = server.uploaded_speakers[voice_lower].get("ref_text")
|
||||
ref_audio_data = None
|
||||
if isinstance(ref_audio_source, list):
|
||||
ref_audio_data = await server._resolve_ref_audio_many(ref_audio_source)
|
||||
if request.speaker_embedding is None:
|
||||
request.speaker_embedding = server._extract_ming_speaker_embeddings_from_ref_audio(ref_audio_data)
|
||||
elif ref_audio_source is not None and isinstance(ref_audio_source, str):
|
||||
wav_list, sr = await server._resolve_ref_audio(ref_audio_source)
|
||||
ref_audio_data = (wav_list, sr)
|
||||
if request.speaker_embedding is None:
|
||||
request.speaker_embedding = server._extract_ming_speaker_embeddings_from_ref_audio([ref_audio_data])[0]
|
||||
prompt = server._build_ming_dense_prompt(request, ref_audio_data=ref_audio_data)
|
||||
tts_params = prompt.get("additional_information", {})
|
||||
# Ming stop-token / max_tokens sampling stays in the orchestrator tail.
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="ming_tts")
|
||||
@@ -0,0 +1,58 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MOSS-TTS serving adapters (Nano + full family).
|
||||
|
||||
Both variants share the same build/validate flow (``_build_moss_tts_params``
|
||||
handles each); they are registered under distinct model-type names.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm.inputs import tokens_input
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, conditioning_cache_salt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
class _MossTTSAdapterBase(ARTTSAdapter):
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
err = self.ctx.server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
return self.ctx.server._validate_moss_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
tts_params = await server._build_moss_tts_params(request)
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
tts_params["voice_name"] = [voice_lower]
|
||||
tts_params["voice_created_at"] = [server._voice_created_at(voice_lower)]
|
||||
# MOSS reads the resolved seed at build time (it samples internally).
|
||||
if sampling_params_list and getattr(sampling_params_list[0], "seed", None) is not None:
|
||||
tts_params["seed"] = [sampling_params_list[0].seed]
|
||||
if isinstance(tts_params.get("prompt_token_ids"), list):
|
||||
prompt_token_ids = tts_params.pop("prompt_token_ids")
|
||||
prompt = tokens_input(prompt_token_ids=prompt_token_ids)
|
||||
else:
|
||||
prompt = tokens_input(prompt_token_ids=[1])
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type=self.name)
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class MossTTSNanoAdapter(_MossTTSAdapterBase):
|
||||
stage_keys = frozenset({"moss_tts_nano"})
|
||||
name = "moss_tts_nano"
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class MossTTSAdapter(_MossTTSAdapterBase):
|
||||
stage_keys = frozenset({"moss_tts", "moss_tts_codec"})
|
||||
name = "moss_tts"
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""OmniVoice serving adapter (AR engine_client path).
|
||||
|
||||
OmniVoice can also be served through the pure-diffusion engine via
|
||||
``for_diffusion`` (``_create_diffusion_speech``); unifying that path is a
|
||||
follow-up. This adapter covers the AR-stage deployment.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class OmniVoiceAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"omnivoice_generator"})
|
||||
name = "omnivoice"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
if not request.input or not request.input.strip():
|
||||
return "Input text cannot be empty"
|
||||
return self.ctx.server._apply_uploaded_speaker(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
prompt: dict = {"input": request.input}
|
||||
if request.ref_audio:
|
||||
wav, sr = await server._resolve_ref_audio(request.ref_audio)
|
||||
prompt["ref_audio"] = (np.asarray(wav, dtype=np.float32), sr)
|
||||
if request.ref_text:
|
||||
prompt["ref_text"] = request.ref_text
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
prompt["voice_name"] = voice_lower
|
||||
prompt["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
if request.language:
|
||||
prompt["lang"] = request.language
|
||||
if request.instructions:
|
||||
prompt["instruct"] = request.instructions
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="omnivoice")
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3-TTS serving adapter.
|
||||
|
||||
First model migrated onto the adapter framework (RFC #4327). It is the dispatch
|
||||
surface for Qwen3-TTS: the serving orchestrator routes prompt/param building
|
||||
through ``build()`` instead of the inline ``else`` branch in
|
||||
``_prepare_speech_generation``.
|
||||
|
||||
To stay behaviour-identical and avoid divergence from the batch path (which
|
||||
shares ``_validate_qwen_tts_request`` and ``_build_tts_params``), the adapter
|
||||
reuses the single-source helper implementations on the serving instance through
|
||||
``ctx.server`` rather than copying them. Subsequent PRs relocate those bodies as
|
||||
more models migrate and the batch path also routes through adapters.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
"""Adapter for Qwen3-TTS (AR ``engine_client`` backend)."""
|
||||
|
||||
stage_keys = frozenset({"qwen3_tts"})
|
||||
name = "qwen3_tts"
|
||||
|
||||
def normalize(self, request: "OpenAICreateSpeechRequest") -> None:
|
||||
"""Qwen3-TTS normalization (Base-task inference, voice lowercasing) is
|
||||
performed inside ``validate`` today; kept fused for a strict behaviour
|
||||
match. See ``_validate_qwen_tts_request``."""
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
# Go through the shared dispatcher (which routes to
|
||||
# _validate_qwen_tts_request for this model-type) rather than the leaf
|
||||
# validator directly, matching the pre-adapter qwen3 path.
|
||||
return self.ctx.server._validate_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt, tts_params, warmup_key = await self.ctx.server._build_qwen3_tts_request(request)
|
||||
return PreparedRequest(
|
||||
prompt=prompt,
|
||||
tts_params=tts_params,
|
||||
model_type=tts_params.get("task_type", ["unknown"])[0],
|
||||
warmup_artifact_key=warmup_key,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Step-Audio2 serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class StepAudio2Adapter(ARTTSAdapter):
|
||||
"""Adapter for Step-Audio2 (AR ``engine_client`` backend).
|
||||
|
||||
Step-Audio2 runs a single thinker stage that emits audio tokens after a
|
||||
``<tts_start>`` marker; prompt building is a synchronous chat-template
|
||||
construction with no uploaded-voice / ref-audio handling.
|
||||
"""
|
||||
|
||||
stage_keys = frozenset({"step_audio2_thinker"})
|
||||
name = "step_audio2"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
# Dispatcher routes to the step_audio2 case (non-empty input check).
|
||||
return self.ctx.server._validate_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = self.ctx.server._build_step_audio2_prompt(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="step_audio2")
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""VoxCPM2 serving adapter (AR base-LM + diffusion side-computation)."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class VoxCPM2Adapter(ARTTSAdapter):
|
||||
"""VoxCPM2 shares ``latent_generator`` with VoxCPM; selected when no ``vae``
|
||||
stage is present (and/or via ``model_arch``)."""
|
||||
|
||||
stage_keys = frozenset({"latent_generator"})
|
||||
name = "voxcpm2"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
return self.ctx.server._validate_voxcpm2_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
server = self.ctx.server
|
||||
# VoxCPM2 needs the raw waveform tuple for prefill-length accounting, so
|
||||
# it loads uploaded audio directly rather than via _apply_uploaded_speaker.
|
||||
uploaded_ref = None
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
if server.uploaded_speakers[voice_lower].get("embedding_source") == "direct":
|
||||
raise ValueError(
|
||||
f"Uploaded voice '{request.voice}' uses a speaker embedding (Qwen3-only). "
|
||||
f"Re-upload with an audio file for VoxCPM2."
|
||||
)
|
||||
if request.ref_audio is None:
|
||||
uploaded_ref = server._load_uploaded_audio(voice_lower)
|
||||
prompt = await server._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref)
|
||||
tts_params = {}
|
||||
if request.voice:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers or voice_lower in server.precomputed_speakers:
|
||||
additional = prompt.setdefault("additional_information", {})
|
||||
additional["voice_name"] = voice_lower
|
||||
additional["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="voxcpm2")
|
||||
@@ -0,0 +1,25 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Voxtral TTS serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class VoxtralTTSAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"audio_generation"})
|
||||
name = "voxtral_tts"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
return self.ctx.server._validate_voxtral_tts_request(request)
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = await self.ctx.server._build_voxtral_prompt_async(request)
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="voxtral_tts")
|
||||
@@ -21,6 +21,7 @@ to avoid OOM from vLLM's post-init KV-cache profiling. Mirrors qwen3_tts
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
@@ -38,6 +39,40 @@ from vllm_omni.model_executor.models.utils import reinit_rotary_inv_freq
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _hf_load_without_tp_warmup():
|
||||
"""Work around a transformers 5.8.x crash loading some trust_remote_code LMs.
|
||||
|
||||
``caching_allocator_warmup`` does ``len(model._tp_plan)`` when
|
||||
``torch.distributed`` is initialized, but the MOSS-TTS-Nano remote model
|
||||
leaves ``_tp_plan`` as ``None`` (it survives ``PreTrainedModel.__init__``),
|
||||
so the warmup raises ``TypeError: object of type 'NoneType' has no len()``.
|
||||
vLLM initializes ``torch.distributed`` even at TP=1, so the crash fires.
|
||||
|
||||
MOSS-TTS-Nano runs single-GPU (no tensor parallelism), so forcing the
|
||||
warmup down its non-distributed branch is byte-for-byte equivalent and
|
||||
sidesteps the ``None`` plan. Scoped to the wrapped ``from_pretrained`` call.
|
||||
|
||||
Targets the private ``transformers.modeling_utils._is_torch_distributed_initialized``.
|
||||
Guarded by ``hasattr`` so that if a future transformers renames or removes it
|
||||
(and presumably the crash with it), we no-op instead of raising AttributeError.
|
||||
"""
|
||||
import transformers.modeling_utils as _tmu
|
||||
|
||||
if not hasattr(_tmu, "_is_torch_distributed_initialized"):
|
||||
# Internal hook gone (transformers refactor); assume the upstream crash
|
||||
# is gone too and run the load unpatched.
|
||||
yield
|
||||
return
|
||||
|
||||
original = _tmu._is_torch_distributed_initialized
|
||||
_tmu._is_torch_distributed_initialized = lambda: False
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tmu._is_torch_distributed_initialized = original
|
||||
|
||||
|
||||
def _patch_torchaudio_load() -> None:
|
||||
"""Patch torchaudio.load to use soundfile if torchcodec is unavailable."""
|
||||
try:
|
||||
@@ -158,11 +193,12 @@ class MossTTSNanoForGeneration(nn.Module):
|
||||
logger.info("Loading MOSS-TTS-Nano LM from %s (dtype=%s)", self.model_path, tts_dtype)
|
||||
from transformers import AutoModelForCausalLM
|
||||
|
||||
lm = AutoModelForCausalLM.from_pretrained(
|
||||
self.model_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=tts_dtype,
|
||||
)
|
||||
with _hf_load_without_tp_warmup():
|
||||
lm = AutoModelForCausalLM.from_pretrained(
|
||||
self.model_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=tts_dtype,
|
||||
)
|
||||
if device.type == "cuda":
|
||||
try:
|
||||
import flash_attn # noqa: F401
|
||||
@@ -197,11 +233,12 @@ class MossTTSNanoForGeneration(nn.Module):
|
||||
logger.info("Loading MOSS-Audio-Tokenizer-Nano from %s", codec_path)
|
||||
from transformers import AutoModel
|
||||
|
||||
audio_tokenizer = AutoModel.from_pretrained(
|
||||
codec_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=torch.float32,
|
||||
)
|
||||
with _hf_load_without_tp_warmup():
|
||||
audio_tokenizer = AutoModel.from_pretrained(
|
||||
codec_path,
|
||||
trust_remote_code=True,
|
||||
torch_dtype=torch.float32,
|
||||
)
|
||||
audio_tokenizer.to(device=device)
|
||||
audio_tokenizer.eval()
|
||||
self._audio_tokenizer: nn.Module = audio_tokenizer
|
||||
|
||||
@@ -329,6 +329,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
self._tts_pad_embed_buffer = tts_pad_embed
|
||||
self._encode_ref_audio_batch_fn = encode_ref_audio_batch
|
||||
self._speaker_cache = speaker_cache
|
||||
self._embedding_dtype = torch.bfloat16
|
||||
|
||||
self._text_tokenizer: Any | None = None
|
||||
|
||||
@@ -598,7 +599,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
if isinstance(ref_code, torch.Tensor):
|
||||
entry["ref_code"] = ref_code.detach().to("cpu", dtype=torch.long).contiguous()
|
||||
if isinstance(ref_spk_embedding, torch.Tensor):
|
||||
entry["ref_spk_embedding"] = ref_spk_embedding.detach().to("cpu", dtype=torch.bfloat16).reshape(-1)
|
||||
entry["ref_spk_embedding"] = ref_spk_embedding.detach().to("cpu", dtype=self._embedding_dtype).reshape(-1)
|
||||
if not entry:
|
||||
return
|
||||
self._ref_audio_artifact_cache[cache_key] = entry
|
||||
@@ -613,10 +614,11 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
# CUDA. Ensure the speaker encoder is on the same device/dtype as the
|
||||
# main model before running it.
|
||||
dev = self._device()
|
||||
dtype = self._embedding_dtype
|
||||
try:
|
||||
spk_param = next(self._speaker_encoder.parameters())
|
||||
if spk_param.device != dev or spk_param.dtype != torch.bfloat16:
|
||||
self._speaker_encoder.to(device=dev, dtype=torch.bfloat16)
|
||||
if spk_param.device != dev or spk_param.dtype != dtype:
|
||||
self._speaker_encoder.to(device=dev, dtype=dtype)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
@@ -641,8 +643,8 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
fmin=0,
|
||||
fmax=12000,
|
||||
).transpose(1, 2)
|
||||
spk = self._speaker_encoder(mels.to(dtype=torch.bfloat16))[0]
|
||||
return spk.to(dtype=torch.bfloat16)
|
||||
spk = self._speaker_encoder(mels.to(dtype=dtype))[0]
|
||||
return spk.to(dtype=dtype)
|
||||
|
||||
def encode_ref_audio_batch(
|
||||
self,
|
||||
@@ -1187,17 +1189,18 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
# NOTE: Do NOT use _as_singleton here — the embedding may be a plain
|
||||
# float list (from API via msgspec IPC) that _as_singleton would
|
||||
# destructively unwrap to a single scalar.
|
||||
speaker_dtype = self._embedding_dtype
|
||||
spk = None
|
||||
if voice_clone_prompt is not None:
|
||||
spk = voice_clone_prompt.get("ref_spk_embedding")
|
||||
if isinstance(spk, torch.Tensor):
|
||||
speaker_embed = spk.to(device=input_ids.device, dtype=torch.bfloat16).view(1, 1, -1)
|
||||
speaker_embed = spk.to(device=input_ids.device, dtype=speaker_dtype).view(1, 1, -1)
|
||||
elif isinstance(spk, (list, np.ndarray)):
|
||||
speaker_embed = torch.tensor(spk, dtype=torch.bfloat16, device=input_ids.device).view(1, 1, -1)
|
||||
speaker_embed = torch.tensor(spk, dtype=speaker_dtype, device=input_ids.device).view(1, 1, -1)
|
||||
elif cached_artifacts is not None and isinstance(cached_artifacts.get("ref_spk_embedding"), torch.Tensor):
|
||||
speaker_embed = (
|
||||
cached_artifacts["ref_spk_embedding"]
|
||||
.to(device=input_ids.device, dtype=torch.bfloat16)
|
||||
.to(device=input_ids.device, dtype=speaker_dtype)
|
||||
.view(1, 1, -1)
|
||||
)
|
||||
elif artifact_only:
|
||||
|
||||
@@ -400,6 +400,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
torch.zeros(1, int(self.talker_config.hidden_size), dtype=model_dtype),
|
||||
persistent=False,
|
||||
)
|
||||
self._embedding_dtype = torch.bfloat16
|
||||
|
||||
tokenizer_config = Qwen3TTSTokenizerV2Config.from_pretrained(
|
||||
self.model_path,
|
||||
@@ -537,10 +538,12 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
logger.warning_once("runtime_additional_information is deprecated, use model_intermediate_buffer")
|
||||
audio_codes_list: list[torch.Tensor] = []
|
||||
ref_code_len_list: list[torch.Tensor] = []
|
||||
ref_code_tensor: torch.Tensor | None = None
|
||||
ref_code_list: list[torch.Tensor] = []
|
||||
has_ref_code = False
|
||||
codec_streaming_list: list[torch.Tensor] = []
|
||||
for info in info_dicts:
|
||||
if not isinstance(info, dict):
|
||||
ref_code_list.append(torch.empty(0, dtype=torch.long))
|
||||
continue
|
||||
codes = info.get("codes", {})
|
||||
meta = info.get("meta", {})
|
||||
@@ -554,7 +557,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
)
|
||||
ref_code = codes.get("ref")
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0:
|
||||
ref_code_tensor = ref_code
|
||||
ref_code_list.append(ref_code)
|
||||
has_ref_code = True
|
||||
else:
|
||||
ref_code_list.append(torch.empty(0, dtype=torch.long))
|
||||
ref_len = meta.get("ref_code_len")
|
||||
if ref_len is None:
|
||||
continue
|
||||
@@ -584,8 +590,11 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
mm: OmniPayload = {"codes": {"audio": audio_codes}}
|
||||
if ref_code_len_list:
|
||||
mm.setdefault("meta", {})["ref_code_len"] = torch.cat(ref_code_len_list, dim=0)[:span_len]
|
||||
if ref_code_tensor is not None:
|
||||
mm.setdefault("codes", {})["ref"] = [ref_code_tensor]
|
||||
if has_ref_code:
|
||||
# Batch-aligned, one entry per request: ``to_payload_element``
|
||||
# indexes ``element[idx]`` per request, and a shorter list would
|
||||
# silently broadcast one request's reference codes to the others.
|
||||
mm.setdefault("codes", {})["ref"] = ref_code_list
|
||||
if codec_streaming_list:
|
||||
mm.setdefault("meta", {})["codec_streaming"] = torch.cat(codec_streaming_list, dim=0)[:span_len]
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs=mm)
|
||||
@@ -642,7 +651,8 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# :meth:`_init_runtime_buffers`. Materialize once on the right
|
||||
# device/dtype and reuse for both the prefill placeholder padding
|
||||
# and the decode text-step fallback below.
|
||||
tts_pad_embed = self._tts_pad_embed.to(device=input_ids.device, dtype=torch.bfloat16).reshape(1, -1)
|
||||
dtype = self._embedding_dtype
|
||||
tts_pad_embed = self._tts_pad_embed.to(device=input_ids.device, dtype=dtype).reshape(1, -1)
|
||||
|
||||
if is_prefill:
|
||||
# Prefill (prompt embeddings)
|
||||
@@ -689,7 +699,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
pad_n = int(span_len - int(take.shape[0]))
|
||||
pad_rows = tts_pad_embed.reshape(1, -1).to("cpu").expand(pad_n, -1)
|
||||
take = torch.cat([take, pad_rows], dim=0)
|
||||
prompt_embeds = take.to(device=input_ids.device, dtype=torch.bfloat16)
|
||||
prompt_embeds = take.to(device=input_ids.device, dtype=dtype)
|
||||
info_update["meta"]["talker_prefill_offset"] = int(offset + span_len)
|
||||
|
||||
# When inputs_embeds is set, token ids are ignored by the model but must stay in-vocab for vLLM bookkeeping.
|
||||
@@ -719,7 +729,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
tail[text_offset : text_offset + 1]
|
||||
.to(
|
||||
device=input_ids.device,
|
||||
dtype=torch.bfloat16,
|
||||
dtype=dtype,
|
||||
)
|
||||
.reshape(1, -1)
|
||||
)
|
||||
@@ -744,14 +754,14 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
|
||||
last_hidden = hs.get("last")
|
||||
if isinstance(last_hidden, torch.Tensor):
|
||||
past_hidden = last_hidden.to(device=input_ids.device, dtype=torch.bfloat16).reshape(1, -1)
|
||||
past_hidden = last_hidden.to(device=input_ids.device, dtype=dtype).reshape(1, -1)
|
||||
else:
|
||||
# Defensive: EOS step row is zeroed by the invalid-layer-0 mask and filtered downstream.
|
||||
past_hidden = torch.zeros_like(text_step)
|
||||
|
||||
# Use OmniGPUModelRunner talker_mtp fast-path for residual codebooks and per-step inputs_embeds update.
|
||||
last_id_hidden = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)).to(
|
||||
device=input_ids.device, dtype=torch.bfloat16
|
||||
device=input_ids.device, dtype=dtype
|
||||
)
|
||||
inputs_embeds_out = last_id_hidden.reshape(1, -1)
|
||||
|
||||
@@ -784,7 +794,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
)
|
||||
|
||||
device = input_ids_flat.device
|
||||
dtype = torch.bfloat16
|
||||
dtype = self._embedding_dtype
|
||||
# Request-independent constant (see :meth:`_init_runtime_buffers`) —
|
||||
# compute once for the batch instead of fetching it per request from
|
||||
# ``info_dict["embed"]["tts_pad"]``.
|
||||
@@ -1043,11 +1053,12 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
bsz = int(input_ids.shape[0])
|
||||
q = int(self.talker_config.num_code_groups)
|
||||
dev = input_embeds.device
|
||||
dtype = self._embedding_dtype
|
||||
|
||||
input_ids = input_ids.reshape(bsz, 1).to(dtype=torch.long, device=dev)
|
||||
last_id_hidden = input_embeds.reshape(bsz, 1, -1).to(dtype=torch.bfloat16, device=dev)
|
||||
past_hidden = last_talker_hidden.reshape(bsz, 1, -1).to(dtype=torch.bfloat16, device=dev)
|
||||
text_step = text_step.reshape(bsz, 1, -1).to(dtype=torch.bfloat16, device=dev)
|
||||
last_id_hidden = input_embeds.reshape(bsz, 1, -1).to(dtype=dtype, device=dev)
|
||||
past_hidden = last_talker_hidden.reshape(bsz, 1, -1).to(dtype=dtype, device=dev)
|
||||
text_step = text_step.reshape(bsz, 1, -1).to(dtype=dtype, device=dev)
|
||||
|
||||
# Residual predictor runs fixed-length (Q-1) steps via the vLLM-native code_predictor.
|
||||
max_steps = q - 1
|
||||
|
||||
@@ -53,6 +53,32 @@ _OMNI_MODELS = {
|
||||
"qwen3_omni_code2wav",
|
||||
"Qwen3OmniMoeCode2Wav",
|
||||
),
|
||||
# Step-Audio2 models
|
||||
"StepAudio2ForCausalLM": (
|
||||
"step_audio2",
|
||||
"step_audio2",
|
||||
"StepAudio2ForConditionalGeneration",
|
||||
),
|
||||
"StepAudio2ForConditionalGeneration": (
|
||||
"step_audio2",
|
||||
"step_audio2",
|
||||
"StepAudio2ForConditionalGeneration",
|
||||
),
|
||||
"StepAudio2ThinkerForConditionalGeneration": (
|
||||
"step_audio2",
|
||||
"step_audio2_thinker",
|
||||
"StepAudio2ThinkerForConditionalGeneration",
|
||||
),
|
||||
"StepAudio2Token2WavModel": (
|
||||
"step_audio2",
|
||||
"step_audio2_token2wav",
|
||||
"StepAudio2Token2WavForConditionalGeneration",
|
||||
),
|
||||
"StepAudio2Token2WavForConditionalGeneration": (
|
||||
"step_audio2",
|
||||
"step_audio2_token2wav",
|
||||
"StepAudio2Token2WavForConditionalGeneration",
|
||||
),
|
||||
"CosyVoice3Model": (
|
||||
"cosyvoice3",
|
||||
"cosyvoice3",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .step_audio2 import StepAudio2ForConditionalGeneration
|
||||
|
||||
__all__ = ["StepAudio2ForConditionalGeneration"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.models.interfaces import SupportsMultiModal, SupportsPP
|
||||
from vllm.model_executor.models.utils import init_vllm_registered_model, maybe_prefix
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
from vllm.sequence import IntermediateTensors
|
||||
|
||||
from .step_audio2_thinker import (
|
||||
StepAudio2DummyInputsBuilder,
|
||||
StepAudio2MultiModalProcessor,
|
||||
StepAudio2ProcessingInfo,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@MULTIMODAL_REGISTRY.register_processor(
|
||||
StepAudio2MultiModalProcessor,
|
||||
info=StepAudio2ProcessingInfo,
|
||||
dummy_inputs=StepAudio2DummyInputsBuilder,
|
||||
)
|
||||
class StepAudio2ForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
|
||||
"""
|
||||
Step-Audio2 Main Controller
|
||||
|
||||
Manages two-stage inference pipeline:
|
||||
- Stage 1 (Thinker): Audio understanding and token generation
|
||||
- Stage 2 (Token2Wav): Audio token to waveform synthesis
|
||||
|
||||
Usage:
|
||||
# Stage 1: Thinker
|
||||
model = StepAudio2ForConditionalGeneration(
|
||||
vllm_config=config,
|
||||
model_stage="thinker"
|
||||
)
|
||||
|
||||
# Stage 2: Token2Wav
|
||||
model = StepAudio2ForConditionalGeneration(
|
||||
vllm_config=config,
|
||||
model_stage="token2wav"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.have_multimodal_outputs = True
|
||||
|
||||
config = vllm_config.model_config.hf_config
|
||||
multimodal_config = vllm_config.model_config.multimodal_config
|
||||
|
||||
self.config = config
|
||||
self.multimodal_config = multimodal_config
|
||||
self.vllm_config = vllm_config
|
||||
|
||||
raw_model_stage = vllm_config.model_config.model_stage
|
||||
# Normalise: "step_audio2_thinker" → "thinker"
|
||||
self.model_stage = "thinker" if raw_model_stage in ("thinker", "step_audio2_thinker") else raw_model_stage
|
||||
|
||||
if self.model_stage == "thinker":
|
||||
self.thinker = init_vllm_registered_model(
|
||||
vllm_config=vllm_config,
|
||||
prefix=maybe_prefix(prefix, "thinker"),
|
||||
hf_config=config,
|
||||
architectures=["StepAudio2ThinkerForConditionalGeneration"],
|
||||
)
|
||||
self.model = self.thinker
|
||||
self.token2wav = None
|
||||
|
||||
logger.info("Initialized Step-Audio2 Thinker (Stage 0)")
|
||||
|
||||
elif self.model_stage == "token2wav":
|
||||
self.thinker = None
|
||||
self.token2wav = init_vllm_registered_model(
|
||||
vllm_config=vllm_config,
|
||||
prefix=maybe_prefix(prefix, "token2wav"),
|
||||
hf_config=config,
|
||||
architectures=["StepAudio2Token2WavForConditionalGeneration"],
|
||||
)
|
||||
self.model = self.token2wav
|
||||
|
||||
logger.info("Initialized Step-Audio2 Token2Wav (Stage 1)")
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid model_stage: {self.model_stage}. Must be 'thinker'/'step_audio2_thinker' or 'token2wav'"
|
||||
)
|
||||
|
||||
self.make_empty_intermediate_tensors = (
|
||||
self.thinker.make_empty_intermediate_tensors if self.model_stage == "thinker" else lambda: None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_placeholder_str(cls, modality: str, i: int) -> str | None:
|
||||
"""Get placeholder string for a modality
|
||||
|
||||
Returns:
|
||||
For audio: "<audio_patch>" (matches processor's audio_token)
|
||||
"""
|
||||
if modality == "audio":
|
||||
return "<audio_patch>"
|
||||
return None
|
||||
|
||||
def get_language_model(self) -> nn.Module:
|
||||
"""Get the underlying language model."""
|
||||
if self.model_stage == "thinker":
|
||||
return self.thinker.get_language_model()
|
||||
# Token2Wav is not a real language model. Return the wrapper itself so
|
||||
# vLLM interface helpers resolve embed_input_ids() on the adapter layer.
|
||||
return self.token2wav
|
||||
|
||||
def get_multimodal_embeddings(self, **kwargs):
|
||||
"""Get multimodal embeddings - only used in Thinker stage."""
|
||||
if self.model_stage == "thinker":
|
||||
return self.thinker.get_multimodal_embeddings(**kwargs)
|
||||
return None
|
||||
|
||||
def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
|
||||
"""Compatibility helper used by older call sites."""
|
||||
if self.model_stage == "thinker":
|
||||
return self.model.get_input_embeddings(input_ids)
|
||||
return self.model.embed_input_ids(input_ids)
|
||||
|
||||
def embed_input_ids(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
multimodal_embeddings=None,
|
||||
is_multimodal=None,
|
||||
) -> torch.Tensor:
|
||||
"""Explicit vLLM embedding hook for both stages."""
|
||||
if self.model_stage == "token2wav":
|
||||
return self.model.embed_input_ids(input_ids)
|
||||
return self.model.get_input_embeddings(input_ids, multimodal_embeddings)
|
||||
|
||||
def embed_multimodal(self, **kwargs):
|
||||
"""Delegate multimodal embedding to thinker stage only."""
|
||||
if self.model_stage == "thinker":
|
||||
return self.model.embed_multimodal(**kwargs)
|
||||
return None
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Forward pass through the model
|
||||
|
||||
For Thinker:
|
||||
Returns hidden states/logits
|
||||
For Token2Wav:
|
||||
Returns waveform
|
||||
"""
|
||||
return self.model.forward(
|
||||
input_ids=input_ids,
|
||||
positions=positions,
|
||||
intermediate_tensors=intermediate_tensors,
|
||||
inputs_embeds=inputs_embeds,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
|
||||
"""Compute logits from hidden states"""
|
||||
return self.model.compute_logits(hidden_states)
|
||||
|
||||
def load_weights(self, weights):
|
||||
"""Load weights"""
|
||||
return self.model.load_weights(weights)
|
||||
|
||||
def move_submodules_to_devices(
|
||||
self,
|
||||
*,
|
||||
thinker_device: str | torch.device | None = None,
|
||||
token2wav_device: str | torch.device | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Optionally move thinker/token2wav to different devices
|
||||
|
||||
Example:
|
||||
model.move_submodules_to_devices(
|
||||
thinker_device='cuda:0',
|
||||
token2wav_device='cuda:1',
|
||||
)
|
||||
"""
|
||||
if thinker_device is not None and self.thinker is not None:
|
||||
self.thinker.to(torch.device(thinker_device))
|
||||
logger.info(f"Moved Thinker to {thinker_device}")
|
||||
|
||||
if token2wav_device is not None and self.token2wav is not None:
|
||||
self.token2wav.to(torch.device(token2wav_device))
|
||||
logger.info(f"Moved Token2Wav to {token2wav_device}")
|
||||
@@ -0,0 +1,181 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Step-Audio2 configuration constants - Single Source of Truth."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAudio2TokenConfig:
|
||||
"""
|
||||
Step-Audio2 token ID ranges and vocabulary configuration.
|
||||
|
||||
Token ID Layout (based on vocab_size=158720):
|
||||
- Text tokens: 0 - 151688 (standard Qwen tokenizer)
|
||||
- Special tokens: 151689 - 151695 (reserved)
|
||||
- Audio tokens: 151696 - 158257 (vocab size 6562)
|
||||
|
||||
These values are architecture constants for Step-Audio2 and do not
|
||||
change between model variants (mini/7B).
|
||||
"""
|
||||
|
||||
# Text token range
|
||||
text_max: int = 151688
|
||||
"""Maximum text token ID (inclusive)"""
|
||||
|
||||
# Audio token range (absolute IDs in full vocabulary)
|
||||
audio_start: int = 151696
|
||||
"""First audio token ID in absolute vocabulary"""
|
||||
|
||||
audio_vocab_size: int = 6562
|
||||
"""Total number of audio tokens"""
|
||||
|
||||
audio_eos: int = 6561
|
||||
"""Audio EOS token ID (relative to audio_start, used for padding)"""
|
||||
|
||||
# Special tokens
|
||||
audio_patch_token_id: int = 151690
|
||||
"""<audio_patch> placeholder token ID"""
|
||||
|
||||
@property
|
||||
def audio_end(self) -> int:
|
||||
"""Last audio token ID in absolute vocabulary"""
|
||||
return self.audio_start + self.audio_vocab_size - 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAudio2EncoderConfig:
|
||||
"""
|
||||
Step-Audio2 audio encoder architecture configuration.
|
||||
|
||||
These values define the encoder structure and should match your model weights.
|
||||
"""
|
||||
|
||||
# Mel spectrogram parameters
|
||||
n_mels: int = 128
|
||||
"""Number of mel frequency bins"""
|
||||
|
||||
# Encoder architecture
|
||||
n_audio_ctx: int = 1500
|
||||
"""Audio context length (max sequence length)"""
|
||||
|
||||
n_audio_state: int = 512
|
||||
"""Audio encoder hidden state dimension"""
|
||||
|
||||
n_audio_head: int = 8
|
||||
"""Number of attention heads in encoder"""
|
||||
|
||||
n_audio_layer: int = 6
|
||||
"""Number of encoder layers"""
|
||||
|
||||
# Adapter parameters
|
||||
kernel_size: int = 3
|
||||
"""Convolution kernel size for adapter"""
|
||||
|
||||
adapter_stride: int = 2
|
||||
"""Stride for adapter convolution (downsampling factor)"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAudio2Token2WavConfig:
|
||||
"""Token2Wav synthesis configuration."""
|
||||
|
||||
n_timesteps: int = 10
|
||||
"""Number of diffusion timesteps for flow model inference"""
|
||||
|
||||
sample_rate: int = 24000
|
||||
"""Output audio sample rate"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAudio2StreamConfig:
|
||||
"""Streaming (chunked) inference configuration for Token2Wav."""
|
||||
|
||||
chunk_size: int = 25
|
||||
"""Number of audio tokens consumed per chunk."""
|
||||
|
||||
pre_lookahead_len: int = 3
|
||||
"""Conformer encoder lookahead tokens beyond the chunk boundary."""
|
||||
|
||||
up_rate: int = 2
|
||||
"""Token-to-mel upsampling rate inside the flow model."""
|
||||
|
||||
mel_cache_len: int = 8
|
||||
"""HiFT overlap mel frames (160 ms at 50 Hz mel rate)."""
|
||||
|
||||
n_timesteps: int = 10
|
||||
"""Flow model ODE solver steps."""
|
||||
|
||||
estimator_cache_keep: int = 100
|
||||
"""Attention-cache pruning window for the flow estimator."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAudio2ModelConfig:
|
||||
"""
|
||||
Step-Audio2 complete model configuration - Single Source of Truth.
|
||||
|
||||
All architecture constants are defined here. The stage YAML and model config.json
|
||||
can override these values if needed, but this serves as the default.
|
||||
"""
|
||||
|
||||
# LLM configuration (Qwen2)
|
||||
hidden_size: int = 4096
|
||||
"""LLM hidden dimension (Qwen2-7B: 4096, Qwen2-1.5B: 1536, Qwen2-0.5B: 896)"""
|
||||
|
||||
# Token configuration
|
||||
text_max: int = 151688
|
||||
audio_start: int = 151696
|
||||
audio_vocab_size: int = 6562
|
||||
audio_eos: int = 6561
|
||||
audio_patch_token_id: int = 151690
|
||||
|
||||
# Encoder configuration
|
||||
n_mels: int = 128
|
||||
n_audio_ctx: int = 1500
|
||||
n_audio_state: int = 512
|
||||
n_audio_head: int = 8
|
||||
n_audio_layer: int = 6
|
||||
kernel_size: int = 3
|
||||
adapter_stride: int = 2
|
||||
|
||||
|
||||
# Default configuration instances
|
||||
DEFAULT_TOKEN_CONFIG = StepAudio2TokenConfig()
|
||||
DEFAULT_ENCODER_CONFIG = StepAudio2EncoderConfig()
|
||||
DEFAULT_TOKEN2WAV_CONFIG = StepAudio2Token2WavConfig()
|
||||
DEFAULT_STREAM_CONFIG = StepAudio2StreamConfig()
|
||||
DEFAULT_MODEL_CONFIG = StepAudio2ModelConfig()
|
||||
|
||||
STREAM_SOURCE_CACHE_LEN = DEFAULT_STREAM_CONFIG.mel_cache_len * 480 # 3840 samples
|
||||
|
||||
# Export constants for backward compatibility
|
||||
STEP_AUDIO2_TEXT_MAX = DEFAULT_TOKEN_CONFIG.text_max
|
||||
STEP_AUDIO2_AUDIO_START = DEFAULT_TOKEN_CONFIG.audio_start
|
||||
STEP_AUDIO2_AUDIO_VOCAB_SIZE = DEFAULT_TOKEN_CONFIG.audio_vocab_size
|
||||
STEP_AUDIO2_AUDIO_EOS = DEFAULT_TOKEN_CONFIG.audio_eos
|
||||
STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID = DEFAULT_TOKEN_CONFIG.audio_patch_token_id
|
||||
STEP_AUDIO2_AUDIO_END = DEFAULT_TOKEN_CONFIG.audio_end
|
||||
|
||||
# Default prompt wav path for Token2Wav (can be overridden by env var)
|
||||
STEP_AUDIO2_DEFAULT_PROMPT_WAV = "default_female.wav"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StepAudio2TokenConfig",
|
||||
"StepAudio2EncoderConfig",
|
||||
"StepAudio2Token2WavConfig",
|
||||
"StepAudio2StreamConfig",
|
||||
"StepAudio2ModelConfig",
|
||||
"DEFAULT_TOKEN_CONFIG",
|
||||
"DEFAULT_ENCODER_CONFIG",
|
||||
"DEFAULT_TOKEN2WAV_CONFIG",
|
||||
"DEFAULT_STREAM_CONFIG",
|
||||
"DEFAULT_MODEL_CONFIG",
|
||||
"STREAM_SOURCE_CACHE_LEN",
|
||||
"STEP_AUDIO2_TEXT_MAX",
|
||||
"STEP_AUDIO2_AUDIO_START",
|
||||
"STEP_AUDIO2_AUDIO_VOCAB_SIZE",
|
||||
"STEP_AUDIO2_AUDIO_EOS",
|
||||
"STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID",
|
||||
"STEP_AUDIO2_AUDIO_END",
|
||||
]
|
||||
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
Step-Audio2 Thinker - Stage 1 LLM for Audio Understanding
|
||||
|
||||
This file contains:
|
||||
- Audio encoder components (AudioEncoder, Adaptor)
|
||||
- Audio preprocessing utilities (mel-spectrogram extraction)
|
||||
- Multi-modal processing (StepAudio2MultiModalProcessor)
|
||||
- Main thinker model (StepAudio2ThinkerForConditionalGeneration)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
import librosa # noqa: TID251
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
from transformers import AutoTokenizer
|
||||
from transformers.processing_utils import ProcessorMixin
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.inputs import MultiModalDataDict
|
||||
from vllm.model_executor.models.interfaces import SupportsMultiModal, SupportsPP
|
||||
from vllm.model_executor.models.utils import (
|
||||
_merge_multimodal_embeddings,
|
||||
flatten_bn,
|
||||
init_vllm_registered_model,
|
||||
maybe_prefix,
|
||||
)
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
from vllm.multimodal.inputs import (
|
||||
MultiModalFieldConfig,
|
||||
MultiModalKwargsItems,
|
||||
NestedTensors,
|
||||
)
|
||||
from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser
|
||||
from vllm.multimodal.processing import (
|
||||
BaseDummyInputsBuilder,
|
||||
BaseMultiModalProcessor,
|
||||
BaseProcessingInfo,
|
||||
PromptReplacement,
|
||||
PromptUpdate,
|
||||
)
|
||||
from vllm.sequence import IntermediateTensors
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_constants import (
|
||||
DEFAULT_MODEL_CONFIG,
|
||||
DEFAULT_TOKEN_CONFIG,
|
||||
STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Audio Preprocessing Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _mel_filters(n_mels: int) -> torch.Tensor:
|
||||
"""Generate mel filter banks"""
|
||||
assert n_mels in {80, 128}, f"Unsupported n_mels: {n_mels}"
|
||||
return torch.from_numpy(librosa.filters.mel(sr=16000, n_fft=400, n_mels=n_mels))
|
||||
|
||||
|
||||
def _normalize_audio(audio: Any) -> torch.Tensor:
|
||||
"""Normalize audio to torch.Tensor"""
|
||||
if isinstance(audio, tuple):
|
||||
audio = audio[0]
|
||||
if isinstance(audio, np.ndarray):
|
||||
audio = torch.from_numpy(audio)
|
||||
elif not torch.is_tensor(audio):
|
||||
audio = torch.tensor(audio, dtype=torch.float32)
|
||||
return audio.float()
|
||||
|
||||
|
||||
def log_mel_spectrogram(audio: Any, n_mels: int = 128, padding: int = 479) -> torch.Tensor:
|
||||
"""
|
||||
Convert audio to log mel-spectrogram
|
||||
|
||||
Args:
|
||||
audio: Audio waveform (any format)
|
||||
n_mels: Number of mel frequency bins (default: 128)
|
||||
padding: Right padding for STFT (default: 479)
|
||||
|
||||
Returns:
|
||||
Log mel-spectrogram tensor of shape (n_mels, T)
|
||||
"""
|
||||
audio = _normalize_audio(audio)
|
||||
if padding > 0:
|
||||
audio = F.pad(audio, (0, padding))
|
||||
window = torch.hann_window(400, device=audio.device)
|
||||
stft = torch.stft(audio, 400, 160, window=window, return_complex=True)
|
||||
magnitudes = stft[..., :-1].abs() ** 2
|
||||
filters = _mel_filters(n_mels).to(audio.device)
|
||||
mel_spec = filters @ magnitudes
|
||||
|
||||
log_spec = torch.clamp(mel_spec, min=1e-10).log10()
|
||||
log_spec = torch.maximum(log_spec, log_spec.max() - 8.0)
|
||||
log_spec = (log_spec + 4.0) / 4.0
|
||||
return log_spec
|
||||
|
||||
|
||||
def padding_mels(data: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
Pad mel spectrograms to same length
|
||||
|
||||
Args:
|
||||
data: List of mel spectrograms, each of shape (n_mels, T)
|
||||
|
||||
Returns:
|
||||
padded_feats: Padded tensor of shape (batch, n_mels, max_T)
|
||||
feats_lengths: Lengths of each sequence
|
||||
"""
|
||||
feats_lengths = torch.tensor([s.size(1) - 2 for s in data], dtype=torch.int32)
|
||||
feats = [s.t() for s in data]
|
||||
padded_feats = pad_sequence(feats, batch_first=True, padding_value=0)
|
||||
return padded_feats.transpose(1, 2), feats_lengths
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Encoder Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def make_non_pad_mask(lengths: torch.Tensor, max_len: int = 0) -> torch.Tensor:
|
||||
"""Create non-padding mask from lengths"""
|
||||
batch_size = lengths.size(0)
|
||||
max_len = max_len if max_len > 0 else lengths.max().item()
|
||||
seq_range = torch.arange(0, max_len, dtype=torch.int64, device=lengths.device)
|
||||
seq_range_expand = seq_range.unsqueeze(0).expand(batch_size, max_len)
|
||||
seq_length_expand = lengths.unsqueeze(-1)
|
||||
mask = seq_range_expand >= seq_length_expand
|
||||
return ~mask
|
||||
|
||||
|
||||
def mask_to_bias(mask: torch.Tensor, dtype: torch.dtype) -> torch.Tensor:
|
||||
"""Convert mask to attention bias"""
|
||||
assert mask.dtype == torch.bool
|
||||
assert dtype in [torch.float32, torch.bfloat16, torch.float16]
|
||||
mask = mask.to(dtype)
|
||||
mask = (1.0 - mask) * -1.0e10
|
||||
return mask
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Audio Encoder Components
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MultiHeadAttention(nn.Module):
|
||||
"""Multi-head attention for audio encoder"""
|
||||
|
||||
def __init__(self, n_state: int, n_head: int):
|
||||
super().__init__()
|
||||
self.n_head = n_head
|
||||
self.query = nn.Linear(n_state, n_state)
|
||||
self.key = nn.Linear(n_state, n_state, bias=False)
|
||||
self.value = nn.Linear(n_state, n_state)
|
||||
self.out = nn.Linear(n_state, n_state)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
mask: torch.Tensor | None = None,
|
||||
):
|
||||
q = self.query(x)
|
||||
k = self.key(x)
|
||||
v = self.value(x)
|
||||
|
||||
wv, qk = self.qkv_attention(q, k, v, mask)
|
||||
return self.out(wv), qk
|
||||
|
||||
def qkv_attention(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: torch.Tensor | None = None):
|
||||
B, T, D = q.shape
|
||||
head_dim = D // self.n_head
|
||||
|
||||
# Reshape: (B, T, D) -> (B, n_head, T, head_dim)
|
||||
q = q.view(B, T, self.n_head, head_dim).transpose(1, 2)
|
||||
k = k.view(B, T, self.n_head, head_dim).transpose(1, 2)
|
||||
v = v.view(B, T, self.n_head, head_dim).transpose(1, 2)
|
||||
|
||||
# Use F.scaled_dot_product_attention
|
||||
# mask is additive bias (0 or -1e10), pass directly as attn_mask
|
||||
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
|
||||
|
||||
# Reshape back: (B, n_head, T, head_dim) -> (B, T, D)
|
||||
out = out.transpose(1, 2).reshape(B, T, D)
|
||||
return out, None
|
||||
|
||||
|
||||
class ResidualAttentionBlock(nn.Module):
|
||||
"""Residual attention block for audio encoder"""
|
||||
|
||||
def __init__(self, n_state: int, n_head: int):
|
||||
super().__init__()
|
||||
|
||||
self.attn = MultiHeadAttention(n_state, n_head)
|
||||
self.attn_ln = nn.LayerNorm(n_state)
|
||||
|
||||
n_mlp = n_state * 4
|
||||
self.mlp = nn.Sequential(nn.Linear(n_state, n_mlp), nn.GELU(), nn.Linear(n_mlp, n_state))
|
||||
self.mlp_ln = nn.LayerNorm(n_state)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
mask: torch.Tensor | None = None,
|
||||
):
|
||||
x = x + self.attn(self.attn_ln(x), mask=mask)[0]
|
||||
x = x + self.mlp(self.mlp_ln(x))
|
||||
return x
|
||||
|
||||
|
||||
class AudioEncoder(nn.Module):
|
||||
"""
|
||||
Step-Audio2 Audio Encoder
|
||||
|
||||
Lightweight audio encoder (6 layers, 512 hidden)
|
||||
Optimized for 25s audio chunks at 25Hz
|
||||
"""
|
||||
|
||||
def __init__(self, n_mels: int, n_ctx: int, n_state: int, n_head: int, n_layer: int):
|
||||
super().__init__()
|
||||
self.conv1 = nn.Conv1d(n_mels, n_state, kernel_size=3, padding=1)
|
||||
self.conv2 = nn.Conv1d(n_state, n_state, kernel_size=3, stride=2, padding=1)
|
||||
self.positional_embedding = nn.Embedding(n_ctx, n_state)
|
||||
|
||||
self.blocks: Iterable[ResidualAttentionBlock] = nn.ModuleList(
|
||||
[ResidualAttentionBlock(n_state, n_head) for _ in range(n_layer)]
|
||||
)
|
||||
self.avg_pooler = nn.AvgPool1d(2, stride=2)
|
||||
self.after_norm = nn.LayerNorm(n_state)
|
||||
|
||||
def forward(self, x: torch.Tensor, x_len: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
Args:
|
||||
x: mel spectrogram, shape (batch_size, n_mels, T)
|
||||
x_len: length of each audio, shape (batch_size,)
|
||||
|
||||
Returns:
|
||||
x: encoded features, shape (batch_size, T//4, n_state)
|
||||
x_len: updated lengths, shape (batch_size,)
|
||||
"""
|
||||
T = x.size(-1)
|
||||
x = F.gelu(self.conv1(x))
|
||||
x = F.gelu(self.conv2(x))
|
||||
x = x.permute(0, 2, 1) # (B, T // 2, n_state)
|
||||
|
||||
# Create attention mask
|
||||
mask = make_non_pad_mask(x_len, T).unsqueeze(1) # (B, 1, T)
|
||||
mask = mask_to_bias(mask[:, :, (T + 1) % 2 :: 2], x.dtype) # (B, 1, T // 2)
|
||||
|
||||
# Add positional embedding
|
||||
x = (x + self.positional_embedding.weight[: x.shape[1], :]).to(x.dtype).contiguous()
|
||||
|
||||
# Apply attention blocks
|
||||
for block in self.blocks:
|
||||
x = block(x, mask.unsqueeze(1))
|
||||
|
||||
# Pool and normalize
|
||||
x = x.permute(0, 2, 1)
|
||||
x = self.avg_pooler(x)
|
||||
x = x.permute(0, 2, 1)
|
||||
x_len = (x_len + 1) // 2 // 2
|
||||
x = self.after_norm(x)
|
||||
|
||||
return x, x_len
|
||||
|
||||
|
||||
class Adaptor(nn.Module):
|
||||
"""
|
||||
Adaptor to project audio features to LLM dimension
|
||||
|
||||
Maps from n_state (512) to n_hidden (LLM dimension, e.g., 4096)
|
||||
with optional downsampling via convolution
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, n_state: int = 512, n_hidden: int = 4096, kernel_size: int = 3, stride: int = 2, adapter_state: int = 2048
|
||||
):
|
||||
super().__init__()
|
||||
self.stride = stride
|
||||
|
||||
if self.stride != -1:
|
||||
self.conv = nn.Conv1d(n_state, n_state, kernel_size, stride, padding=1)
|
||||
|
||||
self.linear1 = nn.Linear(n_state, adapter_state)
|
||||
self.relu = nn.ReLU()
|
||||
self.linear2 = nn.Linear(adapter_state, n_hidden)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Args:
|
||||
x: audio features, shape (batch_size, T, n_state)
|
||||
|
||||
Returns:
|
||||
x: projected features, shape (batch_size, T//stride, n_hidden)
|
||||
"""
|
||||
if self.stride != -1:
|
||||
x = x.permute(0, 2, 1) # (B, n_state, T)
|
||||
x = F.gelu(self.conv(x))
|
||||
x = x.permute(0, 2, 1) # (B, T//stride, n_state)
|
||||
|
||||
x = self.linear1(x)
|
||||
x = self.relu(x)
|
||||
x = self.linear2(x)
|
||||
|
||||
return x
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feature Length Calculation Utility
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def calculate_audio_feature_length(mel_length: int) -> int:
|
||||
"""
|
||||
Calculate output feature length after encoder + adapter processing.
|
||||
|
||||
Processing chain:
|
||||
1. Mel spectrogram input: length = mel_length
|
||||
2. Encoder (AudioEncoder):
|
||||
- conv1: kernel=3, padding=1, stride=1 → no change
|
||||
- conv2: kernel=3, padding=1, stride=2 → T // 2
|
||||
- avg_pool: kernel=2, stride=2 → T // 4
|
||||
- Output length: (mel_length + 1) // 2 // 2 ≈ mel_length // 4
|
||||
3. Adapter (Adaptor):
|
||||
- conv: kernel=3, padding=1, stride=2 → T // 2
|
||||
- Output length: (encoder_output - 1) // 2 + 1
|
||||
|
||||
Args:
|
||||
mel_length: Length of mel spectrogram (time steps)
|
||||
|
||||
Returns:
|
||||
Final feature length after encoder + adapter
|
||||
|
||||
Example:
|
||||
>>> calculate_audio_feature_length(1000)
|
||||
125
|
||||
"""
|
||||
encoder_output_len = (mel_length + 1) // 4
|
||||
|
||||
adapter_output_len = (encoder_output_len - 1) // 2 + 1
|
||||
|
||||
return max(1, adapter_output_len)
|
||||
|
||||
|
||||
class StepAudio2Processor(ProcessorMixin):
|
||||
"""Processor for Step-Audio2 that handles text tokenization and audio mel-spectrograms."""
|
||||
|
||||
attributes = ["tokenizer"]
|
||||
attribute_class = {"tokenizer": "PreTrainedTokenizerBase"}
|
||||
|
||||
def __init__(self, tokenizer):
|
||||
self.tokenizer = tokenizer
|
||||
self.audio_token = "<audio_patch>"
|
||||
|
||||
def __call__(self, text=None, audio=None, **kwargs):
|
||||
"""Process text and audio inputs."""
|
||||
if text is None:
|
||||
text = ""
|
||||
|
||||
allowed = {
|
||||
"padding",
|
||||
"truncation",
|
||||
"max_length",
|
||||
"return_tensors",
|
||||
"add_special_tokens",
|
||||
"pad_to_multiple_of",
|
||||
"stride",
|
||||
"return_attention_mask",
|
||||
"return_token_type_ids",
|
||||
"return_overflowing_tokens",
|
||||
"return_offsets_mapping",
|
||||
"return_special_tokens_mask",
|
||||
"verbose",
|
||||
"is_split_into_words",
|
||||
}
|
||||
tok_kwargs = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if "return_tensors" not in tok_kwargs:
|
||||
tok_kwargs["return_tensors"] = "pt"
|
||||
|
||||
encoded = self.tokenizer(text, **tok_kwargs)
|
||||
|
||||
if audio is None:
|
||||
encoded["audio_mels"] = torch.empty((0, 128, 0))
|
||||
encoded["audio_lens"] = torch.tensor([], dtype=torch.int32)
|
||||
return encoded
|
||||
|
||||
audio_list = list(audio) if isinstance(audio, (list, tuple)) else [audio]
|
||||
|
||||
mels = [log_mel_spectrogram(a) for a in audio_list]
|
||||
audio_mels, audio_lens = padding_mels(mels)
|
||||
|
||||
encoded["audio_mels"] = audio_mels
|
||||
encoded["audio_lens"] = audio_lens
|
||||
return encoded
|
||||
|
||||
|
||||
class StepAudio2AudioInputs(TypedDict):
|
||||
"""Audio inputs for Step-Audio2"""
|
||||
|
||||
audio_mels: torch.Tensor
|
||||
"""Shape: (batch, num_mel_bins, time_steps)"""
|
||||
|
||||
audio_lens: list[int]
|
||||
"""Shape: (batch,) - length of each audio in time steps"""
|
||||
|
||||
|
||||
class StepAudio2ProcessingInfo(BaseProcessingInfo):
|
||||
"""Processing info for Step-Audio2"""
|
||||
|
||||
def get_tokenizer(self, **kwargs):
|
||||
return AutoTokenizer.from_pretrained(
|
||||
self.ctx.model_config.model,
|
||||
trust_remote_code=True,
|
||||
fix_mistral_regex=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_hf_processor(self, **kwargs: object):
|
||||
return StepAudio2Processor(self.get_tokenizer())
|
||||
|
||||
def build_data_parser(self) -> MultiModalDataParser:
|
||||
return MultiModalDataParser(target_sr=16000)
|
||||
|
||||
# Backward/branch compatibility:
|
||||
# some code paths still call get_data_parser() on ProcessingInfo.
|
||||
def get_data_parser(self) -> MultiModalDataParser:
|
||||
return MultiModalDataParser(target_sr=16000)
|
||||
|
||||
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
|
||||
return {"audio": None}
|
||||
|
||||
def get_mm_max_tokens_per_item(
|
||||
self,
|
||||
seq_len: int,
|
||||
mm_counts: Mapping[str, int],
|
||||
) -> Mapping[str, int]:
|
||||
max_audio_tokens = 250
|
||||
return {"audio": max_audio_tokens}
|
||||
|
||||
|
||||
class StepAudio2DummyInputsBuilder(BaseDummyInputsBuilder[StepAudio2ProcessingInfo]):
|
||||
"""Dummy inputs builder for Step-Audio2"""
|
||||
|
||||
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
|
||||
num_audios = mm_counts.get("audio", 0)
|
||||
return "<audio_patch>" * num_audios
|
||||
|
||||
def get_dummy_mm_data(
|
||||
self,
|
||||
seq_len: int,
|
||||
mm_counts: Mapping[str, int],
|
||||
mm_options: Mapping[str, object] | None = None,
|
||||
) -> MultiModalDataDict:
|
||||
audio_len = 16000 * 25
|
||||
num_audios = mm_counts.get("audio", 0)
|
||||
return {"audio": self._get_dummy_audios(length=audio_len, num_audios=num_audios)}
|
||||
|
||||
|
||||
class StepAudio2MultiModalProcessor(BaseMultiModalProcessor[StepAudio2ProcessingInfo]):
|
||||
"""Multi-modal processor for Step-Audio2"""
|
||||
|
||||
def _get_mm_fields_config(
|
||||
self,
|
||||
hf_inputs,
|
||||
hf_processor_mm_kwargs: Mapping[str, object],
|
||||
) -> Mapping[str, MultiModalFieldConfig]:
|
||||
"""Map processor outputs to vLLM multimodal fields.
|
||||
|
||||
audio_mels is a padded 3D tensor per audio item with matching audio_lens.
|
||||
Use flat_from_sizes to keep one mm item per audio, so downstream mm_kwargs
|
||||
alignment does not collapse to a single item.
|
||||
"""
|
||||
audio_lens = hf_inputs.get("audio_lens", torch.empty(0))
|
||||
if isinstance(audio_lens, torch.Tensor):
|
||||
lens_tensor = audio_lens.flatten()
|
||||
elif audio_lens is None:
|
||||
lens_tensor = torch.empty(0, dtype=torch.int32)
|
||||
else:
|
||||
lens_tensor = torch.tensor(
|
||||
list(audio_lens) if hasattr(audio_lens, "__iter__") else [int(audio_lens)], dtype=torch.int32
|
||||
)
|
||||
|
||||
return dict(
|
||||
audio_mels=MultiModalFieldConfig.flat_from_sizes("audio", lens_tensor),
|
||||
audio_lens=MultiModalFieldConfig.batched("audio"),
|
||||
)
|
||||
|
||||
def _get_prompt_updates(
|
||||
self,
|
||||
mm_items: MultiModalDataItems,
|
||||
hf_processor_mm_kwargs: Mapping[str, object],
|
||||
out_mm_kwargs: MultiModalKwargsItems,
|
||||
) -> Sequence[PromptUpdate]:
|
||||
processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
|
||||
|
||||
audio_token = getattr(processor, "audio_token", "<audio_patch>")
|
||||
|
||||
audio_items = mm_items.get("audio", [])
|
||||
num_audio_items = len(audio_items) if audio_items else 0
|
||||
|
||||
out_mm_data = out_mm_kwargs.get_data()
|
||||
audio_lens = out_mm_data.get("audio_lens")
|
||||
|
||||
feature_lens = []
|
||||
if audio_lens is not None:
|
||||
if isinstance(audio_lens, torch.Tensor):
|
||||
audio_lens_list = audio_lens.tolist()
|
||||
else:
|
||||
audio_lens_list = list(audio_lens) if hasattr(audio_lens, "__iter__") else [audio_lens]
|
||||
|
||||
for length in audio_lens_list:
|
||||
if length > 0:
|
||||
feature_len = calculate_audio_feature_length(int(length))
|
||||
feature_lens.append(feature_len)
|
||||
|
||||
# CRITICAL: Align feature_lens with mm_items['audio'] count
|
||||
if num_audio_items > 0:
|
||||
if len(feature_lens) < num_audio_items:
|
||||
default_feature_len = 250
|
||||
pad_count = num_audio_items - len(feature_lens)
|
||||
feature_lens.extend([default_feature_len] * pad_count)
|
||||
elif len(feature_lens) > num_audio_items:
|
||||
feature_lens = feature_lens[:num_audio_items]
|
||||
elif not feature_lens:
|
||||
feature_lens = [250]
|
||||
|
||||
def get_replacement_audio(item_idx: int):
|
||||
"""Generate replacement tokens for audio placeholder.
|
||||
|
||||
Following Qwen2.5-Omni pattern: returns list[int] of token IDs.
|
||||
"""
|
||||
if item_idx >= len(feature_lens):
|
||||
num_features = 1
|
||||
else:
|
||||
num_features = feature_lens[item_idx]
|
||||
|
||||
return [STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID] * num_features
|
||||
|
||||
return [
|
||||
PromptReplacement(
|
||||
modality="audio",
|
||||
target=audio_token,
|
||||
replacement=get_replacement_audio,
|
||||
)
|
||||
]
|
||||
|
||||
def _call_hf_processor(
|
||||
self,
|
||||
prompt: str,
|
||||
mm_data: Mapping[str, object],
|
||||
mm_kwargs: Mapping[str, object] | None = None,
|
||||
tok_kwargs: Mapping[str, object] | None = None,
|
||||
**_: object,
|
||||
):
|
||||
"""Call HF processor and post-process outputs"""
|
||||
mm_data = dict(mm_data)
|
||||
audios = mm_data.pop("audios", [])
|
||||
|
||||
mm_kwargs = mm_kwargs or {}
|
||||
tok_kwargs = tok_kwargs or {}
|
||||
|
||||
# CRITICAL: Ensure audios is ALWAYS a list to prevent string iteration
|
||||
if audios:
|
||||
if isinstance(audios, str):
|
||||
mm_data["audio"] = [audios]
|
||||
elif isinstance(audios, (list, tuple)):
|
||||
mm_data["audio"] = audios
|
||||
else:
|
||||
mm_data["audio"] = [audios]
|
||||
|
||||
hf_inputs = super()._call_hf_processor(
|
||||
prompt=prompt,
|
||||
mm_data=mm_data,
|
||||
mm_kwargs=mm_kwargs,
|
||||
tok_kwargs=tok_kwargs,
|
||||
)
|
||||
|
||||
if "audio_mels" not in hf_inputs:
|
||||
hf_inputs["audio_mels"] = torch.empty((0, 128, 0))
|
||||
if "audio_lens" not in hf_inputs:
|
||||
hf_inputs["audio_lens"] = torch.tensor([], dtype=torch.int32)
|
||||
|
||||
return hf_inputs
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Thinker Model
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@MULTIMODAL_REGISTRY.register_processor(
|
||||
StepAudio2MultiModalProcessor,
|
||||
info=StepAudio2ProcessingInfo,
|
||||
dummy_inputs=StepAudio2DummyInputsBuilder,
|
||||
)
|
||||
class StepAudio2ThinkerForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
|
||||
"""
|
||||
Step-Audio2 Thinker - Stage 1 LLM
|
||||
|
||||
Architecture:
|
||||
AudioEncoder (6 layers, 512 hidden) →
|
||||
Adaptor (512 → LLM dim) →
|
||||
Qwen2 LLM (vocab=64012)
|
||||
"""
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.have_multimodal_outputs = True
|
||||
|
||||
config = vllm_config.model_config.hf_config
|
||||
multimodal_config = vllm_config.model_config.multimodal_config
|
||||
|
||||
self.config = config
|
||||
self.multimodal_config = multimodal_config
|
||||
|
||||
audio_enc_cfg = getattr(config, "audio_encoder_config", {})
|
||||
|
||||
def _get_config_value(cfg, attr_name: str, default_value):
|
||||
"""Get value from config object or dict"""
|
||||
if isinstance(cfg, dict):
|
||||
return cfg.get(attr_name, default_value)
|
||||
else:
|
||||
return getattr(cfg, attr_name, default_value)
|
||||
|
||||
n_mels = _get_config_value(audio_enc_cfg, "n_mels", DEFAULT_MODEL_CONFIG.n_mels)
|
||||
n_audio_ctx = _get_config_value(audio_enc_cfg, "n_audio_ctx", DEFAULT_MODEL_CONFIG.n_audio_ctx)
|
||||
n_audio_state = _get_config_value(audio_enc_cfg, "n_audio_state", DEFAULT_MODEL_CONFIG.n_audio_state)
|
||||
n_audio_head = _get_config_value(audio_enc_cfg, "n_audio_head", DEFAULT_MODEL_CONFIG.n_audio_head)
|
||||
n_audio_layer = _get_config_value(audio_enc_cfg, "n_audio_layer", DEFAULT_MODEL_CONFIG.n_audio_layer)
|
||||
kernel_size = _get_config_value(audio_enc_cfg, "kernel_size", DEFAULT_MODEL_CONFIG.kernel_size)
|
||||
adapter_stride = _get_config_value(audio_enc_cfg, "adapter_stride", DEFAULT_MODEL_CONFIG.adapter_stride)
|
||||
|
||||
n_hidden = None
|
||||
|
||||
if audio_enc_cfg:
|
||||
n_hidden = _get_config_value(audio_enc_cfg, "llm_dim", None)
|
||||
|
||||
if n_hidden is None:
|
||||
text_config = getattr(config, "text_config", None)
|
||||
if text_config is not None:
|
||||
n_hidden = _get_config_value(text_config, "hidden_size", None)
|
||||
|
||||
if n_hidden is None:
|
||||
n_hidden = _get_config_value(config, "hidden_size", None)
|
||||
|
||||
if n_hidden is None:
|
||||
n_hidden = DEFAULT_MODEL_CONFIG.hidden_size
|
||||
|
||||
self.encoder = AudioEncoder(
|
||||
n_mels=n_mels,
|
||||
n_ctx=n_audio_ctx,
|
||||
n_state=n_audio_state,
|
||||
n_head=n_audio_head,
|
||||
n_layer=n_audio_layer,
|
||||
)
|
||||
|
||||
self.adapter = Adaptor(
|
||||
n_state=n_audio_state,
|
||||
n_hidden=n_hidden,
|
||||
kernel_size=kernel_size,
|
||||
stride=adapter_stride,
|
||||
)
|
||||
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
text_config = getattr(config, "text_config", None)
|
||||
lm_config = text_config if isinstance(text_config, PretrainedConfig) else config
|
||||
architectures = getattr(lm_config, "architectures", None) or getattr(config, "architectures", None)
|
||||
if not architectures:
|
||||
model_type = getattr(lm_config, "model_type", None) or getattr(config, "model_type", None)
|
||||
model_type_to_arch = {
|
||||
"qwen2": "Qwen2ForCausalLM",
|
||||
"qwen2_5": "Qwen2_5ForCausalLM",
|
||||
}
|
||||
arch = model_type_to_arch.get(model_type)
|
||||
architectures = [arch] if arch else ["Qwen2ForCausalLM"]
|
||||
lm_vllm_config = vllm_config.with_hf_config(lm_config, architectures=architectures)
|
||||
self.language_model = init_vllm_registered_model(
|
||||
vllm_config=lm_vllm_config, hf_config=lm_config, prefix=maybe_prefix(prefix, "language_model")
|
||||
)
|
||||
|
||||
self.make_empty_intermediate_tensors = self.language_model.make_empty_intermediate_tensors
|
||||
|
||||
@classmethod
|
||||
def get_placeholder_str(cls, modality: str, i: int) -> str | None:
|
||||
if modality.startswith("audio"):
|
||||
return "<audio_patch>"
|
||||
raise ValueError("Only audio modality is supported")
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
return next(self.parameters()).device
|
||||
|
||||
@property
|
||||
def dtype(self):
|
||||
return next(self.parameters()).dtype
|
||||
|
||||
def _parse_and_validate_audio_input(self, **kwargs: object) -> StepAudio2AudioInputs | None:
|
||||
"""Parse audio inputs from kwargs"""
|
||||
audio_mels = kwargs.get("audio_mels", None)
|
||||
audio_lens = kwargs.get("audio_lens", None)
|
||||
|
||||
if audio_mels is None:
|
||||
return None
|
||||
|
||||
if isinstance(audio_mels, torch.Tensor):
|
||||
if audio_mels.dim() >= 4:
|
||||
audio_mels = audio_mels.flatten(0, 1)
|
||||
elif audio_mels.dim() == 3:
|
||||
# Already (B, n_mels, T)
|
||||
pass
|
||||
elif audio_mels.dim() == 2:
|
||||
audio_mels = audio_mels.unsqueeze(0)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
audio_mels = flatten_bn(audio_mels, concat=True)
|
||||
|
||||
if isinstance(audio_lens, torch.Tensor):
|
||||
if audio_lens.dim() >= 2:
|
||||
audio_lens = audio_lens.flatten(0, 1)
|
||||
elif audio_lens.dim() == 0:
|
||||
audio_lens = audio_lens.unsqueeze(0)
|
||||
# dim == 1 is already fine
|
||||
else:
|
||||
audio_lens = flatten_bn(audio_lens, concat=True)
|
||||
|
||||
expected_n_mels = 128
|
||||
if audio_mels.ndim == 3 and audio_mels.size(1) != expected_n_mels:
|
||||
return None
|
||||
|
||||
return StepAudio2AudioInputs(
|
||||
audio_mels=audio_mels.to(self.dtype).to(self.device),
|
||||
audio_lens=audio_lens,
|
||||
)
|
||||
|
||||
def _process_audio_input(self, audio_input: StepAudio2AudioInputs) -> tuple[torch.Tensor, ...]:
|
||||
"""Process audio mels through encoder and adapter"""
|
||||
audio_mels = audio_input["audio_mels"]
|
||||
audio_lens = audio_input["audio_lens"]
|
||||
if not isinstance(audio_lens, torch.Tensor):
|
||||
audio_lens = torch.tensor(audio_lens, device=self.device)
|
||||
else:
|
||||
audio_lens = audio_lens.to(self.device)
|
||||
|
||||
audio_features, audio_lens = self.encoder(audio_mels, audio_lens)
|
||||
|
||||
audio_features = self.adapter(audio_features)
|
||||
|
||||
audio_feature_lens = (audio_lens - 1) // 2 + 1
|
||||
|
||||
audio_feature_list = [audio_features[i, : audio_feature_lens[i]] for i in range(audio_features.size(0))]
|
||||
|
||||
return audio_feature_list
|
||||
|
||||
def get_multimodal_embeddings(self, **kwargs) -> NestedTensors | None:
|
||||
"""Get multimodal embeddings"""
|
||||
audio_input = self._parse_and_validate_audio_input(**kwargs)
|
||||
if audio_input is None:
|
||||
return None
|
||||
else:
|
||||
audio_embeddings = self._process_audio_input(audio_input)
|
||||
return audio_embeddings
|
||||
|
||||
def embed_multimodal(self, **kwargs: object) -> NestedTensors:
|
||||
"""vLLM multimodal encoder entrypoint."""
|
||||
audio_embeddings = self.get_multimodal_embeddings(**kwargs)
|
||||
if audio_embeddings is None:
|
||||
return []
|
||||
return audio_embeddings
|
||||
|
||||
def get_input_embeddings(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
multimodal_embeddings: NestedTensors | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Get input embeddings with multimodal fusion"""
|
||||
# Under newer vLLM versions the registered LM wrapper exposes
|
||||
# get_input_embeddings(), while the inner `.model` may be a bare
|
||||
# Qwen2Model without that compatibility method.
|
||||
inputs_embeds = self.language_model.embed_input_ids(input_ids)
|
||||
if multimodal_embeddings is not None:
|
||||
inputs_embeds = _merge_multimodal_embeddings(
|
||||
input_ids, inputs_embeds, multimodal_embeddings, STEP_AUDIO2_AUDIO_PATCH_TOKEN_ID
|
||||
)
|
||||
return inputs_embeds
|
||||
|
||||
def get_language_model(self) -> nn.Module:
|
||||
return self.language_model
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
**kwargs: object,
|
||||
):
|
||||
"""Forward pass - returns OmniOutput for multi-stage pipeline"""
|
||||
if intermediate_tensors is not None:
|
||||
inputs_embeds = None
|
||||
elif inputs_embeds is None:
|
||||
audio_embeddings = self.get_multimodal_embeddings(**kwargs)
|
||||
inputs_embeds = self.get_input_embeddings(input_ids, audio_embeddings)
|
||||
input_ids = None
|
||||
|
||||
hidden_states = self.language_model(
|
||||
input_ids,
|
||||
positions,
|
||||
intermediate_tensors,
|
||||
inputs_embeds=inputs_embeds,
|
||||
)
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=hidden_states,
|
||||
multimodal_outputs={},
|
||||
)
|
||||
|
||||
def compute_logits(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
) -> torch.Tensor | None:
|
||||
return self.language_model.compute_logits(hidden_states)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Load weights with name mapping from HuggingFace to vLLM structure"""
|
||||
from vllm.model_executor.models.utils import AutoWeightsLoader
|
||||
|
||||
# Map HuggingFace weight names to our model structure
|
||||
# HF: model.*, lm_head.* → Our: language_model.model.*, language_model.lm_head.*
|
||||
# HF: encoder.*, adapter.* → Our: encoder.*, adapter.* (no change)
|
||||
def weight_mapper(name: str) -> str:
|
||||
if name.startswith("lm_head."):
|
||||
return name.replace("lm_head.", "language_model.lm_head.")
|
||||
elif name.startswith("model."):
|
||||
return name.replace("model.", "language_model.model.")
|
||||
else:
|
||||
# encoder.* and adapter.* remain unchanged
|
||||
return name
|
||||
|
||||
# Apply mapping to weights
|
||||
mapped_weights = [(weight_mapper(name), tensor) for name, tensor in weights]
|
||||
|
||||
loader = AutoWeightsLoader(self)
|
||||
return loader.load_weights(mapped_weights)
|
||||
|
||||
@staticmethod
|
||||
def separate_tokens(
|
||||
token_ids: list[int],
|
||||
text_max: int = DEFAULT_TOKEN_CONFIG.text_max,
|
||||
audio_start: int = DEFAULT_TOKEN_CONFIG.audio_start,
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""Separate generated tokens into text and audio tokens"""
|
||||
text_tokens = [tid for tid in token_ids if tid < text_max]
|
||||
audio_tokens = [tid - audio_start for tid in token_ids if tid >= audio_start]
|
||||
return text_tokens, audio_tokens
|
||||
|
||||
@staticmethod
|
||||
def has_audio_output(token_ids: list[int], audio_start: int = DEFAULT_TOKEN_CONFIG.audio_start) -> bool:
|
||||
"""Check if generated tokens contain audio tokens"""
|
||||
return any(tid >= audio_start for tid in token_ids)
|
||||
|
||||
|
||||
class StepAudio2OutputProcessor:
|
||||
"""Helper class to process Step-Audio2 outputs"""
|
||||
|
||||
@staticmethod
|
||||
def process_output(output_ids: torch.Tensor, tokenizer, remove_audio_padding: bool = True) -> dict:
|
||||
"""Process model output and separate text/audio tokens"""
|
||||
if isinstance(output_ids, torch.Tensor):
|
||||
output_ids = output_ids.squeeze().tolist()
|
||||
|
||||
# Separate tokens
|
||||
text_tokens, audio_tokens = StepAudio2ThinkerForConditionalGeneration.separate_tokens(output_ids)
|
||||
|
||||
# Remove audio padding if requested
|
||||
if remove_audio_padding and audio_tokens:
|
||||
audio_tokens = [t for t in audio_tokens if t < DEFAULT_TOKEN_CONFIG.audio_eos]
|
||||
|
||||
# Decode text
|
||||
text = tokenizer.decode(text_tokens, skip_special_tokens=False)
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"text_tokens": text_tokens,
|
||||
"audio_tokens": audio_tokens,
|
||||
"has_audio": len(audio_tokens) > 0,
|
||||
"all_tokens": output_ids,
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import io
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import librosa # noqa: TID251
|
||||
import numpy as np
|
||||
import onnxruntime
|
||||
import s3tokenizer
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torchaudio
|
||||
import torchaudio.compliance.kaldi as kaldi
|
||||
from flashcosyvoice.modules.hifigan import HiFTGenerator
|
||||
from flashcosyvoice.utils.audio import mel_spectrogram
|
||||
from hyperpyyaml import load_hyperpyyaml
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.models.interfaces import SupportsPP
|
||||
from vllm.model_executor.models.utils import WeightsMapper
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.outputs import SamplerOutput
|
||||
from vllm.v1.sample.metadata import SamplingMetadata
|
||||
from vllm.v1.sample.sampler import Sampler
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.model_executor.models.step_audio2.step_audio2_constants import (
|
||||
DEFAULT_STREAM_CONFIG,
|
||||
DEFAULT_TOKEN2WAV_CONFIG,
|
||||
STEP_AUDIO2_DEFAULT_PROMPT_WAV,
|
||||
STREAM_SOURCE_CACHE_LEN,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def fade_in_out(
|
||||
fade_in_mel: torch.Tensor,
|
||||
fade_out_mel: torch.Tensor,
|
||||
window: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Cross-fade two overlapping waveform segments using a Hamming window.
|
||||
|
||||
The window is split in half: the first half ramps *up* (fade-in) and
|
||||
the second half ramps *down* (fade-out). The overlap region of
|
||||
``fade_in_mel`` is blended with the tail of ``fade_out_mel``.
|
||||
"""
|
||||
mel_overlap_len = int(window.shape[0] / 2)
|
||||
fade_in_mel = fade_in_mel.clone()
|
||||
fade_in_mel[..., :mel_overlap_len] = (
|
||||
fade_in_mel[..., :mel_overlap_len] * window[:mel_overlap_len]
|
||||
+ fade_out_mel[..., -mel_overlap_len:] * window[mel_overlap_len:]
|
||||
)
|
||||
return fade_in_mel
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamState:
|
||||
"""Per-request streaming state for concurrent Token2Wav sessions."""
|
||||
|
||||
setup_done: bool = False
|
||||
stream_cache: dict | None = None
|
||||
hift_cache_dict: dict = field(default_factory=dict)
|
||||
finished: bool = False
|
||||
|
||||
|
||||
class StepAudio2Token2WavCore(nn.Module):
|
||||
"""Core Token2Wav model - handles token to waveform conversion"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str,
|
||||
float16: bool = False,
|
||||
device: str = "cuda",
|
||||
n_timesteps: int = DEFAULT_TOKEN2WAV_CONFIG.n_timesteps,
|
||||
):
|
||||
super().__init__()
|
||||
self.model_path = model_path
|
||||
self.float16 = float16
|
||||
self.device = torch.device(device)
|
||||
self.n_timesteps = n_timesteps
|
||||
|
||||
self._models_loaded = False
|
||||
self._audio_tokenizer = None
|
||||
self._spk_model = None
|
||||
self._flow = None
|
||||
self._hift = None
|
||||
|
||||
self.cache = {}
|
||||
|
||||
# Streaming state (constants from centralised config)
|
||||
self.mel_cache_len = DEFAULT_STREAM_CONFIG.mel_cache_len
|
||||
self.source_cache_len = STREAM_SOURCE_CACHE_LEN
|
||||
self.speech_window: torch.Tensor | None = None # created lazily on device
|
||||
self.stream_cache: dict | None = None # flow model causal cache
|
||||
self.hift_cache_dict: dict = {} # HiFT vocoder overlap cache
|
||||
|
||||
def _ensure_models_loaded(self):
|
||||
"""Lazy load models on first use"""
|
||||
if self._models_loaded:
|
||||
return
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
logger.info(f"Loading Token2Wav models from: {self.model_path}")
|
||||
|
||||
self._audio_tokenizer = (
|
||||
s3tokenizer.load_model(f"{self.model_path}/speech_tokenizer_v2_25hz.onnx").to(self.device).eval()
|
||||
)
|
||||
|
||||
option = onnxruntime.SessionOptions()
|
||||
option.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
option.intra_op_num_threads = 1
|
||||
self._spk_model = onnxruntime.InferenceSession(
|
||||
f"{self.model_path}/campplus.onnx", sess_options=option, providers=["CPUExecutionProvider"]
|
||||
)
|
||||
|
||||
with open(f"{self.model_path}/flow.yaml") as f:
|
||||
configs = load_hyperpyyaml(f)
|
||||
self._flow = configs["flow"]
|
||||
if self.float16:
|
||||
self._flow.half()
|
||||
self._flow.load_state_dict(
|
||||
torch.load(f"{self.model_path}/flow.pt", map_location="cpu", weights_only=True), strict=True
|
||||
)
|
||||
self._flow.to(self.device).eval()
|
||||
|
||||
self._hift = HiFTGenerator()
|
||||
hift_state_dict = {
|
||||
k.replace("generator.", ""): v
|
||||
for k, v in torch.load(f"{self.model_path}/hift.pt", map_location="cpu", weights_only=True).items()
|
||||
}
|
||||
self._hift.load_state_dict(hift_state_dict, strict=True)
|
||||
self._hift.to(self.device).eval()
|
||||
|
||||
self._models_loaded = True
|
||||
logger.info("Token2Wav models loaded successfully")
|
||||
|
||||
@property
|
||||
def audio_tokenizer(self):
|
||||
"""Lazy-loaded audio tokenizer"""
|
||||
self._ensure_models_loaded()
|
||||
return self._audio_tokenizer
|
||||
|
||||
@property
|
||||
def spk_model(self):
|
||||
"""Lazy-loaded speaker model"""
|
||||
self._ensure_models_loaded()
|
||||
return self._spk_model
|
||||
|
||||
@property
|
||||
def flow(self):
|
||||
"""Lazy-loaded flow model"""
|
||||
self._ensure_models_loaded()
|
||||
return self._flow
|
||||
|
||||
@property
|
||||
def hift(self):
|
||||
"""Lazy-loaded HiFT vocoder"""
|
||||
self._ensure_models_loaded()
|
||||
return self._hift
|
||||
|
||||
def _prepare_prompt(self, prompt_wav: str):
|
||||
"""Prepare prompt audio for conditioning"""
|
||||
# Prefer soundfile/librosa path to avoid torchaudio->torchcodec runtime coupling.
|
||||
try:
|
||||
audio_np_raw, sample_rate = sf.read(prompt_wav, dtype="float32", always_2d=False)
|
||||
except Exception:
|
||||
audio_np_raw, sample_rate = librosa.load(prompt_wav, sr=None, mono=True)
|
||||
if isinstance(audio_np_raw, np.ndarray) and audio_np_raw.ndim > 1:
|
||||
audio_np_raw = np.mean(audio_np_raw, axis=-1)
|
||||
sample_rate = int(sample_rate)
|
||||
|
||||
# 16 kHz branch: s3tokenizer + speaker embedding
|
||||
audio_16k = audio_np_raw.astype(np.float32)
|
||||
if sample_rate != 16000:
|
||||
audio_16k = librosa.resample(y=audio_16k, orig_sr=sample_rate, target_sr=16000)
|
||||
audio = torch.from_numpy(audio_16k)
|
||||
mels = s3tokenizer.log_mel_spectrogram(audio)
|
||||
mels, mels_lens = s3tokenizer.padding([mels])
|
||||
prompt_speech_tokens, prompt_speech_tokens_lens = self.audio_tokenizer.quantize(
|
||||
mels.to(self.device), mels_lens.to(self.device)
|
||||
)
|
||||
|
||||
spk_feat = kaldi.fbank(audio.unsqueeze(0), num_mel_bins=80, dither=0, sample_frequency=16000)
|
||||
spk_feat = spk_feat - spk_feat.mean(dim=0, keepdim=True)
|
||||
spk_emb = torch.tensor(
|
||||
self.spk_model.run(None, {self.spk_model.get_inputs()[0].name: spk_feat.unsqueeze(dim=0).cpu().numpy()})[0],
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
# 24 kHz branch: mel spectrogram for flow model conditioning
|
||||
# Must resample from the ORIGINAL audio, not the 16 kHz version.
|
||||
audio_24k = audio_np_raw.astype(np.float32)
|
||||
if sample_rate != 24000:
|
||||
audio_24k = librosa.resample(y=audio_24k, orig_sr=sample_rate, target_sr=24000)
|
||||
audio_24k_t = torch.from_numpy(audio_24k).unsqueeze(0) # [1, T]
|
||||
prompt_mel = mel_spectrogram(audio_24k_t).transpose(1, 2).squeeze(0) # [T, num_mels]
|
||||
prompt_mels = prompt_mel.unsqueeze(0).to(self.device)
|
||||
prompt_mels_lens = torch.tensor([prompt_mels.shape[1]], dtype=torch.int32, device=self.device)
|
||||
prompt_mels = torch.nn.functional.pad(
|
||||
prompt_mels,
|
||||
(0, 0, 0, prompt_speech_tokens.shape[1] * self.flow.up_rate - prompt_mels.shape[1]),
|
||||
mode="replicate",
|
||||
)
|
||||
|
||||
return prompt_speech_tokens, prompt_speech_tokens_lens, spk_emb, prompt_mels, prompt_mels_lens
|
||||
|
||||
def forward(
|
||||
self, generated_speech_tokens: list, prompt_wav: str, return_bytes: bool = True
|
||||
) -> torch.Tensor | bytes:
|
||||
"""
|
||||
Convert audio tokens to waveform
|
||||
|
||||
Args:
|
||||
generated_speech_tokens: List of audio token IDs (0-6560)
|
||||
prompt_wav: Path to prompt wav file for speaker conditioning
|
||||
return_bytes: If True, return WAV bytes; else return tensor
|
||||
|
||||
Returns:
|
||||
Audio waveform as bytes or tensor
|
||||
"""
|
||||
if prompt_wav not in self.cache:
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
prompt_speech_tokens, prompt_speech_tokens_lens, spk_emb, prompt_mels, prompt_mels_lens = self.cache[prompt_wav]
|
||||
|
||||
generated_speech_tokens = torch.tensor([generated_speech_tokens], dtype=torch.int32, device=self.device)
|
||||
generated_speech_tokens_lens = torch.tensor(
|
||||
[generated_speech_tokens.shape[1]], dtype=torch.int32, device=self.device
|
||||
)
|
||||
|
||||
with torch.amp.autocast(str(self.device.type), dtype=torch.float16 if self.float16 else torch.float32):
|
||||
mel = self.flow.inference(
|
||||
generated_speech_tokens,
|
||||
generated_speech_tokens_lens,
|
||||
prompt_speech_tokens,
|
||||
prompt_speech_tokens_lens,
|
||||
prompt_mels,
|
||||
prompt_mels_lens,
|
||||
spk_emb,
|
||||
self.n_timesteps,
|
||||
)
|
||||
|
||||
wav, _ = self.hift(speech_feat=mel)
|
||||
|
||||
if return_bytes:
|
||||
output = io.BytesIO()
|
||||
torchaudio.save(output, wav.cpu(), sample_rate=DEFAULT_TOKEN2WAV_CONFIG.sample_rate, format="wav")
|
||||
return output.getvalue()
|
||||
else:
|
||||
return wav
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Streaming (chunked) inference
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def setup_stream(self, prompt_wav: str) -> None:
|
||||
"""Initialize flow + HiFT caches for chunked streaming inference.
|
||||
|
||||
Must be called once before the first ``stream_chunk()`` call for a
|
||||
given utterance.
|
||||
"""
|
||||
if prompt_wav not in self.cache:
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
(
|
||||
prompt_speech_tokens,
|
||||
_,
|
||||
spk_emb,
|
||||
prompt_mels,
|
||||
_,
|
||||
) = self.cache[prompt_wav]
|
||||
|
||||
# Lazily create the Hamming window on the correct device
|
||||
if self.speech_window is None or self.speech_window.device != self.device:
|
||||
self.speech_window = torch.from_numpy(np.hamming(2 * self.source_cache_len)).to(
|
||||
device=self.device, dtype=torch.float32
|
||||
)
|
||||
|
||||
# Flow model: setup causal cache with prompt tokens + lookahead
|
||||
pre_lookahead = DEFAULT_STREAM_CONFIG.pre_lookahead_len
|
||||
self.stream_cache = self.flow.setup_cache(
|
||||
torch.cat([prompt_speech_tokens, prompt_speech_tokens[:, :pre_lookahead]], dim=1),
|
||||
prompt_mels,
|
||||
spk_emb,
|
||||
n_timesteps=self.n_timesteps,
|
||||
)
|
||||
|
||||
# HiFT vocoder: empty overlap buffers
|
||||
self.hift_cache_dict = {
|
||||
"mel": torch.zeros(1, prompt_mels.shape[2], 0, device=self.device),
|
||||
"source": torch.zeros(1, 1, 0, device=self.device),
|
||||
"speech": torch.zeros(1, 0, device=self.device),
|
||||
}
|
||||
|
||||
def stream_chunk(
|
||||
self,
|
||||
audio_tokens: list[int],
|
||||
prompt_wav: str,
|
||||
last_chunk: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""Process one chunk of audio tokens and return a waveform segment.
|
||||
|
||||
Args:
|
||||
audio_tokens: Token IDs for this chunk (includes lookahead for
|
||||
non-last chunks).
|
||||
prompt_wav: Path to the prompt wav (for cached speaker embeddings).
|
||||
last_chunk: Whether this is the final chunk of the utterance.
|
||||
|
||||
Returns:
|
||||
1-D float tensor of audio samples (24 kHz).
|
||||
"""
|
||||
if self.stream_cache is None:
|
||||
raise ValueError("stream_cache not initialised – call setup_stream() first")
|
||||
|
||||
if prompt_wav not in self.cache:
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
_, _, spk_emb, prompt_mels, _ = self.cache[prompt_wav]
|
||||
|
||||
token_tensor = torch.tensor([audio_tokens], dtype=torch.int32, device=self.device)
|
||||
|
||||
with torch.amp.autocast(
|
||||
str(self.device.type),
|
||||
dtype=torch.float16 if self.float16 else torch.float32,
|
||||
):
|
||||
chunk_mel, self.stream_cache = self.flow.inference_chunk(
|
||||
token=token_tensor,
|
||||
spk=spk_emb,
|
||||
cache=self.stream_cache,
|
||||
last_chunk=last_chunk,
|
||||
n_timesteps=self.n_timesteps,
|
||||
)
|
||||
|
||||
# Trim estimator attention cache to avoid unbounded growth
|
||||
keep = DEFAULT_STREAM_CONFIG.estimator_cache_keep
|
||||
est_att = self.stream_cache.get("estimator_att_cache")
|
||||
if est_att is not None and est_att.shape[4] > (prompt_mels.shape[1] + keep):
|
||||
self.stream_cache["estimator_att_cache"] = torch.cat(
|
||||
[est_att[:, :, :, :, : prompt_mels.shape[1]], est_att[:, :, :, :, -keep:]],
|
||||
dim=4,
|
||||
)
|
||||
|
||||
# ---- HiFT vocoder with overlap-add ----
|
||||
hift_cache_mel = self.hift_cache_dict["mel"]
|
||||
hift_cache_source = self.hift_cache_dict["source"]
|
||||
hift_cache_speech = self.hift_cache_dict["speech"]
|
||||
|
||||
mel = torch.cat([hift_cache_mel, chunk_mel], dim=2)
|
||||
speech, source = self.hift(mel, hift_cache_source)
|
||||
|
||||
# Cross-fade overlap region for smooth concatenation
|
||||
if hift_cache_speech.shape[-1] > 0:
|
||||
speech = fade_in_out(speech, hift_cache_speech, self.speech_window)
|
||||
|
||||
# Update vocoder cache for the next chunk
|
||||
self.hift_cache_dict = {
|
||||
"mel": mel[..., -self.mel_cache_len :].clone().detach(),
|
||||
"source": source[:, :, -self.source_cache_len :].clone().detach(),
|
||||
"speech": speech[:, -self.source_cache_len :].clone().detach(),
|
||||
}
|
||||
|
||||
# For non-last chunks, trim the tail that will be re-synthesised
|
||||
if not last_chunk:
|
||||
speech = speech[:, : -self.source_cache_len]
|
||||
|
||||
return speech.squeeze(0) # [samples]
|
||||
|
||||
def reset_stream(self) -> None:
|
||||
"""Clear all streaming state after an utterance is complete."""
|
||||
self.stream_cache = None
|
||||
self.hift_cache_dict = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-request streaming (does NOT mutate self – uses external state)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def setup_stream_for(self, prompt_wav: str, state: _StreamState) -> None:
|
||||
"""Initialise flow + HiFT caches into *state* (no self mutation)."""
|
||||
if prompt_wav not in self.cache:
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
prompt_speech_tokens, _, spk_emb, prompt_mels, _ = self.cache[prompt_wav]
|
||||
|
||||
if self.speech_window is None or self.speech_window.device != self.device:
|
||||
self.speech_window = torch.from_numpy(np.hamming(2 * self.source_cache_len)).to(
|
||||
device=self.device, dtype=torch.float32
|
||||
)
|
||||
|
||||
pre_lookahead = DEFAULT_STREAM_CONFIG.pre_lookahead_len
|
||||
state.stream_cache = self.flow.setup_cache(
|
||||
torch.cat(
|
||||
[prompt_speech_tokens, prompt_speech_tokens[:, :pre_lookahead]],
|
||||
dim=1,
|
||||
),
|
||||
prompt_mels,
|
||||
spk_emb,
|
||||
n_timesteps=self.n_timesteps,
|
||||
)
|
||||
state.hift_cache_dict = {
|
||||
"mel": torch.zeros(1, prompt_mels.shape[2], 0, device=self.device),
|
||||
"source": torch.zeros(1, 1, 0, device=self.device),
|
||||
"speech": torch.zeros(1, 0, device=self.device),
|
||||
}
|
||||
state.setup_done = True
|
||||
|
||||
def stream_chunk_for(
|
||||
self,
|
||||
audio_tokens: list[int],
|
||||
prompt_wav: str,
|
||||
last_chunk: bool,
|
||||
state: _StreamState,
|
||||
) -> torch.Tensor:
|
||||
"""Process one chunk using *state* (no self mutation except speech_window)."""
|
||||
if state.stream_cache is None:
|
||||
raise ValueError("stream_cache not initialised – call setup_stream_for() first")
|
||||
|
||||
if prompt_wav not in self.cache:
|
||||
self.cache[prompt_wav] = self._prepare_prompt(prompt_wav)
|
||||
_, _, spk_emb, prompt_mels, _ = self.cache[prompt_wav]
|
||||
|
||||
token_tensor = torch.tensor([audio_tokens], dtype=torch.int32, device=self.device)
|
||||
|
||||
with torch.amp.autocast(
|
||||
str(self.device.type),
|
||||
dtype=torch.float16 if self.float16 else torch.float32,
|
||||
):
|
||||
chunk_mel, state.stream_cache = self.flow.inference_chunk(
|
||||
token=token_tensor,
|
||||
spk=spk_emb,
|
||||
cache=state.stream_cache,
|
||||
last_chunk=last_chunk,
|
||||
n_timesteps=self.n_timesteps,
|
||||
)
|
||||
|
||||
# Trim estimator attention cache to avoid unbounded growth
|
||||
keep = DEFAULT_STREAM_CONFIG.estimator_cache_keep
|
||||
est_att = state.stream_cache.get("estimator_att_cache")
|
||||
if est_att is not None and est_att.shape[4] > (prompt_mels.shape[1] + keep):
|
||||
state.stream_cache["estimator_att_cache"] = torch.cat(
|
||||
[
|
||||
est_att[:, :, :, :, : prompt_mels.shape[1]],
|
||||
est_att[:, :, :, :, -keep:],
|
||||
],
|
||||
dim=4,
|
||||
)
|
||||
|
||||
# ---- HiFT vocoder with overlap-add ----
|
||||
hift_cache_mel = state.hift_cache_dict["mel"]
|
||||
hift_cache_source = state.hift_cache_dict["source"]
|
||||
hift_cache_speech = state.hift_cache_dict["speech"]
|
||||
|
||||
mel = torch.cat([hift_cache_mel, chunk_mel], dim=2)
|
||||
speech, source = self.hift(mel, hift_cache_source)
|
||||
|
||||
if hift_cache_speech.shape[-1] > 0:
|
||||
speech = fade_in_out(speech, hift_cache_speech, self.speech_window)
|
||||
|
||||
state.hift_cache_dict = {
|
||||
"mel": mel[..., -self.mel_cache_len :].clone().detach(),
|
||||
"source": source[:, :, -self.source_cache_len :].clone().detach(),
|
||||
"speech": speech[:, -self.source_cache_len :].clone().detach(),
|
||||
}
|
||||
|
||||
if not last_chunk:
|
||||
speech = speech[:, : -self.source_cache_len]
|
||||
|
||||
return speech.squeeze(0)
|
||||
|
||||
@staticmethod
|
||||
def reset_stream_for(state: _StreamState) -> None:
|
||||
"""Clear streaming caches for the given state."""
|
||||
state.stream_cache = None
|
||||
state.hift_cache_dict = {}
|
||||
state.setup_done = False
|
||||
state.finished = True
|
||||
|
||||
|
||||
class StepAudio2Token2WavForConditionalGeneration(nn.Module, SupportsPP):
|
||||
"""vLLM-compatible wrapper for Step-Audio2 Token2Wav"""
|
||||
|
||||
hf_to_vllm_mapper = WeightsMapper(
|
||||
orig_to_new_prefix={
|
||||
"model.": "token2wav.",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.vllm_config = vllm_config
|
||||
self.config = vllm_config.model_config.hf_config
|
||||
|
||||
model_path = getattr(self.config, "token2wav_path", None)
|
||||
model_name_or_path = vllm_config.model_config.model
|
||||
if model_path is None:
|
||||
# Resolve HF repo names to local cache path
|
||||
if not os.path.isdir(model_name_or_path):
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
model_name_or_path = snapshot_download(model_name_or_path)
|
||||
model_path = f"{model_name_or_path}/token2wav"
|
||||
|
||||
float16 = getattr(self.config, "token2wav_float16", False)
|
||||
|
||||
device = "cuda"
|
||||
if hasattr(vllm_config, "device_config") and vllm_config.device_config:
|
||||
device = str(vllm_config.device_config.device)
|
||||
|
||||
self.token2wav = StepAudio2Token2WavCore(model_path=model_path, float16=float16, device=device)
|
||||
|
||||
self.have_multimodal_outputs = True
|
||||
# Required for the runner to pass async_chunk info via
|
||||
# runtime_additional_information on every forward step.
|
||||
self.enable_update_additional_information = True
|
||||
|
||||
# Per-request streaming states (ordered list matching batch order).
|
||||
self._stream_states: list[_StreamState] = []
|
||||
|
||||
# Resolve default prompt wav path from model directory.
|
||||
# Priority: env var > {model_dir}/assets/default_female.wav > bare filename (cwd)
|
||||
self._default_prompt_wav = self._resolve_prompt_wav(model_name_or_path)
|
||||
|
||||
self.make_empty_intermediate_tensors = lambda: None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_prompt_wav(model_root: str) -> str:
|
||||
"""Find the default prompt wav, searching model assets directory."""
|
||||
env_val = os.environ.get("STEP_AUDIO2_DEFAULT_PROMPT_WAV")
|
||||
if env_val and os.path.exists(env_val):
|
||||
return env_val
|
||||
# Search common locations: model dir, then bundled assets in this package
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
candidates = [
|
||||
os.path.join(model_root, "assets", STEP_AUDIO2_DEFAULT_PROMPT_WAV),
|
||||
os.path.join(model_root, STEP_AUDIO2_DEFAULT_PROMPT_WAV),
|
||||
os.path.join(pkg_dir, "assets", STEP_AUDIO2_DEFAULT_PROMPT_WAV),
|
||||
os.path.join(pkg_dir, STEP_AUDIO2_DEFAULT_PROMPT_WAV),
|
||||
STEP_AUDIO2_DEFAULT_PROMPT_WAV,
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
# Return the env var (even if not found) or the bare filename as fallback
|
||||
return env_val or STEP_AUDIO2_DEFAULT_PROMPT_WAV
|
||||
|
||||
def get_language_model(self) -> torch.nn.Module:
|
||||
# Return the wrapper so vLLM interface helpers resolve
|
||||
# embed_input_ids() on this adapter instead of the bare core model.
|
||||
return self
|
||||
|
||||
@property
|
||||
def sampler(self):
|
||||
return Sampler()
|
||||
|
||||
def embed_input_ids(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
multimodal_embeddings=None,
|
||||
is_multimodal=None,
|
||||
) -> torch.Tensor:
|
||||
hidden_size = self.vllm_config.model_config.get_hidden_size()
|
||||
return torch.zeros_like(input_ids).reshape(-1, 1).repeat(1, hidden_size)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
additional: dict | None = None,
|
||||
runtime_additional_information: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> torch.Tensor | bytes:
|
||||
"""
|
||||
Forward pass for Token2Wav
|
||||
|
||||
Args:
|
||||
input_ids: Audio token IDs (from stage input processor)
|
||||
positions: Position IDs (unused)
|
||||
intermediate_tensors: Intermediate tensors (unused)
|
||||
inputs_embeds: Input embeddings (unused)
|
||||
additional: Additional information dict (deprecated)
|
||||
runtime_additional_information: Per-request dicts from the
|
||||
async_chunk processor. Each dict contains ``audio_tokens``
|
||||
(list[int]) and ``last_chunk`` (bool).
|
||||
**kwargs: Other kwargs
|
||||
|
||||
Returns:
|
||||
OmniOutput with waveform tensor
|
||||
"""
|
||||
# ----- async_chunk streaming path -----
|
||||
# Only enter when at least one entry carries ``left_context_size``
|
||||
# (the field the framework forwards from the async_chunk payload).
|
||||
# The runner always passes runtime_additional_information (possibly
|
||||
# [{}]), so a bare truthiness check would incorrectly enter this
|
||||
# branch in synchronous mode.
|
||||
if runtime_additional_information and any(
|
||||
"left_context_size" in info for info in runtime_additional_information
|
||||
):
|
||||
return self._forward_async_chunk(input_ids, runtime_additional_information, **kwargs)
|
||||
|
||||
# ----- profiling / warmup guard -----
|
||||
# Profile/warmup passes use all-zero token IDs. Real audio tokens
|
||||
# are non-zero, so check sum == 0 rather than a length threshold
|
||||
# (short utterances may have fewer than 50 tokens).
|
||||
if isinstance(input_ids, torch.Tensor):
|
||||
is_profile_mode = input_ids.numel() == 0 or input_ids.sum().item() == 0
|
||||
else:
|
||||
is_profile_mode = not input_ids if not hasattr(input_ids, "__len__") else len(input_ids) == 0
|
||||
|
||||
if is_profile_mode:
|
||||
device = torch.device("cpu")
|
||||
if isinstance(inputs_embeds, torch.Tensor) and inputs_embeds.numel() > 0:
|
||||
device = inputs_embeds.device
|
||||
elif isinstance(input_ids, torch.Tensor) and input_ids.numel() > 0:
|
||||
device = input_ids.device
|
||||
|
||||
dummy_audio = torch.zeros(1000, device=device, dtype=torch.float32)
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": [dummy_audio]},
|
||||
)
|
||||
|
||||
# ----- synchronous (full-sequence) path -----
|
||||
if isinstance(input_ids, torch.Tensor):
|
||||
if input_ids.dim() == 1:
|
||||
input_ids_list = input_ids.cpu().tolist()
|
||||
else:
|
||||
input_ids_list = input_ids[0].cpu().tolist()
|
||||
else:
|
||||
input_ids_list = input_ids if isinstance(input_ids, list) else [input_ids]
|
||||
|
||||
prompt_wav = self._default_prompt_wav
|
||||
|
||||
if not os.path.exists(prompt_wav):
|
||||
raise FileNotFoundError(f"Token2Wav: prompt_wav file not found: {prompt_wav}")
|
||||
|
||||
waveform_tensor = self.token2wav(input_ids_list, prompt_wav=prompt_wav, return_bytes=False)
|
||||
|
||||
if waveform_tensor.dim() == 2 and waveform_tensor.shape[0] == 1:
|
||||
waveform_tensor = waveform_tensor.squeeze(0)
|
||||
|
||||
waveform_tensor_cpu = waveform_tensor.detach().cpu().contiguous()
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": [waveform_tensor_cpu]},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async-chunk streaming forward
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _forward_async_chunk(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
runtime_additional_information: list[dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
"""Handle one async_chunk forward step (batch=1).
|
||||
|
||||
The end-of-stream flag is encoded as ``left_context_size == 1``
|
||||
(the only field the framework transparently forwards from the
|
||||
async_chunk payload to ``request.additional_information``).
|
||||
"""
|
||||
# --- batch=1 guard ---
|
||||
batch_size = sum(1 for info in runtime_additional_information if "left_context_size" in info)
|
||||
if batch_size != 1:
|
||||
raise RuntimeError(
|
||||
f"Token2Wav async_chunk only supports batch=1, got {batch_size}. "
|
||||
"Batch>1 requires framework support for batch_req_ids."
|
||||
)
|
||||
|
||||
info = next(info for info in runtime_additional_information if "left_context_size" in info)
|
||||
last_chunk = info.get("left_context_size", 0) == 1
|
||||
|
||||
# --- Manage single stream state ---
|
||||
# If the previous request was preempted (setup_done but not finished),
|
||||
# its stale caches must be discarded before we start a new session.
|
||||
if self._stream_states and not self._stream_states[0].finished:
|
||||
state = self._stream_states[0]
|
||||
if state.setup_done and not last_chunk:
|
||||
# Heuristic: a non-last chunk arriving into an already-active
|
||||
# state that we did NOT set up this call means a new request
|
||||
# reused the slot. For a genuinely continuing request the
|
||||
# state is fine; we cannot distinguish without request IDs,
|
||||
# so we only forcibly reset on an explicit new-session signal
|
||||
# (setup_done=True but stream_cache already consumed all
|
||||
# previous data -- detected below when stream_chunk fails).
|
||||
pass
|
||||
if not self._stream_states or self._stream_states[0].finished:
|
||||
self._stream_states = [_StreamState()]
|
||||
state = self._stream_states[0]
|
||||
|
||||
# --- Extract audio tokens ---
|
||||
audio_tokens = input_ids.flatten().cpu().tolist()
|
||||
|
||||
# Empty chunk (e.g. EOF with no audio tokens)
|
||||
if not audio_tokens:
|
||||
if state.setup_done:
|
||||
self.token2wav.reset_stream_for(state)
|
||||
else:
|
||||
state.finished = True
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": [torch.zeros(1, dtype=torch.float32)]},
|
||||
)
|
||||
|
||||
prompt_wav = self._default_prompt_wav
|
||||
if not os.path.exists(prompt_wav):
|
||||
raise FileNotFoundError(f"Token2Wav: prompt_wav file not found: {prompt_wav}")
|
||||
|
||||
try:
|
||||
if not state.setup_done:
|
||||
logger.info("Token2Wav: initialising streaming caches")
|
||||
self.token2wav.setup_stream_for(prompt_wav, state)
|
||||
|
||||
waveform = self.token2wav.stream_chunk_for(
|
||||
audio_tokens,
|
||||
prompt_wav=prompt_wav,
|
||||
last_chunk=last_chunk,
|
||||
state=state,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token2Wav: stream error, resetting state")
|
||||
self.token2wav.reset_stream_for(state)
|
||||
raise
|
||||
|
||||
if last_chunk:
|
||||
self.token2wav.reset_stream_for(state)
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": [waveform.detach().cpu().contiguous()]},
|
||||
)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
|
||||
return hidden_states
|
||||
|
||||
def sample(
|
||||
self,
|
||||
logits: torch.Tensor,
|
||||
sampling_metadata: SamplingMetadata,
|
||||
) -> SamplerOutput | None:
|
||||
return None
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
loaded_params = set()
|
||||
for name, _ in self.token2wav.named_parameters():
|
||||
loaded_params.add(f"token2wav.{name}")
|
||||
for name, _ in self.token2wav.named_buffers():
|
||||
loaded_params.add(f"token2wav.{name}")
|
||||
return loaded_params
|
||||
|
||||
def load_weights_without_buffers(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
loaded_params = set()
|
||||
for name, _ in self.token2wav.named_parameters():
|
||||
loaded_params.add(f"token2wav.{name}")
|
||||
return loaded_params
|
||||
@@ -283,7 +283,8 @@ def _encode_raw_audio(
|
||||
pad = (padding_size, 0) if padding_mode == "left" else (0, padding_size)
|
||||
audio = torch.nn.functional.pad(audio, pad)
|
||||
|
||||
feat = tts.audio_vae.encode(audio.to(tts.device), encode_sr).cpu()
|
||||
vae_device = next(tts.audio_vae.parameters()).device
|
||||
feat = tts.audio_vae.encode(audio.to(vae_device), encode_sr).cpu()
|
||||
return feat.view(tts.audio_vae.latent_dim, -1, tts.patch_size).permute(1, 2, 0)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import replace
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
@@ -220,7 +220,7 @@ class VoxtralTTSForConditionalGeneration(
|
||||
|
||||
def tts_postprocess(self, hidden_states: torch.Tensor, multimodal_outputs: object, **info_dict: object | None):
|
||||
update_dict = {}
|
||||
if isinstance(multimodal_outputs, dict) and "audio" in multimodal_outputs:
|
||||
if isinstance(multimodal_outputs, Mapping) and "audio" in multimodal_outputs:
|
||||
assert self.post_process_idx < len(multimodal_outputs["audio"]), (
|
||||
f"Expect {self.post_process_idx=} < {len(multimodal_outputs['audio'])=}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Stage config for Step-Audio2 with vLLM-Omni
|
||||
#
|
||||
# Two-stage pipeline:
|
||||
# Stage 0 (Thinker): Audio understanding → Text + Audio tokens
|
||||
# Stage 1 (Token2Wav): Audio tokens → Waveform (24kHz)
|
||||
#
|
||||
# Hardware requirement: 1-2 GPUs (can run on single GPU with devices: "0" for both)
|
||||
|
||||
# Model configuration reference (for documentation)
|
||||
# Actual values are set in hf_overrides below
|
||||
#
|
||||
# Token configuration:
|
||||
# text_max: 151688 # Maximum text token ID (inclusive)
|
||||
# audio_start: 151696 # First audio token ID in absolute vocabulary
|
||||
# audio_vocab_size: 6562 # Total number of audio tokens
|
||||
# audio_eos: 6561 # Audio EOS token ID (relative to audio_start)
|
||||
# audio_patch_token_id: 151690 # <audio_patch> placeholder token ID
|
||||
#
|
||||
# Encoder configuration:
|
||||
# n_mels: 128 # Number of mel frequency bins
|
||||
# n_audio_ctx: 1500 # Audio context length
|
||||
# n_audio_state: 512 # Audio encoder hidden state dimension
|
||||
# n_audio_head: 8 # Number of attention heads
|
||||
# n_audio_layer: 6 # Number of encoder layers
|
||||
# kernel_size: 3 # Convolution kernel size for adapter
|
||||
# adapter_stride: 2 # Stride for adapter convolution
|
||||
|
||||
stage_args:
|
||||
# Stage 0: Thinker (Audio Understanding + Token Generation)
|
||||
- stage_id: 0
|
||||
runtime:
|
||||
process: true # Run in separate process
|
||||
devices: "0,1,2,3" # Use 3 GPUs for tensor parallelism
|
||||
max_batch_size: 1 # Single sample at a time
|
||||
engine_args:
|
||||
model_stage: step_audio2_thinker
|
||||
model_arch: StepAudio2ThinkerForConditionalGeneration
|
||||
tensor_parallel_size: 4 # Split model across 3 GPUs
|
||||
# NOTE: All default values are defined in step_audio2_constants.py (DEFAULT_MODEL_CONFIG)
|
||||
# Only override here if your model differs from the defaults
|
||||
#
|
||||
# Default values (for reference, from constants.py):
|
||||
# hidden_size: 4096 # Qwen2-7B (change to 1536 for 1.5B, 896 for 0.5B)
|
||||
# text_max: 151688
|
||||
# audio_start: 151696
|
||||
# audio_vocab_size: 6562
|
||||
# audio_eos: 6561
|
||||
# audio_patch_token_id: 151690
|
||||
# n_mels: 128
|
||||
# n_audio_ctx: 1500
|
||||
# n_audio_state: 512
|
||||
# n_audio_head: 8
|
||||
# n_audio_layer: 6
|
||||
# kernel_size: 3
|
||||
# adapter_stride: 2
|
||||
#
|
||||
# Uncomment and modify only if needed:
|
||||
# hidden_size: 4096
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.8
|
||||
enforce_eager: true # Required for now
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 8192
|
||||
engine_output_type: text # Outputs text + audio tokens
|
||||
is_comprehension: true # This stage does comprehension
|
||||
final_output: true # Can be final output (for ASR mode)
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.7
|
||||
top_p: 0.9
|
||||
top_k: -1
|
||||
max_tokens: 1024
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.05
|
||||
|
||||
# Stage 1: Token2Wav (Audio Synthesis)
|
||||
- stage_id: 1
|
||||
runtime:
|
||||
process: true
|
||||
devices: "3" # Use GPU 3 (separate from Stage 0's 0-2)
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: token2wav
|
||||
model_arch: StepAudio2Token2WavModel
|
||||
hf_overrides:
|
||||
architectures: ["StepAudio2Token2WavForConditionalGeneration"]
|
||||
worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
|
||||
gpu_memory_utilization: 0.3 # Token2Wav needs less memory
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 4096
|
||||
engine_output_type: audio # Outputs waveform
|
||||
engine_input_source: [0] # Takes input from Stage 0
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.step_audio2.thinker2token2wav
|
||||
final_output: true # Final output stage
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: false
|
||||
|
||||
# Runtime configuration
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1 # Wait for full input before triggering
|
||||
max_inflight: 1 # Process one at a time
|
||||
|
||||
edges:
|
||||
- from: 0 # Thinker → Token2Wav
|
||||
to: 1
|
||||
window_size: -1 # Wait for complete output from Thinker
|
||||
@@ -0,0 +1,35 @@
|
||||
# Stage config for Step-Audio2 ASR (speech-to-text only)
|
||||
#
|
||||
# Single-stage pipeline: only Thinker, no Token2Wav.
|
||||
# Use this for audio_to_text tasks where no audio output is needed.
|
||||
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: step_audio2_thinker
|
||||
model_arch: StepAudio2ThinkerForConditionalGeneration
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.8
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 2048
|
||||
tensor_parallel_size: 2
|
||||
engine_output_type: text
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.7
|
||||
top_p: 0.9
|
||||
top_k: -1
|
||||
max_tokens: 1024
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.05
|
||||
@@ -0,0 +1,78 @@
|
||||
# Stage config for Step-Audio2 with async_chunk support
|
||||
#
|
||||
# Two-stage pipeline with streaming between stages:
|
||||
# Stage 0 (Thinker): Audio understanding → Text + Audio tokens (streamed in chunks)
|
||||
# Stage 1 (Token2Wav): Audio tokens → Waveform (24kHz)
|
||||
#
|
||||
# Key difference from step_audio_2.yaml:
|
||||
# - async_chunk: true enables streaming from Thinker to Token2Wav
|
||||
# - Thinker uses custom_process_next_stage_input_func to send audio token chunks
|
||||
# - Token2Wav receives chunks incrementally instead of waiting for full output
|
||||
#
|
||||
# Hardware requirement: 1-2 GPUs (can run on single GPU with devices: "0" for both)
|
||||
|
||||
async_chunk: true
|
||||
stage_args:
|
||||
# Stage 0: Thinker (Audio Understanding + Token Generation)
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: step_audio2_thinker
|
||||
model_arch: StepAudio2ThinkerForConditionalGeneration
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.8
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 1024
|
||||
tensor_parallel_size: 2
|
||||
engine_output_type: latent
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.step_audio2.thinker2token2wav_async_chunk
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.7
|
||||
top_p: 0.9
|
||||
top_k: -1
|
||||
max_tokens: 1024
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.05
|
||||
|
||||
# Stage 1: Token2Wav (Audio Synthesis)
|
||||
- stage_id: 1
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "3"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: token2wav
|
||||
model_arch: StepAudio2Token2WavModel
|
||||
worker_type: generation
|
||||
hf_overrides:
|
||||
architectures: ["StepAudio2Token2WavForConditionalGeneration"]
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
gpu_memory_utilization: 0.3
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 2048
|
||||
engine_output_type: audio
|
||||
engine_input_source: [0]
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: false
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from contextlib import nullcontext
|
||||
from typing import Any
|
||||
|
||||
@@ -162,7 +163,7 @@ def text2flow(
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -198,12 +199,14 @@ def talker2code2wav_async_chunk(
|
||||
value = _to_cpu_tensor(info_embed.get(key))
|
||||
if value is not None:
|
||||
prompt_payload[key] = value
|
||||
if isinstance(pooling_output, dict):
|
||||
po_embed = pooling_output.get("embed", {}) if isinstance(pooling_output.get("embed"), dict) else {}
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
mm_embed = multimodal_output.get("embed", {})
|
||||
if not isinstance(mm_embed, Mapping):
|
||||
mm_embed = multimodal_output
|
||||
for key in cond_keys:
|
||||
if key in prompt_payload:
|
||||
continue
|
||||
value = _to_cpu_tensor(po_embed.get(key))
|
||||
value = _to_cpu_tensor(mm_embed.get(key))
|
||||
if value is not None:
|
||||
prompt_payload[key] = value
|
||||
# Drop any right-padding carried from batched talker emission so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Stage input processor for Fish Speech S2 Pro: Slow AR → DAC Decoder."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -91,14 +92,14 @@ def _select_backlog_chunk_size(
|
||||
return backlog_chunk_size
|
||||
|
||||
|
||||
def _extract_last_frame(pooling_output: dict[str, Any]) -> torch.Tensor | None:
|
||||
"""Extract the last frame of audio codes from the pooling output."""
|
||||
audio_codes = pooling_output.get("audio_codes")
|
||||
def _extract_last_frame(multimodal_output: dict[str, Any]) -> torch.Tensor | None:
|
||||
"""Extract the last frame of audio codes from the multimodal output."""
|
||||
audio_codes = multimodal_output.get("audio_codes")
|
||||
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
|
||||
return None
|
||||
if audio_codes.ndim == 2:
|
||||
frame = audio_codes[-1]
|
||||
valid = pooling_output.get("audio_code_valid")
|
||||
valid = multimodal_output.get("audio_code_valid")
|
||||
if isinstance(valid, torch.Tensor) and valid.numel() > 0:
|
||||
is_valid = bool(valid.reshape(-1)[-1].item())
|
||||
elif valid is not None:
|
||||
@@ -146,7 +147,7 @@ def slow_ar_to_dac_decoder(
|
||||
|
||||
def slow_ar_to_dac_decoder_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -160,8 +161,8 @@ def slow_ar_to_dac_decoder_async_chunk(
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
cfg = _get_connector_extra(transfer_manager)
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = _extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(frame.detach())
|
||||
elif not finished:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -23,7 +24,7 @@ def _has_source_image(mm_data: Any) -> bool:
|
||||
- `img2img`: legacy single-image key
|
||||
- `images`: list or single image
|
||||
"""
|
||||
if not isinstance(mm_data, dict):
|
||||
if not isinstance(mm_data, Mapping):
|
||||
return False
|
||||
if mm_data.get("image") is not None:
|
||||
return True
|
||||
@@ -35,7 +36,7 @@ def _has_source_image(mm_data: Any) -> bool:
|
||||
|
||||
def _first_source_image(mm_data: Any) -> Any:
|
||||
"""Get first source image from normalized multimodal keys."""
|
||||
if not isinstance(mm_data, dict):
|
||||
if not isinstance(mm_data, Mapping):
|
||||
return None
|
||||
|
||||
image = mm_data.get("image")
|
||||
@@ -285,7 +286,7 @@ def ar2diffusion(
|
||||
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict) and mm_output.get("ids", {}).get("prior_image") is not None:
|
||||
if isinstance(mm_output, Mapping) and mm_output.get("ids", {}).get("prior_image") is not None:
|
||||
is_i2i = True
|
||||
_dt_mode = (time.perf_counter() - _t_mode) * 1000
|
||||
|
||||
@@ -322,7 +323,7 @@ def ar2diffusion(
|
||||
# Check ar_output (RequestOutput) for multimodal_output - this is the correct location
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict):
|
||||
if isinstance(mm_output, Mapping):
|
||||
raw_prior_image_ids = mm_output.get("ids", {}).get("prior_image")
|
||||
if raw_prior_image_ids is not None:
|
||||
# Handle different formats:
|
||||
@@ -348,7 +349,7 @@ def ar2diffusion(
|
||||
if hasattr(output, "multimodal_output") and output.multimodal_output:
|
||||
mm_output = output.multimodal_output
|
||||
logger.debug(f"[ar2diffusion] Request {i}: found multimodal_output on CompletionOutput (fallback)")
|
||||
if isinstance(mm_output, dict):
|
||||
if isinstance(mm_output, Mapping):
|
||||
raw_prior_image_ids = mm_output.get("ids", {}).get("prior_image")
|
||||
if raw_prior_image_ids is not None:
|
||||
if isinstance(raw_prior_image_ids, torch.Tensor):
|
||||
|
||||
@@ -188,7 +188,7 @@ def _to_cpu_tensor(value: Any) -> torch.Tensor | None:
|
||||
|
||||
def ar_to_dit_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -216,6 +216,7 @@ def ar_to_dit_async_chunk(
|
||||
if request_id is None:
|
||||
raise ValueError("GLM-TTS async chunk request is missing request id")
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
pooling_output = multimodal_output
|
||||
|
||||
# Read connector chunk config (supports progressive list or single int)
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
|
||||
@@ -176,7 +176,7 @@ def _extract_last_frame(pooling_output: OmniPayload) -> torch.Tensor | None:
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: OmniPayload | None,
|
||||
multimodal_output: OmniPayload | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
@@ -188,6 +188,7 @@ def talker2code2wav_async_chunk(
|
||||
"""
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
pooling_output = multimodal_output
|
||||
|
||||
if isinstance(pooling_output, dict):
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user