Signed-off-by: zwhzzz0821 <2831474076@qq.com> Co-authored-by: zwhzzz0821 <zwhzzz0821@users.noreply.github.com> Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
343 lines
10 KiB
Python
343 lines
10 KiB
Python
# 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-Audio-2-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-Audio-2-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)
|