Files
vllm-omni/examples/offline_inference/text_to_speech/soulxsinger/end2end.py
T
fcf1085395 [Refactor][OutputProcessor 2/8]: OmniRequestOutput should inherit RequestOutput and no nested wrap-up (#5146)
Signed-off-by: Boao Shi <aoibosh@connect.hku.hk>
Signed-off-by: bowieshi <aoibosh@connect.hku.hk>
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Signed-off-by: Shi Boao Bowie <aoibosh@connect.hku.hk>
Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com>
Co-authored-by: ruirui(rein) yang <73573651+R2-Y@users.noreply.github.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 22:58:23 +08:00

286 lines
10 KiB
Python

"""Offline SoulX-Singer SVS / SVC: single-stage DiT (preprocess inline).
Pass raw prompt/target audio; vLLM-Omni runs integrated preprocess then inference.
Usage:
# SVS (default)
python end2end.py --model /path/to/SoulX-Singer \\
--prompt-audio path/to/prompt/audio.mp3 \\
--target-audio path/to/target/audio.mp3 \\
--preprocess-weights-dir /path/to/SoulX-Singer-Preprocess \\
-o output.wav
# SVC (config.json must declare SoulXSingerSVCPipeline; --model points at SVC view)
python end2end.py --model /path/to/SoulX-Singer-svc \\
--prompt-audio path/to/prompt/audio.mp3 \\
--target-audio path/to/target/audio.mp3 \\
--preprocess-weights-dir /path/to/SoulX-Singer-Preprocess \\
-o output_svc.wav
"""
import argparse
import os
from pathlib import Path
import numpy as np
import soundfile as sf
from vllm_omni.diffusion.models.soulx_singer.utils import resolve_soulx_kind, validate_soulx_extra_args
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
REPO_ROOT = Path(__file__).resolve().parents[4]
SVS_DEPLOY_CONFIG = REPO_ROOT / "vllm_omni" / "deploy" / "soulxsinger_svs.yaml"
SVC_DEPLOY_CONFIG = REPO_ROOT / "vllm_omni" / "deploy" / "soulxsinger_svc.yaml"
DEFAULT_PROMPT_AUDIO = REPO_ROOT / "tests" / "assets" / "soulxsinger" / "zh_prompt.mp3"
DEFAULT_TARGET_AUDIO = REPO_ROOT / "tests" / "assets" / "soulxsinger" / "music.mp3"
_SAMPLE_RATE = 24000
def _require_paths(paths: dict[str, str | None]) -> None:
missing = [name for name, p in paths.items() if not p or not os.path.isfile(p)]
if missing:
raise FileNotFoundError("Missing file(s): " + ", ".join(f"{k}={paths[k]!r}" for k in missing))
def resolve_preprocess_weights_dir(explicit: str | None) -> str:
if explicit:
path = Path(explicit).expanduser().resolve()
if not (path / "rmvpe" / "rmvpe.pt").is_file():
raise FileNotFoundError(f"--preprocess-weights-dir must contain rmvpe/rmvpe.pt: {path}")
return str(path)
env = os.environ.get("SOULX_PREPROCESS_WEIGHTS_DIR")
if env:
path = Path(env).expanduser().resolve()
if (path / "rmvpe" / "rmvpe.pt").is_file():
return str(path)
raise FileNotFoundError(
"SoulX preprocess weights not found. Pass --preprocess-weights-dir or set "
"SOULX_PREPROCESS_WEIGHTS_DIR to a directory containing rmvpe/rmvpe.pt "
"(huggingface-cli download Soul-AILab/SoulX-Singer-Preprocess)."
)
def build_sampling(
args: argparse.Namespace,
*,
preprocess_weights_dir: str,
kind: str,
) -> OmniDiffusionSamplingParams:
extra: dict = {
"prompt_audio": os.path.abspath(args.prompt_audio),
"target_audio": os.path.abspath(args.target_audio),
"preprocess_weights_dir": preprocess_weights_dir,
"vocal_sep": args.vocal_sep,
"auto_shift": args.auto_shift,
"pitch_shift": args.pitch_shift,
}
if kind == "svs":
extra["language"] = args.language
extra["control"] = args.control
if args.prompt_metadata_path:
extra["prompt_metadata_path"] = os.path.abspath(args.prompt_metadata_path)
if args.target_metadata_path:
extra["target_metadata_path"] = os.path.abspath(args.target_metadata_path)
if args.prompt_metadata_path or args.target_metadata_path:
extra["audio_path"] = os.path.abspath(args.prompt_wav_path or args.prompt_audio)
extra = validate_soulx_extra_args(kind, extra)
return OmniDiffusionSamplingParams(
num_inference_steps=args.num_inference_steps,
guidance_scale=args.guidance_scale,
seed=args.seed,
extra_args=extra,
)
def extract_audio(outputs) -> tuple[np.ndarray, int]:
out = outputs[0]
mm = getattr(out, "multimodal_output", None)
if mm is None:
ro = out
if ro is not None:
mm = getattr(ro, "multimodal_output", None)
if mm is None and ro.outputs:
mm = getattr(ro.outputs[0], "multimodal_output", None)
if not mm or "audio" not in mm:
raise RuntimeError("No audio in multimodal_output")
audio_val = mm["audio"]
if isinstance(audio_val, list):
chunks = [
np.asarray(chunk.detach().cpu().float().numpy() if hasattr(chunk, "detach") else chunk).reshape(-1)
for chunk in audio_val
if chunk is not None
]
audio_np = np.concatenate(chunks, axis=0) if chunks else np.array([], dtype=np.float32)
elif hasattr(audio_val, "cpu"):
audio_np = audio_val.detach().cpu().numpy().squeeze()
else:
audio_np = np.asarray(audio_val, dtype=np.float32).squeeze()
sr_val = mm.get("sr", mm.get("audio_sample_rate", _SAMPLE_RATE))
if isinstance(sr_val, list) and sr_val:
sr_val = sr_val[-1]
if hasattr(sr_val, "item"):
sr_val = sr_val.item()
return audio_np.astype(np.float32, copy=False), int(sr_val)
def add_inference_args(parser: argparse.ArgumentParser) -> None:
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--svc",
action="store_true",
help="Assert SVC mode (must match config.json architectures)",
)
parser.add_argument(
"--model",
type=str,
required=True,
help="SoulX-Singer weight directory ($BASE for SVS, $SVC_DIR for SVC)",
)
parser.add_argument(
"--deploy-config",
type=str,
default=None,
help="Override deploy YAML (default: soulxsinger_svs.yaml or soulxsinger_svc.yaml)",
)
parser.add_argument(
"--prompt-audio",
type=str,
default=str(DEFAULT_PROMPT_AUDIO),
help="Prompt / reference audio (mp3 or wav)",
)
parser.add_argument(
"--target-audio",
type=str,
default=str(DEFAULT_TARGET_AUDIO),
help="Target content audio (mp3 or wav)",
)
parser.add_argument(
"--prompt-metadata-path",
type=str,
default=None,
help="SVS precomputed prompt metadata.json (skips online ASR/ROSVOT for prompt)",
)
parser.add_argument(
"--target-metadata-path",
type=str,
default=None,
help="SVS precomputed target metadata.json (skips online ASR/ROSVOT for target)",
)
parser.add_argument(
"--prompt-wav-path",
type=str,
default=None,
help="SVS prompt vocal wav for metadata processor when using --prompt-metadata-path (default: --prompt-audio)",
)
parser.add_argument(
"--preprocess-weights-dir",
type=str,
default=None,
help="SoulX-Singer-Preprocess weights (or set SOULX_PREPROCESS_WEIGHTS_DIR)",
)
parser.add_argument(
"--language",
type=str,
default="Mandarin",
help="SVS lyrics language (Mandarin, Cantonese, English)",
)
parser.add_argument(
"--control",
type=str,
default="melody",
choices=["score", "melody"],
help="SVS control mode",
)
parser.add_argument(
"--vocal-sep",
action=argparse.BooleanOptionalAction,
default=False,
help="Run vocal separation during preprocess",
)
parser.add_argument(
"--auto-shift",
action=argparse.BooleanOptionalAction,
default=True,
help="Auto pitch shift to align target pitch with prompt",
)
parser.add_argument("--pitch-shift", type=int, default=0)
parser.add_argument("--num-inference-steps", type=int, default=32)
parser.add_argument("--guidance-scale", type=float, default=3.0)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Optional CFM RNG seed. Omit for non-deterministic sampling.",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="SoulX-Singer offline SVS / SVC (preprocess + DiT)")
add_inference_args(parser)
parser.add_argument("--output", "-o", type=str, default="output.wav")
args = parser.parse_args()
return args
def main() -> None:
args = parse_args()
kind = resolve_soulx_kind(args.model)
if not (args.svc ^ (kind == "svs")):
raise ValueError(f"mode mismatch: {args.model}/config.json (declares {kind!r}), --svc={args.svc}")
required = {
"prompt_audio": args.prompt_audio,
"target_audio": args.target_audio,
}
if kind == "svs" and bool(args.prompt_metadata_path) ^ bool(args.target_metadata_path):
raise ValueError(
"SVS precomputed metadata requires both --prompt-metadata-path and "
"--target-metadata-path (or neither, for online preprocess)."
)
if args.prompt_metadata_path:
required["prompt_metadata_path"] = args.prompt_metadata_path
required["target_metadata_path"] = args.target_metadata_path
if args.prompt_wav_path:
required["prompt_wav_path"] = args.prompt_wav_path
_require_paths(required)
preprocess_dir = resolve_preprocess_weights_dir(args.preprocess_weights_dir)
deploy_config = args.deploy_config or str(SVC_DEPLOY_CONFIG if kind == "svc" else SVS_DEPLOY_CONFIG)
mode = kind.upper()
print(f"Loading SoulX-Singer {mode} from {args.model}")
print(f" deploy: {deploy_config}")
print(f" prompt: {args.prompt_audio}")
print(f" target: {args.target_audio}")
omni = Omni(model=args.model, deploy_config=deploy_config, async_chunk=False) # SoulX-Singer is batch-only
sampling = build_sampling(
args,
preprocess_weights_dir=preprocess_dir,
kind=kind,
)
prompt = {"prompt_token_ids": [0], "multi_modal_data": {}}
print("Running generate (inline preprocess + DiT)...")
outputs = list(omni.generate([prompt], sampling))
if not outputs:
raise RuntimeError("No output from omni.generate")
err = getattr(outputs[0], "error", None)
if err is not None:
raise RuntimeError(f"Pipeline error: {err}")
audio_np, sr = extract_audio(outputs)
sf.write(args.output, audio_np, sr)
print(f"Saved {args.output} ({sr} Hz, {len(audio_np) / sr:.2f}s)")
omni.close()
if __name__ == "__main__":
main()