202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
import argparse
|
|
import copy
|
|
import functools
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
from vllm.multimodal.media.audio import load_audio
|
|
|
|
from vllm_omni.entrypoints.omni import Omni
|
|
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
|
|
from vllm_omni.model_executor.models.cosyvoice3.utils import extract_text_token
|
|
|
|
# Upstream zero-shot reference clip
|
|
ZERO_SHOT_PROMPT_URL = "https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav"
|
|
|
|
|
|
def _default_ref_audio() -> str:
|
|
# Download the upstream zero_shot_prompt.wav into the current dir
|
|
dest = Path("zero_shot_prompt.wav")
|
|
if not dest.exists() or dest.stat().st_size == 0:
|
|
print(f"Downloading default reference audio to {dest}")
|
|
urllib.request.urlretrieve(ZERO_SHOT_PROMPT_URL, dest)
|
|
|
|
return str(dest)
|
|
|
|
|
|
def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]:
|
|
"""Parse a CLI value as a JSON object, attributing errors to ``flag_name``."""
|
|
try:
|
|
config = json.loads(value)
|
|
except json.JSONDecodeError as e:
|
|
raise argparse.ArgumentTypeError(f"{flag_name} must be valid JSON: {e}") from e
|
|
if not isinstance(config, dict):
|
|
raise argparse.ArgumentTypeError(f"{flag_name} must be a JSON object")
|
|
return config
|
|
|
|
|
|
parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config")
|
|
|
|
|
|
def run_e2e():
|
|
parser = argparse.ArgumentParser()
|
|
# ""FunAudioLLM/Fun-CosyVoice3-0.5B-2512
|
|
parser.add_argument(
|
|
"--model",
|
|
type=str,
|
|
required=True,
|
|
help="Path to CosyVoice3 model directory (e.g., pretrained_models/Fun-CosyVoice3-0.5B/).",
|
|
)
|
|
parser.add_argument(
|
|
"--deploy-config",
|
|
type=str,
|
|
default=None,
|
|
help="Override the deploy config path. If unset, selects the bundled CosyVoice3 profile for the device.",
|
|
)
|
|
parser.add_argument("--text", type=str, default="Hello, this is a test of the CosyVoice system capability.")
|
|
parser.add_argument(
|
|
"--prompt-text",
|
|
type=str,
|
|
default="You are a helpful assistant.<|endofprompt|>希望你以后,能够做的比我还好呦!",
|
|
)
|
|
parser.add_argument(
|
|
"--ref-audio",
|
|
type=str,
|
|
default=None,
|
|
help="Path to reference audio for voice cloning. "
|
|
"If unset, downloads the upstream CosyVoice3 zero-shot prompt audio clip",
|
|
)
|
|
parser.add_argument(
|
|
"--tokenizer",
|
|
type=str,
|
|
required=True,
|
|
help="Path to tokenizer directory (e.g., <model_path>/CosyVoice-BlankEN).",
|
|
)
|
|
parser.add_argument(
|
|
"--profiler-config",
|
|
type=parse_profiler_config,
|
|
default=None,
|
|
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
|
|
)
|
|
args = parser.parse_args()
|
|
# Ensure tokenizer directory exists
|
|
if not os.path.exists(args.tokenizer):
|
|
raise FileNotFoundError(f"{args.tokenizer} does not exist!")
|
|
|
|
if args.deploy_config is not None and not os.path.exists(args.deploy_config):
|
|
raise FileNotFoundError(f"{args.deploy_config} does not exist!")
|
|
|
|
print(f"Initializing cosyvoice E2E with model={args.model}")
|
|
|
|
omni = Omni(
|
|
model=args.model,
|
|
deploy_config=args.deploy_config,
|
|
tokenizer=args.tokenizer,
|
|
log_stats=True,
|
|
profiler_config=args.profiler_config,
|
|
)
|
|
|
|
print("Model initialized. Preparing inputs...")
|
|
ref_audio_path = args.ref_audio or _default_ref_audio()
|
|
if not os.path.exists(ref_audio_path):
|
|
raise FileNotFoundError(f"Audio file not found: {ref_audio_path}")
|
|
# Load at native sample rate
|
|
audio_signal, sr = load_audio(ref_audio_path, sr=None)
|
|
|
|
# Validate sample rate before processing (similar to original CosyVoice)
|
|
min_sr = 16000
|
|
if sr < min_sr:
|
|
raise ValueError(
|
|
f"Audio sample rate {sr} Hz is too low. "
|
|
f"Minimum required: {min_sr} Hz. "
|
|
f"Please provide audio with sample rate >= {min_sr} Hz."
|
|
)
|
|
|
|
audio_data = (audio_signal.astype(np.float32), sr)
|
|
|
|
prompts = {
|
|
"prompt": args.text,
|
|
"multi_modal_data": {
|
|
"audio": audio_data,
|
|
},
|
|
"mm_processor_kwargs": {
|
|
"prompt_text": args.prompt_text,
|
|
"sample_rate": audio_data[1],
|
|
},
|
|
}
|
|
|
|
print(f"Generating for prompt: {args.text}")
|
|
|
|
config = omni.engine.stage_vllm_configs[0].model_config.hf_config
|
|
tokenizer = get_qwen_tokenizer(
|
|
token_path=args.tokenizer,
|
|
skip_special_tokens=config.skip_special_tokens,
|
|
version=config.version,
|
|
)
|
|
_, text_token_len = extract_text_token(args.text, tokenizer, config.allowed_special)
|
|
base_len = int(text_token_len)
|
|
min_len = int(base_len * config.min_token_text_ratio)
|
|
max_len = int(base_len * config.max_token_text_ratio)
|
|
|
|
# Keep the deploy profile's sampling policy, penalties and required stops.
|
|
sampling_params_list = copy.deepcopy(omni.default_sampling_params_list)
|
|
sampling_params_list[0].max_tokens = max(1, min(2048, max_len))
|
|
sampling_params_list[0].min_tokens = min(max(1, min_len), sampling_params_list[0].max_tokens)
|
|
|
|
profiler_enabled = args.profiler_config is not None
|
|
if profiler_enabled:
|
|
print("Starting profiler...")
|
|
omni.start_profile()
|
|
|
|
# Generate (Omni orchestrator requires a per-stage SamplingParams list)
|
|
outputs = list(omni.generate(prompts, sampling_params_list=sampling_params_list[:2]))
|
|
|
|
# Stop profiling and get results
|
|
if profiler_enabled:
|
|
print("Stopping profiler...")
|
|
profile_results = omni.stop_profile()
|
|
print(f"Profile traces saved to: {profile_results}")
|
|
|
|
print(outputs)
|
|
# Verify outputs
|
|
print(f"Received {len(outputs)} outputs.")
|
|
for i, output in enumerate(outputs):
|
|
try:
|
|
ro = output
|
|
if ro is None:
|
|
print("No request_output found.")
|
|
continue
|
|
|
|
# Multimodal output may be attached to RequestOutput or CompletionOutput.
|
|
mm = getattr(ro, "multimodal_output", None)
|
|
if not mm and ro.outputs:
|
|
mm = getattr(ro.outputs[0], "multimodal_output", None)
|
|
|
|
if mm:
|
|
print(f"Multimodal output keys: {mm.keys()}")
|
|
if "audio" in mm:
|
|
audio_out = mm["audio"]
|
|
# The model emits its own rate (24 kHz for CosyVoice3); take
|
|
# it from the payload so the file is not mislabeled.
|
|
sample_rate = int(mm["sr"]) if "sr" in mm.keys() else 24000
|
|
print(f"Generated Audio Shape: {audio_out.shape}")
|
|
out_path = f"output_{i}.wav"
|
|
sf.write(out_path, audio_out.cpu().numpy().squeeze(), sample_rate)
|
|
print(f"Saved audio to {out_path} (sr={sample_rate})")
|
|
else:
|
|
print("No multimodal output found.")
|
|
except Exception as e:
|
|
print(f"Error inspecting output: {e}")
|
|
omni.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_e2e()
|