[New Model] Step-Audio2 (#464)

Signed-off-by: linyueqian <linyueqian@outlook.com>
Signed-off-by: wuli666 <djjpro975@gmail.com>
Co-authored-by: linyueqian <linyueqian@outlook.com>
Co-authored-by: wuli666 <djjpro975@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-13 01:00:33 +00:00
1 parent 1733831f78
commit 9bc8705c98
26 files changed
+5347 -2

No files matched your search

+22
View File
@@ -164,6 +164,28 @@ 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
@@ -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
View File
@@ -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!"
@@ -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
@@ -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
@@ -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
@@ -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,
)
)
@@ -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
@@ -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),
}
+45 -2
View File
@@ -80,6 +80,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 +95,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",
@@ -655,6 +657,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 +1360,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 +2867,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(
@@ -3202,7 +3238,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if err:
raise ValueError(err)
if self._is_fish_speech:
if self._tts_model_type == "step_audio2":
if not request.input or not request.input.strip():
raise ValueError("Input text cannot be empty")
prompt = self._build_step_audio2_prompt(request)
tts_params = {}
elif self._is_fish_speech:
validation_error = self._validate_fish_tts_request(request)
if validation_error:
raise ValueError(validation_error)
@@ -3413,7 +3454,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"
@@ -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"]
@@ -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
@@ -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
@@ -0,0 +1,204 @@
# SPDX-License-Identifier: Apache-2.0
"""Stage input processor for Step-Audio2: Thinker → Token2Wav transition."""
from typing import Any
import torch
from vllm.inputs import TextPrompt
from vllm.logger import init_logger
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.models.step_audio2.step_audio2_constants import (
DEFAULT_STREAM_CONFIG,
DEFAULT_TOKEN_CONFIG,
)
logger = init_logger(__name__)
def _ensure_list(x):
"""Convert ConstantList / tensor / iterable to Python list."""
if hasattr(x, "_x"):
return list(x._x)
elif isinstance(x, torch.Tensor):
return x.tolist()
elif not isinstance(x, list):
return list(x) if hasattr(x, "__iter__") else [x]
return list(x)
# =========================
# Async chunk processor
# =========================
def thinker2token2wav_async_chunk(
transfer_manager: Any,
pooling_output: dict[str, Any],
request: OmniEngineCoreRequest,
is_finished: bool = False,
) -> dict[str, Any] | None:
"""
Async chunk processor: stream audio tokens from Thinker to Token2Wav.
Unlike Qwen3-Omni which passes hidden states via pooling_output,
Step Audio2 generates audio tokens as part of the LLM autoregressive
output (token IDs >= audio_start). This processor extracts those audio
tokens from the running token stream and sends them in chunks.
The flow model's conformer encoder requires ``pre_lookahead_len`` extra
tokens beyond the chunk boundary. Each chunk therefore contains
``chunk_size + pre_lookahead_len`` tokens, but the consumed pointer
only advances by ``chunk_size`` so the lookahead tokens are re-used as
the start of the next chunk's context (cached inside the encoder).
Payload adapts to the framework's ``_poll_single_request`` non-AR path:
- ``code_predictor_codes`` → ``request.prompt_token_ids`` → ``input_ids``
- ``left_context_size`` → ``request.additional_information``
→ ``runtime_additional_information`` (0 = not last, 1 = last chunk)
- ``finished`` → marks request as finished in adapter
Args:
transfer_manager: OmniChunkTransferAdapter instance (has
``code_prompt_token_ids`` defaultdict for state tracking).
pooling_output: Pooler output dict (unused — audio tokens are in
the token ID stream, not in hidden states).
request: Current engine request with access to all_token_ids.
is_finished: Whether the upstream request has finished generating.
Returns:
dict with framework-compatible fields, or None if the chunk is
not yet ready.
"""
audio_start = DEFAULT_TOKEN_CONFIG.audio_start
audio_eos = DEFAULT_TOKEN_CONFIG.audio_eos
finished = bool(is_finished or request.is_finished())
# Only look at decode (generated) tokens — the prompt may contain
# historical audio tokens from prior conversation turns.
all_token_ids = _ensure_list(request.all_token_ids)
prompt_len = len(_ensure_list(request.prompt_token_ids))
generated_ids = all_token_ids[prompt_len:]
# Extract audio tokens and convert to 0-based IDs for Token2Wav
audio_tokens = [tid - audio_start for tid in generated_ids if tid >= audio_start]
# Remove padding / EOS tokens
audio_tokens = [t for t in audio_tokens if t < audio_eos]
# Flow model streaming parameters (from centralised config)
chunk_size = DEFAULT_STREAM_CONFIG.chunk_size
pre_lookahead_len = DEFAULT_STREAM_CONFIG.pre_lookahead_len
# consumed = number of tokens whose mel output has been produced.
# We track this via transfer_manager.code_prompt_token_ids[request_id].
request_id = request.external_req_id
consumed = len(transfer_manager.code_prompt_token_ids[request_id])
available = len(audio_tokens) - consumed
if finished:
# Last chunk: send all remaining tokens
if available <= 0:
# No audio tokens at all (text-only response) — send EOF marker
return {
"code_predictor_codes": [],
"left_context_size": 1, # 1 = last chunk
"finished": torch.tensor(True, dtype=torch.bool),
}
remaining_tokens = audio_tokens[consumed:]
transfer_manager.code_prompt_token_ids[request_id].extend(remaining_tokens)
return {
"code_predictor_codes": remaining_tokens,
"left_context_size": 1, # 1 = last chunk
"finished": torch.tensor(True, dtype=torch.bool),
}
else:
# Non-last chunk: need chunk_size + pre_lookahead_len tokens
required = chunk_size + pre_lookahead_len
if available < required:
return None
chunk_tokens = audio_tokens[consumed : consumed + required]
# Only consume chunk_size tokens; the lookahead portion is re-used
transfer_manager.code_prompt_token_ids[request_id].extend(audio_tokens[consumed : consumed + chunk_size])
return {
"code_predictor_codes": chunk_tokens,
"left_context_size": 0, # 0 = not last chunk
"finished": torch.tensor(False, dtype=torch.bool),
}
def thinker2token2wav(
source_outputs: list[Any],
prompt: OmniTokensPrompt | TextPrompt | None = None,
requires_multimodal_data: bool = False,
) -> list[OmniTokensPrompt]:
"""
Process thinker outputs to create token2wav inputs.
Workflow:
1. Extract generated token IDs from thinker output
2. Separate audio tokens from text tokens
3. Package audio tokens for token2wav stage
Step-Audio2 token ranges:
- Text tokens: 0 - 151688
- Audio tokens: 151696 - 158257 (vocab size 6562)
Args:
source_outputs: Resolved list of upstream (thinker) engine outputs
prompt: Original prompt data (unused for Token2Wav in default-voice mode)
requires_multimodal_data: Whether multimodal data is required
Returns:
List of OmniTokensPrompt for token2wav stage
"""
if not source_outputs:
raise ValueError("source_outputs cannot be empty")
thinker_outputs = source_outputs
token2wav_inputs = []
# Token configuration uses the fixed Step-Audio2 model constants.
audio_start = DEFAULT_TOKEN_CONFIG.audio_start
audio_eos = DEFAULT_TOKEN_CONFIG.audio_eos # Relative to audio start
# Process each thinker output
for i, thinker_output in enumerate(thinker_outputs):
output = thinker_output.outputs[0]
# Only look at decode (generated) tokens — the prompt may contain
# historical audio tokens from prior conversation turns.
gen_ids = output.token_ids
# Convert to CPU list if needed (for multi-process/multi-GPU setup)
if isinstance(gen_ids, torch.Tensor):
gen_ids = gen_ids.cpu().tolist()
# Separate audio tokens from text tokens
# Audio tokens are >= audio_start
audio_tokens = [
tid - audio_start # Convert to 0-based for Token2Wav
for tid in gen_ids
if tid >= audio_start
]
# Remove padding tokens (anything >= EOS)
audio_tokens = [t for t in audio_tokens if t < audio_eos]
if not audio_tokens:
# No audio tokens generated, skip Token2Wav for this request
logger.info(f"Request {i}: No audio tokens generated, skipping Token2Wav stage")
continue
logger.debug("Creating Token2Wav input with %d audio tokens", len(audio_tokens))
token2wav_inputs.append(
OmniTokensPrompt(
prompt_token_ids=audio_tokens, # Pass original tokens (no encoding)
multi_modal_data=None,
additional_information=None, # Not used due to framework limitation
)
)
return token2wav_inputs