Files
wehub-resource-sync eec33d25b2
pre-commit / pre-commit (push) Failing after 1s
Build Wheel / build (3.11) (push) Failing after 1s
Build Wheel / build (3.12) (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:29:08 +08:00

528 lines
17 KiB
Python

# 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)