[CI]Remove SoulX-Singer support (#6362)

Signed-off-by: wangyu <410167048@qq.com>
This commit is contained in:
wangyu authored and GitHub committed 2026-08-23 07:16:32 +08:00
1 parent b2c9376539
commit 2e096788a6
51 files changed
+1 -9575

No files matched your search

-1
View File
@@ -245,7 +245,6 @@ datasets/
!recipes/cosmos3/negative_prompt.json
!tests/dfx/perf/tests/*.json
!apps/ComfyUI-vLLM-Omni/example_workflows/*.json
!tests/assets/soulxsinger/*.json
*.jsonl
*.parquet
-2
View File
@@ -76,8 +76,6 @@ th {
| `FluxPipeline` | FLUX.1-schnell | `black-forest-labs/FLUX.1-schnell` | ✅︎ | ✅︎ | | ✅︎ | — |
| `OmniGen2Pipeline` | OmniGen2 | `OmniGen2/OmniGen2` | ✅︎ | ✅︎ | | ✅︎ | — |
| `StableAudioPipeline` | Stable-Audio-Open | `stabilityai/stable-audio-open-1.0` | ✅︎ | ✅︎ | | ✅︎ | — |
| `SoulXSingerPipeline` | SoulX-Singer (SVS) | `Soul-AILab/SoulX-Singer` | ✅︎ | | | | — |
| `SoulXSingerSVCPipeline` | SoulX-Singer-SVC | `Soul-AILab/SoulX-Singer` (`model-svc.pt`) | ✅︎ | | | | — |
| `AudioXPipeline` | AudioX | `zhangj1an/AudioX` | ✅︎ | ✅︎ | | | — |
| `MiniMaxMusic3ForConditionalGeneration` | MiniMax Music 3 (text-to-music) | `MiniMaxAI/MiniMax-Music3` | ✅︎ | | | | — |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | | | | [Published](https://recipes.vllm.ai/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) |
-1
View File
@@ -49,7 +49,6 @@ higher-priority strategy is selected.
| StableDiffusion3Pipeline | `stabilityai/stable-diffusion-3.5-medium` | `SD3Transformer2DModel` | — | yes | — | `transformer_blocks` |
| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | yes | yes | — | `blocks` |
| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | yes | yes | — | `blocks` |
| SoulXSingerPipeline / SoulXSingerSVCPipeline | `Soul-AILab/SoulX-Singer` | `DiffLlama` | yes | yes | — | `layers` |
| BagelPipeline | `ByteDance-Seed/BAGEL-7B-MoT` | `Qwen2MoTModel` | — | yes | — | `layers`, customized modules |
| Cosmos3OmniDiffusersPipeline | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | `Cosmos3VFMTransformer`, `Cosmos3LanguageModel` | yes | yes | yes | `layers`, `gen_layers` |
@@ -420,8 +420,7 @@ against transformers 5.x, so nothing is lost by holding it there.
(The same clash is why this is documented here rather than declared in
`pyproject.toml`: an extra resolves together with the base dependencies, so
`vllm-omni[gepard]` would be unsatisfiable rather than opt-in. SoulX-Singer English
SVS documents `nemo_toolkit[asr]` the same way.)
`vllm-omni[gepard]` would be unsatisfiable rather than opt-in.)
On a host whose CUDA toolkit cannot build kernels for the GPU — no `nvcc`/`ninja`, or a
consumer Blackwell (`sm_120`) card — also set `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's
@@ -617,100 +616,3 @@ Available voice presets are listed on the HF model card (`mistralai/Voxtral-4B-T
- `--num-prompts N` replicates the prompt for performance measurement.
- `--concurrency M` requires `--streaming` and must evenly divide `--num-prompts`.
- Run `--help` for the full argument surface.
---
## SoulX-Singer
Singing voice synthesis (SVS) and conversion (SVC) at 24 kHz. Script: `soulxsinger/end2end.py`. Deploy: `vllm_omni/deploy/soulxsinger_svs.yaml` or `soulxsinger_svc.yaml`.
### Prerequisites
Download DiT and preprocess weights, then set up separate SVS / SVC view directories. Copy `soulxsinger/utils/phoneme/phone_set.json` from upstream [SoulX-Singer](https://github.com/Soul-AILab/SoulX-Singer) into the model weights dir as `phoneme/phone_set.json` — HuggingFace does not ship it.
```bash
# 1. DiT weights
export BASE=path/to/SoulX-Singer
export PREPROCESS=path/to/SoulX-Singer-Preprocess
export SVC_DIR=path/to/SoulX-Singer-svc
huggingface-cli download Soul-AILab/SoulX-Singer --local-dir "$BASE"
# 2. Preprocess weights (required)
huggingface-cli download Soul-AILab/SoulX-Singer-Preprocess --local-dir "$PREPROCESS"
export SOULX_PREPROCESS_WEIGHTS_DIR="$PREPROCESS"
# 3. SVS / SVC view directories
mkdir -p "$SVC_DIR"
cp $BASE/{config.yaml,README.md,assets} $SVC_DIR
mv $BASE/model-svc.pt $SVC_DIR/model-svc.pt
cat > "$BASE/config.json" <<'EOF'
{
"model_type": "soulxsinger",
"architectures": ["SoulXSingerPipeline"],
"max_num_seqs": 1
}
EOF
cat > "$SVC_DIR/config.json" <<'EOF'
{
"model_type": "soulxsinger",
"architectures": ["SoulXSingerSVCPipeline"],
"max_num_seqs": 1
}
EOF
```
`config.yaml` hyper-parameters live under `$BASE`; each view's `config.json` `architectures` field is the single source of truth for SVS vs SVC. Point `--model` at the matching directory (`$BASE` for SVS, `$SVC_DIR` for SVC). Deploy YAML is chosen automatically from `config.json`; optional `--svs` / `--svc` only assert the mode matches.
**Online preprocess** is the default: pass `--prompt-audio` and `--target-audio`, and the worker runs vocal separation, F0, and (for SVS) lyrics/MIDI before DiT. Install only what your run needs:
```bash
pip install "BS-RoFormer" # vocal sep + F0 on GPU — SVS and SVC
```
Mandarin SVS also needs FunASR and Chinese G2P; `ffmpeg` must be on `PATH`:
```bash
# install optional dependencies:
pip install -e ".[soulx-svs]"
```
English SVS adds NeMo ASR and NLTK data; pass `--language English`:
```bash
pip install "nemo_toolkit[asr]==2.6.1" lhotse==1.32.2
python -c "import nltk; nltk.download('cmudict'); nltk.download('averaged_perceptron_tagger_eng')"
```
**Precomputed metadata** is the alternative: pass both `--prompt-metadata-path` and `--target-metadata-path` and skip online ASR/ROSVOT — none of the packages above are required. JSON can be produced by integrated preprocess on a prior run, or by upstream [SoulX-Singer](https://github.com/Soul-AILab/SoulX-Singer) `preprocess/` scripts if you prefer to run that outside vLLM-Omni.
### Quick start
```bash
# SVS — default demo audio: tests/assets/soulxsinger/zh_prompt.mp3 + music.mp3
python examples/offline_inference/text_to_speech/soulxsinger/end2end.py \
--model "$BASE" \
--preprocess-weights-dir "$PREPROCESS" \
--control score \
--num-inference-steps 32 \
-o output.wav
python examples/offline_inference/text_to_speech/soulxsinger/end2end.py \
--model "$SVC_DIR" \
--preprocess-weights-dir "$PREPROCESS" \
--svc \
--num-inference-steps 32 \
-o output_svc.wav
```
`SOULX_PREPROCESS_WEIGHTS_DIR` makes `--preprocess-weights-dir` optional. Long SVS targets are handled in one request. See `end2end.py --help` for `--pitch-shift`, `--vocal-sep`, `--auto-shift`, and language/control options.
### Notes
- Output: 24 kHz mono WAV; batch only.
- Defaults match upstream: `--guidance-scale 3.0`, `--seed 42`, `--auto-shift` on.
- SVS `--control`: `score` or `melody`. MIDI / lyric QC: upstream `midi_editor` only.
---
@@ -1,162 +0,0 @@
"""Repeatable SoulX-Singer offline benchmark (preprocess + DiT, RTF + stage timings).
Usage:
python benchmark.py --model /path/to/SoulX-Singer --svs \\
--prompt-audio ../SoulX-Singer/example/audio/zh_prompt.mp3 \\
--target-audio ../SoulX-Singer/example/audio/music.mp3 \\
--preprocess-weights-dir /path/to/SoulX-Singer-Preprocess \\
-o benchmark.wav
python benchmark.py ... --enable-diffusion-pipeline-profiler
"""
import argparse
import statistics
import time
from pathlib import Path
import soundfile as sf
from end2end import (
SVC_DEPLOY_CONFIG,
SVS_DEPLOY_CONFIG,
add_inference_args,
build_sampling,
extract_audio,
resolve_preprocess_weights_dir,
)
from vllm_omni.entrypoints.omni import Omni
def _audio_duration_sec(outputs) -> float:
audio_np, sr = extract_audio(outputs)
return audio_np.size / sr
def _stage_durations_from_output(outputs) -> dict[str, float]:
omni_out = outputs[0]
durations = getattr(omni_out, "stage_durations", None) or {}
if durations:
return dict(durations)
ro = omni_out
if ro is not None:
return dict(getattr(ro, "stage_durations", None) or {})
return {}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="SoulX-Singer offline benchmark")
add_inference_args(parser)
parser.add_argument("--warmup", type=int, default=1, help="Warmup runs (excluded from stats)")
parser.add_argument("--runs", type=int, default=3, help="Measured runs")
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable low-overhead stage timing in DiffusionOutput.stage_durations",
)
parser.add_argument(
"--enforce-eager",
action="store_true",
help="Disable torch.compile on diff_estimator (eager baseline for A/B)",
)
parser.add_argument(
"--dtype",
type=str,
default=None,
choices=["bfloat16", "bf16", "float16", "fp16", "half"],
help="DiT trunk dtype (default from deploy YAML). Use float16 to match upstream --fp16.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Save WAV from the last measured run (after warmup)",
)
parser.add_argument(
"--save-each-run",
action="store_true",
help="With --output, also write measured runs as <stem>_run1.wav, _run2.wav, ...",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
preprocess_dir = resolve_preprocess_weights_dir(args.preprocess_weights_dir)
deploy_config = args.deploy_config or str(SVS_DEPLOY_CONFIG if args.svs else SVC_DEPLOY_CONFIG)
mode = "SVS" if args.svs else "SVC"
omni_kwargs: dict = {
"model": args.model,
"deploy_config": deploy_config,
"async_chunk": False, # SoulX-Singer currently supports only batch mode (pseudo-streaming was stashed)
}
if args.enable_diffusion_pipeline_profiler:
omni_kwargs["enable_diffusion_pipeline_profiler"] = True
if args.enforce_eager:
omni_kwargs["enforce_eager"] = True
if args.dtype is not None:
omni_kwargs["dtype"] = args.dtype
compile_mode = "eager" if args.enforce_eager else "torch.compile"
dtype_label = args.dtype or "from deploy YAML"
print(f"Loading SoulX-Singer {mode} from {args.model} [{compile_mode}, dtype={dtype_label}]")
omni = Omni(**omni_kwargs)
kind = "svs" if args.svs else "svc"
sampling = build_sampling(args, preprocess_weights_dir=preprocess_dir, kind=kind)
prompt = {"prompt_token_ids": [0]}
latencies_ms: list[float] = []
rtfs: list[float] = []
last_stages: dict[str, float] = {}
last_measured_outputs = None
total_runs = args.warmup + args.runs
for run_idx in range(total_runs):
is_warmup = run_idx < args.warmup
label = "warmup" if is_warmup else f"run {run_idx - args.warmup + 1}/{args.runs}"
t0 = time.perf_counter()
outputs = list(omni.generate([prompt], sampling))
elapsed_ms = (time.perf_counter() - t0) * 1000.0
audio_sec = _audio_duration_sec(outputs)
rtf = (elapsed_ms / 1000.0) / audio_sec if audio_sec > 0 else float("inf")
print(f"[{label}] client={elapsed_ms:.1f} ms, audio={audio_sec:.2f}s, RTF={rtf:.3f}")
if not is_warmup:
latencies_ms.append(elapsed_ms)
rtfs.append(rtf)
last_stages = _stage_durations_from_output(outputs)
last_measured_outputs = outputs
if args.output and args.save_each_run:
measured_idx = run_idx - args.warmup + 1
out_path = Path(args.output)
run_path = out_path.with_name(f"{out_path.stem}_run{measured_idx}{out_path.suffix}")
audio_np, sr = extract_audio(outputs)
sf.write(str(run_path), audio_np, sr)
print(f" saved {run_path} ({sr} Hz, {audio_np.size / sr:.2f}s)")
omni.close()
if args.output and last_measured_outputs is not None:
audio_np, sr = extract_audio(last_measured_outputs)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
sf.write(str(out_path), audio_np, sr)
print(f"\nSaved last measured run → {out_path} ({sr} Hz, {audio_np.size / sr:.2f}s)")
if latencies_ms:
print("\n=== Summary (measured runs) ===")
print(f"client_ms: mean={statistics.mean(latencies_ms):.1f}, stdev={statistics.pstdev(latencies_ms):.1f}")
print(f"RTF: mean={statistics.mean(rtfs):.3f}, stdev={statistics.pstdev(rtfs):.3f}")
if last_stages:
print("\nStage durations (last measured run):")
for name, value in sorted(last_stages.items()):
if name.endswith("_ms"):
print(f" {name}: {value:.1f} ms")
else:
print(f" {name}: {value * 1000.0:.1f} ms")
if __name__ == "__main__":
main()
@@ -1,285 +0,0 @@
"""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()
@@ -25,7 +25,6 @@ For the full list of supported architectures across all modalities, see
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | ✓ (Base) | ✓ (PCM + WebSocket) | ✓ (presets + `/v1/audio/voices` upload) | ✓ (standard + FastRTC) |
| VoxCPM2 | `openbmb/VoxCPM2` | ✓ | ✓ (AudioWorklet via gradio) | — | ✓ |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✓ (gated upstream) | ✓ | ✓ (presets) | ✓ |
| SoulX-Singer | `Soul-AILab/SoulX-Singer` | ✓ (prompt audio) | — (batch only) | — (prompt + target audio) | — (chat client) |
CosyVoice3 is intentionally absent: no online example exists for it yet. See its [offline section](../../offline_inference/text_to_speech/README.md#cosyvoice3) instead.
@@ -715,65 +714,3 @@ The demo handles voice-preset selection and reference-audio upload. `voxtral_tts
- Voice presets are listed on the HF model card (`mistralai/Voxtral-4B-TTS-2603`).
- Voice cloning is gated upstream and may require a recent `mistral_common`.
- A standalone CLI client is not yet shipped; the gradio demo is the canonical reference for now.
---
## SoulX-Singer
Singing voice synthesis (SVS) and conversion (SVC) at 24 kHz. Single-stage DiT with inline preprocess. Uses the `/v1/chat/completions` endpoint with multimodal input (`prompt_audio` + `target_audio`).
### Prerequisites
Download DiT and preprocess weights, then set up separate SVS / SVC view directories and install dependencies as described in the [offline README](../../offline_inference/text_to_speech/README.md#soulx-singer). `config.json` `architectures` field is the single source of truth for SVS vs SVC — point `MODEL` at the matching directory.
### Launch
```bash
# SVS (default)
export MODEL=/path/to/SoulX-Singer
export PREPROCESS=/path/to/SoulX-Singer-Preprocess
bash examples/online_serving/text_to_speech/soulxsinger/run_server.sh
# SVC
export MODE=svc
export MODEL=/path/to/SoulX-Singer-svc
bash examples/online_serving/text_to_speech/soulxsinger/run_server.sh
```
Or equivalently, set `SOULX_PREPROCESS_WEIGHTS_DIR` and launch directly:
```bash
export SOULX_PREPROCESS_WEIGHTS_DIR=$PREPROCESS
vllm serve $MODEL --omni \
--deploy-config vllm_omni/deploy/soulxsinger_${MODE}.yaml \
--port 8192 --trust-remote-code --enforce-eager
```
### Sending requests
Audio paths must be reachable from the server host (local filesystem or data URL). The client sends prompt vocal via `input_audio` and target accompaniment via `extra_args['target_audio']`.
```bash
# Default demo audio: tests/assets/soulxsinger/zh_prompt.mp3 + music.mp3
python examples/online_serving/text_to_speech/soulxsinger/openai_chat_client.py \
--prompt-audio /path/on/server/zh_prompt.mp3 \
--target-audio /path/on/server/music.mp3 \
--preprocess-weights-dir /path/on/server/SoulX-Singer-Preprocess \
-o output.wav
```
Use precomputed metadata to skip online preprocess with following command:
```bash
python examples/online_serving/text_to_speech/soulxsinger/openai_chat_client.py \
--prompt-metadata-path /path/on/server/zh_prompt.json \
--target-metadata-path /path/on/server/music.json \
--audio-path /path/on/server/zh_prompt.mp3 \
-o output.wav
```
`SOULX_PREPROCESS_WEIGHTS_DIR` makes `--preprocess-weights-dir` optional. See `openai_chat_client.py --help` for `--vocal-sep`, `--language`, `--num-inference-steps`, `--guidance-scale`, and `--seed`.
### Notes
- Output: 24 kHz mono WAV; batch only.
- Defaults match upstream: `--guidance-scale 3.0`, `--seed 42`, `--auto-shift` on.
- SVS `--control`: `score` or `melody`. MIDI / lyric QC: upstream `midi_editor` only.
@@ -1,186 +0,0 @@
#!/usr/bin/env python3
"""SoulX-Singer OpenAI-compatible chat client (SVS / SVC).
Sends prompt audio via ``input_audio`` and target accompaniment via
``extra_args['target_audio']`` (server-local path). For integrated preprocess,
also pass ``preprocess_weights_dir`` in ``extra_args``.
Usage:
python openai_chat_client.py \\
--prompt-audio /path/on/server/zh_prompt.mp3 \\
--target-audio /path/on/server/music.mp3 \\
--preprocess-weights-dir /path/on/server/SoulX-Singer-Preprocess \\
-o output.wav
"""
from __future__ import annotations
import argparse
import base64
import io
import sys
from pathlib import Path
import requests
import soundfile
import torch
def _audio_to_data_url(path: Path) -> str:
with path.open("rb") as handle:
data = base64.b64encode(handle.read()).decode("ascii")
return f"data:audio/mpeg;base64,{data}"
def _save_wav(audio: torch.Tensor, path: Path, sample_rate: int) -> None:
audio = audio.to(torch.float32)
peak = audio.abs().max().clamp(min=1e-8)
audio = audio / peak
path.parent.mkdir(parents=True, exist_ok=True)
soundfile.write(str(path), audio.clamp(-1.0, 1.0).cpu().T.numpy(), sample_rate, subtype="PCM_16")
def _decode_audio_from_response(body: dict) -> tuple[torch.Tensor, int]:
for choice in body.get("choices", []):
audio_obj = choice.get("message", {}).get("audio")
if isinstance(audio_obj, dict) and audio_obj.get("data"):
data, sr = soundfile.read(
io.BytesIO(base64.b64decode(audio_obj["data"])),
dtype="float32",
always_2d=True,
)
return torch.from_numpy(data).transpose(0, 1), sr
brief = {k: v for k, v in body.items() if k != "choices"}
raise RuntimeError(f"no audio in response message.audio: {brief}")
def main() -> int:
repo_root = Path(__file__).resolve().parents[4]
default_assets = repo_root / "tests" / "assets" / "soulxsinger"
parser = argparse.ArgumentParser(description="SoulX-Singer online chat client")
parser.add_argument("--port", type=int, default=8192)
parser.add_argument("--model", default="Soul-AILab/SoulX-Singer")
parser.add_argument(
"--prompt-audio",
default=str(default_assets / "zh_prompt.mp3"),
help="Prompt vocal audio (path on server if using extra_args, or local for input_audio)",
)
parser.add_argument(
"--target-audio",
default=str(default_assets / "music.mp3"),
help="Target accompaniment path on the server (extra_args['target_audio'])",
)
parser.add_argument(
"--prompt-metadata-path",
default=None,
help="SVS precomputed prompt metadata.json",
)
parser.add_argument(
"--target-metadata-path",
default=None,
help="SVS precomputed target metadata.json",
)
parser.add_argument(
"--audio-path",
default=None,
help="SVS prompt vocal wav for precomputed metadata",
)
parser.add_argument("--preprocess-weights-dir", default=None)
parser.add_argument("--output", "-o", default="soulxsinger_out.wav")
parser.add_argument("--svc", action="store_true", help="Use SVC mode knobs")
parser.add_argument("--language", default="Mandarin")
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 seed. Omit for non-deterministic sampling.",
)
parser.add_argument(
"--auto-shift",
action=argparse.BooleanOptionalAction,
default=True,
help="Auto pitch shift (default: on, original upstream infer.sh)",
)
parser.add_argument(
"--control",
default="melody",
choices=["melody", "score"],
help="SVS control mode",
)
parser.add_argument("--vocal-sep", action="store_true")
args = parser.parse_args()
meta_paths = (args.prompt_metadata_path, args.target_metadata_path, args.audio_path)
if any(meta_paths) and not all(meta_paths):
print(
"ERROR: precomputed metadata requires --prompt-metadata-path, "
"--target-metadata-path, and --audio-path together.",
file=sys.stderr,
)
return 2
extra_args: dict = {
"vocal_sep": args.vocal_sep,
"auto_shift": args.auto_shift,
"pitch_shift": 0,
}
if all(meta_paths):
extra_args.update(
{
"prompt_metadata_path": str(Path(args.prompt_metadata_path).expanduser().resolve()),
"target_metadata_path": str(Path(args.target_metadata_path).expanduser().resolve()),
"audio_path": str(Path(args.audio_path).expanduser().resolve()),
}
)
content = [{"type": "text", "text": "soulx-singer"}]
else:
prompt_path = Path(args.prompt_audio).expanduser().resolve()
if not prompt_path.is_file():
print(f"ERROR: prompt audio not found: {prompt_path}", file=sys.stderr)
return 2
extra_args["prompt_audio"] = str(prompt_path)
extra_args["target_audio"] = str(Path(args.target_audio).expanduser().resolve())
if args.preprocess_weights_dir:
extra_args["preprocess_weights_dir"] = str(Path(args.preprocess_weights_dir).expanduser().resolve())
content = [
{"type": "text", "text": "soulx-singer"},
{
"type": "input_audio",
"input_audio": {"data": _audio_to_data_url(prompt_path), "format": "mp3"},
},
]
if not args.svc:
extra_args["language"] = args.language
extra_args["control"] = args.control
payload = {
"model": args.model,
"modalities": ["audio"],
"messages": [{"role": "user", "content": content}],
"num_inference_steps": args.num_inference_steps,
"guidance_scale": args.guidance_scale,
"extra_args": extra_args,
}
if args.seed is not None:
payload["seed"] = args.seed
print(f"POST http://localhost:{args.port}/v1/chat/completions")
response = requests.post(
f"http://localhost:{args.port}/v1/chat/completions",
headers={"Content-Type": "application/json"},
json=payload,
timeout=1800,
)
response.raise_for_status()
audio, sample_rate = _decode_audio_from_response(response.json())
_save_wav(audio, Path(args.output), sample_rate)
duration = audio.shape[-1] / sample_rate
print(f"saved {args.output} sr={sample_rate}Hz duration={duration:.2f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,39 +0,0 @@
#!/bin/bash
# Launch vLLM-Omni server for SoulX-Singer (single-stage DiT, preprocess inline).
#
# Usage:
# MODEL=/path/to/SoulX-Singer PREPROCESS=/path/to/Preprocess \
#
# Audio paths in client extra_args must be readable on the server host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
MODEL="${MODEL:-Soul-AILab/SoulX-Singer}"
MODE="${MODE:-svs}"
PORT="${PORT:-8192}"
GPUS="${GPUS:-0}"
if [[ "$MODE" == "svc" ]]; then
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$REPO_ROOT/vllm_omni/deploy/soulxsinger_svc.yaml}"
else
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$REPO_ROOT/vllm_omni/deploy/soulxsinger_svs.yaml}"
fi
echo "Starting SoulX-Singer server"
echo " MODEL=$MODEL"
echo " MODE=$MODE"
echo " PORT=$PORT"
echo " DEPLOY_CONFIG=$DEPLOY_CONFIG"
echo " CUDA_VISIBLE_DEVICES=$GPUS"
CUDA_VISIBLE_DEVICES="$GPUS" \
vllm serve "$MODEL" \
--omni \
--deploy-config "$DEPLOY_CONFIG" \
--host 0.0.0.0 \
--port "$PORT" \
--trust-remote-code \
--enforce-eager
-7
View File
@@ -135,13 +135,6 @@ docs = [
"pydantic",
]
soulx-svs = [
"funasr>=1.0.0",
"g2pM>=0.1.2.5",
"g2p-en>=2.1.0",
"ToJyutping>=3.2.0",
]
[project.urls]
Homepage = "https://github.com/vllm-project/vllm-omni"
Repository = "https://github.com/vllm-project/vllm-omni"
-16
View File
@@ -1,16 +0,0 @@
[
{
"index": "vocal_240_51240",
"language": "Mandarin",
"time": [
240,
51240
],
"duration": "0.21 0.18 0.34 0.38 0.26 0.22 0.50 0.20 0.33 0.13 0.60 0.22 0.38 0.52 0.30 0.12 1.82 0.98 0.20 0.26 0.38 0.36 0.58 0.54 0.26 0.50 1.46 3.03 0.24 0.24 0.29 0.25 0.20 0.14 0.80 0.22 0.54 0.30 0.24 0.16 0.58 0.28 0.38 1.73 0.79 0.24 0.30 0.34 0.30 0.30 0.34 0.21 0.21 0.36 0.34 0.23 1.85 1.90 0.23 0.39 0.68 0.50 0.31 0.43 0.76 0.38 2.00 1.87 0.68 0.72 0.56 0.62 0.80 0.40 0.42 1.68 1.79 0.70 0.66 0.54 0.24 0.48 0.68 0.40 2.34 0.14",
"text": "<SP> 只 是 因 为 为 在 人 群 群 中 多 看 了 你 一 眼 <SP> 再 也 没 能 忘 掉 你 容 颜 <SP> 梦 想 着 着 偶 偶 然 然 有 <SP> 一 一 天 再 相 见 <SP> 从 此 我 开 始 始 孤 孤 单 思 念 念 <SP> 想 想 你 时 你 你 在 天 边 <SP> 想 你 时 你 在 眼 前 前 <SP> 想 你 时 你 你 在 脑 海 <SP>",
"phoneme": "<SP> zh_zhi3 zh_shi4 zh_yin1 zh_wei4 zh_wei2 zh_zai4 zh_ren2 zh_qun2 zh_qun2 zh_zhong1 zh_duo1 zh_kan4 zh_le5 zh_ni3 zh_yi1 zh_yan3 <SP> zh_zai4 zh_ye3 zh_mei2 zh_neng2 zh_wang4 zh_diao4 zh_ni3 zh_rong2 zh_yan2 <SP> zh_meng4 zh_xiang3 zh_zhe5 zh_zhe5 zh_ou3 zh_ou3 zh_ran2 zh_ran2 zh_you3 <SP> zh_yi1 zh_yi1 zh_tian1 zh_zai4 zh_xiang1 zh_jian4 <SP> zh_cong2 zh_ci3 zh_wo3 zh_kai1 zh_shi3 zh_shi3 zh_gu1 zh_gu1 zh_dan1 zh_si1 zh_nian4 zh_nian4 <SP> zh_xiang3 zh_xiang3 zh_ni3 zh_shi2 zh_ni3 zh_ni3 zh_zai4 zh_tian1 zh_bian1 <SP> zh_xiang3 zh_ni3 zh_shi2 zh_ni3 zh_zai4 zh_yan3 zh_qian2 zh_qian2 <SP> zh_xiang3 zh_ni3 zh_shi2 zh_ni3 zh_ni3 zh_zai4 zh_nao3 zh_hai3 <SP>",
"note_pitch": "0 64 64 64 66 68 66 66 66 64 64 64 64 66 64 60 61 0 63 63 63 64 66 63 61 59 56 0 68 66 66 68 68 66 66 64 64 0 64 66 61 61 61 64 0 62 63 63 64 64 66 65 66 61 58 59 56 0 69 71 66 68 68 71 66 64 61 0 66 61 68 66 64 63 61 59 0 71 66 68 68 71 66 64 61 0",
"note_type": "1 2 2 2 2 3 2 2 2 3 2 2 2 2 2 2 2 1 2 2 2 2 2 2 2 2 2 1 2 2 2 3 2 3 2 3 2 1 2 3 2 2 2 2 1 2 2 2 2 2 3 2 3 2 2 2 3 1 2 3 2 2 2 3 2 2 2 1 2 2 2 2 2 2 2 3 1 2 2 2 2 3 2 2 2 1",
"f0": "0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 318.9 332.2 331.2 323.9 312.3 0.0 0.0 0.0 0.0 0.0 0.0 340.9 342.3 340.2 337.4 333.5 329.3 327.8 328.3 329.1 330.5 331.9 331.5 329.2 327.6 327.4 327.7 329.6 330.5 329.5 328.1 328.5 330.1 330.4 329.5 328.9 331.0 333.6 332.8 331.7 330.2 330.6 330.9 329.5 327.6 332.4 343.5 363.9 368.7 369.3 368.2 366.7 371.3 382.0 392.9 402.3 409.8 413.8 416.3 416.4 415.4 414.1 413.4 415.3 417.4 418.3 413.6 416.0 0.0 0.0 0.0 0.0 368.2 362.5 361.3 362.0 362.9 364.3 366.4 367.5 368.4 368.9 368.1 367.6 369.7 371.1 371.1 369.5 368.0 367.3 366.6 367.1 369.5 372.0 371.3 368.2 368.1 369.6 369.9 373.4 376.5 360.2 285.6 0.0 0.0 0.0 0.0 0.0 0.0 0.0 384.0 376.7 372.0 371.7 373.9 376.3 375.5 361.2 333.0 317.8 319.0 329.1 333.5 333.2 333.2 333.3 328.0 314.5 0.0 0.0 0.0 320.6 326.5 332.9 335.7 334.1 329.9 327.4 326.3 326.6 329.1 330.5 330.0 328.0 326.7 328.6 331.1 332.4 332.1 332.8 333.2 333.1 331.8 328.6 318.9 293.2 323.6 327.5 330.0 332.8 331.1 319.4 272.2 0.0 0.0 0.0 0.0 278.5 294.5 310.0 318.5 322.7 327.4 334.1 337.2 336.3 328.5 321.0 324.2 341.5 362.2 374.7 375.8 370.5 366.0 365.2 368.7 371.7 374.4 373.0 368.7 370.4 374.8 375.1 372.5 368.7 363.0 357.1 359.0 366.8 377.1 379.5 371.5 359.9 351.0 358.4 371.7 377.9 375.8 367.8 359.7 363.6 375.3 379.9 377.5 372.9 359.8 345.2 337.1 334.5 333.4 332.8 329.6 319.2 292.8 261.7 253.4 262.8 273.0 278.1 279.3 278.6 277.6 277.9 278.3 278.0 277.3 276.7 275.6 275.3 276.1 277.8 278.2 277.7 277.6 277.2 276.4 275.5 273.9 271.8 270.7 274.7 282.7 285.5 281.4 273.1 264.4 262.2 268.9 278.4 284.3 283.6 277.3 268.5 262.2 263.2 270.7 278.8 285.0 287.3 282.0 272.1 265.2 266.0 272.1 278.2 283.8 285.1 281.4 272.6 267.2 269.1 276.1 282.7 289.3 290.5 285.6 273.9 267.5 267.4 271.8 277.7 282.6 283.1 277.8 269.8 265.6 268.6 273.8 281.1 286.2 287.2 282.3 270.4 264.5 263.3 263.0 268.5 268.3 268.7 270.9 277.2 277.4 284.3 285.7 282.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 311.5 306.4 306.0 306.8 305.8 305.0 307.2 310.8 312.4 313.5 314.4 314.6 313.7 312.2 311.1 311.1 312.8 314.6 312.7 306.5 299.7 302.7 308.7 315.2 314.2 310.9 309.4 309.8 310.6 310.7 310.8 311.4 312.2 313.0 312.5 312.2 312.2 311.9 312.0 312.2 311.4 306.1 303.3 309.3 315.0 325.3 324.4 323.9 325.0 326.4 327.7 327.7 328.1 328.6 327.8 328.3 328.3 327.6 328.6 336.1 347.2 359.4 371.0 374.9 371.3 366.5 369.5 375.5 376.2 370.2 365.7 367.3 372.9 376.9 375.3 368.4 360.1 358.0 365.4 380.8 383.5 380.4 374.4 367.6 365.9 372.0 376.3 377.9 375.7 372.6 368.6 361.2 353.7 0.0 309.4 302.3 299.2 300.6 305.7 308.5 308.6 309.0 310.9 310.9 309.5 308.6 309.0 311.3 313.0 314.4 314.5 313.0 312.1 311.2 308.0 299.5 295.8 295.0 285.7 278.0 280.1 281.2 279.9 278.3 278.3 279.5 279.7 272.2 259.1 0.0 0.0 0.0 240.7 236.0 233.8 233.5 234.6 235.7 235.6 236.2 239.8 245.4 248.8 250.1 250.4 249.9 249.1 247.2 241.7 231.7 216.0 197.6 192.1 197.2 204.3 206.5 205.4 201.7 200.8 203.5 208.2 210.9 210.3 207.1 203.5 202.3 202.1 202.8 205.1 207.3 208.1 207.3 205.4 202.1 199.3 200.5 206.1 212.9 212.9 206.5 197.1 191.1 191.2 196.8 203.6 207.8 208.7 206.4 201.7 197.3 197.4 200.7 206.5 209.9 209.9 206.9 203.3 201.2 203.4 207.7 210.5 208.9 207.6 206.5 204.2 202.0 203.6 205.7 210.8 213.1 214.3 210.6 204.1 199.7 202.3 211.9 217.7 215.1 215.0 215.3 213.3 0.0 0.0 216.3 0.0 0.0 195.2 209.0 205.3 201.3 196.8 195.7 195.8 195.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 431.9 425.8 421.0 417.7 419.3 422.7 423.4 421.1 417.9 411.9 408.6 411.5 414.9 419.6 426.7 424.2 0.0 0.0 0.0 280.8 279.4 369.7 371.8 370.6 369.8 370.5 370.5 371.4 374.6 374.0 361.0 332.7 328.0 0.0 359.0 365.0 371.1 373.8 371.9 369.7 369.6 371.0 378.3 390.4 403.4 414.1 417.9 417.5 417.0 416.6 415.7 414.9 414.5 413.8 413.5 413.6 413.8 415.2 416.9 417.3 415.4 415.0 414.1 411.4 409.2 403.5 392.7 375.6 367.5 365.8 368.8 370.7 370.8 369.0 367.0 366.0 366.2 367.8 369.2 368.8 367.2 366.7 367.3 367.3 368.8 368.5 366.8 365.3 364.0 363.0 365.0 367.3 367.7 365.5 364.0 364.9 367.4 370.3 371.3 369.9 366.5 364.1 368.3 385.8 406.7 409.4 399.7 376.0 357.2 359.9 372.4 377.5 366.9 345.1 320.6 315.8 321.8 329.0 329.9 326.4 324.5 324.8 324.9 325.3 325.1 324.7 325.9 326.8 326.6 326.6 326.4 325.4 323.2 322.5 323.9 326.2 328.6 329.9 329.5 329.0 328.2 327.7 327.9 329.6 331.2 331.4 334.2 340.1 335.5 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.Line truncated
}
]
Binary file not shown.
-16
View File
@@ -1,16 +0,0 @@
[
{
"index": "vocal_320_10687",
"language": "Mandarin",
"time": [
320,
10687
],
"duration": "0.23 0.34 0.26 0.70 0.52 0.46 0.36 0.44 0.14 0.24 0.64 0.47 0.51 1.10 0.28 0.38 0.32 0.32 0.38 0.32 0.31 0.19 1.45",
"text": "<SP> 除 了 想 你 你 <SP> 除 了 了 爱 你 你 <SP> 我 什 么 什 么 都 愿 愿 意",
"phoneme": "<SP> zh_chu2 zh_le5 zh_xiang3 zh_ni3 zh_ni3 <SP> zh_chu2 zh_le5 zh_le5 zh_ai4 zh_ni3 zh_ni3 <SP> zh_wo3 zh_shen2 zh_me5 zh_shen2 zh_me5 zh_dou1 zh_yuan4 zh_yuan4 zh_yi4",
"note_pitch": "0 62 65 67 67 69 0 67 69 67 65 67 69 67 67 66 64 64 60 60 65 67 0",
"note_type": "1 2 2 2 2 3 1 2 2 3 2 2 3 1 2 2 2 2 2 2 2 3 2",
"f0": "0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 294.1 288.2 290.6 294.6 295.7 292.8 291.5 294.4 295.4 294.7 293.5 292.2 294.4 295.8 293.2 297.7 320.1 338.1 348.3 348.7 344.4 342.6 346.2 354.8 356.9 353.1 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 401.1 403.8 405.0 402.1 398.4 395.6 393.3 392.0 391.4 390.3 390.2 390.3 390.3 391.3 392.8 393.6 391.7 390.5 391.4 391.6 391.5 393.5 393.8 390.8 387.4 387.8 389.3 390.8 392.2 391.6 390.2 389.8 389.1 388.4 390.0 395.5 397.2 396.7 395.5 395.1 394.6 394.9 395.6 395.2 394.6 395.4 395.9 394.0 391.7 390.7 391.7 392.6 391.6 395.7 405.8 441.7 462.5 463.8 450.2 430.9 414.5 415.2 426.7 439.8 454.4 462.9 447.8 422.5 400.6 403.2 423.5 451.3 482.3 492.8 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 435.9 414.3 406.2 402.8 398.2 396.7 396.7 395.2 398.3 416.9 441.6 446.9 441.5 437.3 435.3 432.9 431.6 432.3 434.8 436.7 433.8 422.8 406.7 390.9 382.0 381.7 384.7 382.7 368.1 357.4 355.6 355.1 352.4 348.1 346.4 348.7 351.6 355.0 354.4 351.7 349.9 349.2 348.1 346.0 345.4 344.2 344.4 345.5 346.6 349.0 349.7 349.1 349.5 349.6 349.7 349.4 349.5 352.2 354.5 355.7 355.6 356.9 359.4 361.5 363.8 360.4 354.3 357.2 363.9 372.4 382.6 399.1 402.9 400.6 395.5 390.5 388.9 390.2 391.1 391.9 391.5 390.4 390.2 391.3 391.6 391.0 388.6 386.2 389.1 403.8 430.2 441.8 449.5 448.1 443.2 438.3 432.9 430.7 434.6 442.0 447.6 446.0 440.3 434.7 431.1 435.7 442.3 445.9 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 423.8 402.1 398.5 397.6 393.4 390.5 391.4 402.5 427.6 442.5 442.5 435.1 430.1 430.8 439.9 447.4 442.2 426.1 412.3 399.8 391.8 389.5 388.5 387.8 386.4 384.7 384.5 387.9 391.2 391.8 392.9 393.8 392.0 392.0 395.4 398.1 398.2 396.3 393.1 391.1 388.9 386.6 383.1 381.5 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 357.8 367.6 370.9 365.4 359.3 355.7 358.1 372.7 396.8 404.0 398.4 392.6 389.2 388.8 383.6 362.3 341.1 325.1 326.3 327.9 331.3 333.3 326.0 319.4 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 356.4 353.5 349.0 346.5 338.4 328.4 323.7 326.2 334.2 338.4 331.6 309.8 280.6 256.4 252.8 256.1 258.3 256.9 257.6 261.1 259.7 258.7 258.6 260.2 262.2 262.4 262.9 264.2 263.4 260.2 256.4 253.2 238.7 223.3 231.6 257.7 258.1 258.4 258.8 258.0 256.8 255.8 254.0 255.1 258.1 261.9 263.7 262.6 256.7 253.1 250.2 246.7 258.7 294.4 327.1 342.6 346.0 344.1 341.2 342.3 345.2 350.1 364.7 382.5 396.1 396.1 389.2 381.8 381.1 387.2 397.0 399.3 390.7 374.3 360.1 350.6 346.6 347.4 350.7 354.4 354.3 351.7 349.8 348.4 346.9 347.0 348.4 349.5 351.0 352.3 353.6 353.3 350.5 348.3 345.5 344.3 344.4 347.0 350.6 352.0 351.0 350.8 350.1 347.6 345.7 347.0 350.3 351.7 350.7 348.5 346.9 347.7 349.0 349.1 348.5 346.8 346.3 348.4 349.0 349.2 351.1 349.6 348.3 350.5 351.1 348.0 347.6 349.1 351.3 356.0 361.3 360.6 354.0 341.0 316.2 302.9 302.1 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0"
}
]
Binary file not shown.
@@ -1,241 +0,0 @@
"""E2E offline inference tests for SoulX-Singer (single-stage, preprocess inline)."""
import functools
import importlib
import json
import os
import shutil
from pathlib import Path
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import numpy as np
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
PROMPT_AUDIO = get_asset_path("soulxsinger/zh_prompt.mp3")
TARGET_AUDIO = get_asset_path("soulxsinger/music.mp3")
PROMPT_METADATA = get_asset_path("soulxsinger/zh_prompt.json")
TARGET_METADATA = get_asset_path("soulxsinger/music.json")
SAMPLE_RATE = 24_000
# phone_set.json (phoneme vocab) is not shipped on HuggingFace; SVS loads it
# from the model dir. Stage it from this pinned upstream commit so the SVS DiT
# can run in CI; the SVS tests skip gracefully if it cannot be fetched.
_PHONE_SET_URL = (
"https://raw.githubusercontent.com/Soul-AILab/SoulX-Singer/"
"81aeb3ae772c70093c3de74dc23c92d983801ae4/soulxsinger/utils/phoneme/phone_set.json"
)
if not PROMPT_AUDIO.is_file() or not TARGET_AUDIO.is_file():
pytest.skip(
f"Missing SoulX-Singer audio assets: {PROMPT_AUDIO.name}, {TARGET_AUDIO.name}",
allow_module_level=True,
)
pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.tts]
_CASES = (
pytest.param(
"SoulXSingerPipeline",
"soulxsinger_svs.yaml",
{
"language": "Mandarin",
"vocal_sep": False,
"control": "score",
"auto_shift": False,
"pitch_shift": 0,
},
("g2pM", "g2p_en"),
id="svs",
),
pytest.param(
"SoulXSingerSVCPipeline",
"soulxsinger_svc.yaml",
{"vocal_sep": False, "auto_shift": False, "pitch_shift": 0},
(),
id="svc",
),
)
@functools.lru_cache(maxsize=1)
def _resolve_weights() -> tuple[Path, Path, Path]:
from huggingface_hub import snapshot_download
base = Path(snapshot_download("Soul-AILab/SoulX-Singer", allow_patterns=["*"]))
# phone_set.json is not on HF; best-effort stage it from pinned upstream so
# SVS can load. SVS tests skip (not fail) if both this and a manual copy are absent.
phone_set = base / "phoneme" / "phone_set.json"
if not phone_set.is_file() and not (base / "phone_set.json").is_file():
try:
from urllib.request import urlretrieve
phone_set.parent.mkdir(exist_ok=True)
urlretrieve(_PHONE_SET_URL, phone_set)
except Exception:
pass
# make a temporary svc directory
svc_dir = base.parent / "SoulX-Singer-SVC"
svc_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(base / "config.yaml", svc_dir / "config.yaml")
shutil.move(base / "model-svc.pt", svc_dir / "model-svc.pt")
if raw := os.environ.get("SOULX_PREPROCESS_WEIGHTS_DIR"):
pre = Path(raw).expanduser().resolve()
if (pre / "rmvpe" / "rmvpe.pt").is_file():
return base, svc_dir, pre
from huggingface_hub import snapshot_download
pre = Path(snapshot_download("Soul-AILab/SoulX-Singer-Preprocess", allow_patterns=["*"]))
return base, svc_dir, pre
@pytest.fixture(scope="session")
def soulx_weights() -> tuple[Path, Path, Path]:
try:
return _resolve_weights()
except Exception as exc:
pytest.skip(f"Set SOULXSINGER_MODEL_DIR / SOULX_PREPROCESS_WEIGHTS_DIR. ({exc})")
def _flatten_audio(audio_val) -> np.ndarray:
import torch
if isinstance(audio_val, list):
chunks = [c.detach().cpu().float().numpy().reshape(-1) for c in audio_val if c is not None]
return np.concatenate(chunks) if chunks else np.array([], dtype=np.float32)
if isinstance(audio_val, torch.Tensor):
return audio_val.detach().cpu().float().numpy().reshape(-1)
return np.asarray(audio_val, dtype=np.float32).reshape(-1)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("architecture,deploy_yaml,extra_args,py_deps", _CASES)
def test_soulxsinger_multistage_from_audio(
soulx_weights: tuple[Path, Path, Path],
architecture: str,
deploy_yaml: str,
extra_args: dict,
py_deps: tuple[str, ...],
) -> None:
for mod in py_deps:
try:
importlib.import_module(mod)
except ImportError as exc:
pytest.fail(f"SoulX SVS requires {mod}: {exc}")
base_dir, svc_dir, preprocess_dir = soulx_weights
# SVS mode requires phone_set.json in the model directory
if architecture == "SoulXSingerPipeline":
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerPipeline"], "max_num_seqs": 1},
open(base_dir / "config.json", "w"),
indent=4,
)
model = str(base_dir)
if not (base_dir / "phoneme" / "phone_set.json").is_file() and not (base_dir / "phone_set.json").is_file():
pytest.skip(
"SoulX-Singer SVS test requires phoneme/phone_set.json. "
"Copy it from github.com/Soul-AILab/SoulX-Singer into the model dir. "
"See `examples/offline_inference/text_to_speech/README.md` for details."
)
if architecture == "SoulXSingerSVCPipeline":
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerSVCPipeline"], "max_num_seqs": 1},
open(svc_dir / "config.json", "w"),
indent=4,
)
model = str(svc_dir)
with OmniRunner(
model,
deploy_config=get_deploy_config_path(deploy_yaml),
async_chunk=False,
) as runner:
sampling = OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=3.0,
seed=42,
extra_args={
"prompt_audio": str(PROMPT_AUDIO),
"target_audio": str(TARGET_AUDIO),
"preprocess_weights_dir": str(preprocess_dir),
**extra_args,
},
)
prompt = {"prompt_token_ids": [0]}
outputs = runner.generate([prompt], sampling)
assert outputs and outputs[0].error is None, outputs[0].error if outputs else "no output"
mm = outputs[0].multimodal_output
assert isinstance(mm, dict) and "audio" in mm
audio = _flatten_audio(mm["audio"])
assert 12_000 <= audio.size
assert np.isfinite(audio).all() and float(np.max(np.abs(audio))) > 1e-4
duration_s = audio.size / SAMPLE_RATE
assert 50.0 <= duration_s <= 52.0, f"duration={duration_s:.1f}s"
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_soulxsinger_svs_precomputed(soulx_weights: tuple[Path, Path, Path]) -> None:
"""SVS from precomputed note/lyric metadata.
Exercises the SVS DiT + vocoder serving path while skipping the heavy
upstream preprocess (funasr ASR + ROSVOT note transcription), so it runs in
the merge gate without those non-pip dependencies.
"""
base_dir, _svc_dir, preprocess_dir = soulx_weights
if not PROMPT_METADATA.is_file() or not TARGET_METADATA.is_file():
pytest.skip(f"Missing SoulX-Singer metadata fixtures: {PROMPT_METADATA.name}, {TARGET_METADATA.name}")
if not (base_dir / "phoneme" / "phone_set.json").is_file() and not (base_dir / "phone_set.json").is_file():
pytest.skip(
"SoulX-Singer SVS requires phoneme/phone_set.json (upstream fetch unavailable). "
"See `examples/offline_inference/text_to_speech/README.md`."
)
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerPipeline"], "max_num_seqs": 1},
open(base_dir / "config.json", "w"),
indent=4,
)
with OmniRunner(
str(base_dir),
deploy_config=get_deploy_config_path("soulxsinger_svs.yaml"),
async_chunk=False,
) as runner:
sampling = OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=3.0,
seed=42,
extra_args={
"language": "Mandarin",
"control": "score",
"prompt_metadata_path": str(PROMPT_METADATA),
"target_metadata_path": str(TARGET_METADATA),
"audio_path": str(PROMPT_AUDIO),
"preprocess_weights_dir": str(preprocess_dir),
},
)
prompt = {"prompt_token_ids": [0]}
outputs = runner.generate([prompt], sampling)
assert outputs and outputs[0].error is None, outputs[0].error if outputs else "no output"
mm = outputs[0].multimodal_output
assert isinstance(mm, dict) and "audio" in mm
audio = _flatten_audio(mm["audio"])
assert 12_000 <= audio.size
assert np.isfinite(audio).all() and float(np.max(np.abs(audio))) > 1e-4
duration_s = audio.size / SAMPLE_RATE
assert 50.0 <= duration_s <= 52.0, f"duration={duration_s:.1f}s"
@@ -78,8 +78,6 @@ EXCLUDED_MODELS = [
"StableDiffusionXLPipeline",
"Gr00tN1d7Pipeline",
"Pi0Pipeline",
"SoulXSingerPipeline",
"SoulXSingerSVCPipeline",
"SanaWmPipeline",
]
-6
View File
@@ -103,10 +103,6 @@ from vllm_omni.model_executor.models.qwen2_5_omni.pipeline import (
)
from vllm_omni.model_executor.models.qwen3_omni.pipeline import resolve_qwen3_omni_pipeline
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE
from vllm_omni.model_executor.models.soulx_singer.pipeline import (
SOULXSINGER_SVC_PIPELINE,
SOULXSINGER_SVS_PIPELINE,
)
from vllm_omni.model_executor.models.step_audio2.pipeline import (
STEP_AUDIO2_ASR_PIPELINE,
STEP_AUDIO2_PIPELINE,
@@ -184,8 +180,6 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"dynin_omni": DYNIN_OMNI_PIPELINE,
"indextts2": INDEXTTS2_PIPELINE,
"indextts2_5": INDEXTTS25_PIPELINE,
"soulxsinger_svc": SOULXSINGER_SVC_PIPELINE,
"soulxsinger_svs": SOULXSINGER_SVS_PIPELINE,
}
-19
View File
@@ -1,19 +0,0 @@
# SoulX-Singer SVC: single-stage DiT (preprocess inline in pre_process_func).
#
# Use: vllm serve <SoulX-Singer-svc> --omni
pipeline: soulxsinger_svc
async_chunk: false
dtype: float16
trust_remote_code: true
distributed_executor_backend: "mp"
stages:
- stage_id: 0
max_num_seqs: 1
gpu_memory_utilization: 0.5
enforce_eager: false
devices: "0"
default_sampling_params:
num_inference_steps: 32
guidance_scale: 3.0
seed: 42
-19
View File
@@ -1,19 +0,0 @@
# SoulX-Singer SVS: single-stage DiT (preprocess inline in pre_process_func).
#
# Use: vllm serve <SoulX-Singer> --omni
pipeline: soulxsinger_svs
async_chunk: false
dtype: float16
trust_remote_code: true
distributed_executor_backend: "mp"
stages:
- stage_id: 0
max_num_seqs: 1
gpu_memory_utilization: 0.5
enforce_eager: false
devices: "0"
default_sampling_params:
num_inference_steps: 32
guidance_scale: 3.0
seed: 42
@@ -1,44 +0,0 @@
"""SoulX-Singer SVS / SVC flow-matching singing models for vLLM-Omni."""
__all__ = [
"FlowMatchingAudioPipeline",
"PipelineSoulXSingerSVS",
"PipelineSoulXSingerSVC",
"get_soulxsinger_post_process_func",
"get_soulxsinger_pre_process_func",
"get_soulxsinger_svc_pre_process_func",
]
def __getattr__(name: str):
if name in {"FlowMatchingAudioPipeline", "get_soulxsinger_post_process_func"}:
from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_base import (
FlowMatchingAudioPipeline,
get_soulxsinger_post_process_func,
)
return {
"FlowMatchingAudioPipeline": FlowMatchingAudioPipeline,
"get_soulxsinger_post_process_func": get_soulxsinger_post_process_func,
}[name]
if name in {"PipelineSoulXSingerSVC", "get_soulxsinger_svc_pre_process_func"}:
from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_svc import (
PipelineSoulXSingerSVC,
get_soulxsinger_svc_pre_process_func,
)
return {
"PipelineSoulXSingerSVC": PipelineSoulXSingerSVC,
"get_soulxsinger_svc_pre_process_func": get_soulxsinger_svc_pre_process_func,
}[name]
if name in {"PipelineSoulXSingerSVS", "get_soulxsinger_pre_process_func"}:
from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_svs import (
PipelineSoulXSingerSVS,
get_soulxsinger_pre_process_func,
)
return {
"PipelineSoulXSingerSVS": PipelineSoulXSingerSVS,
"get_soulxsinger_pre_process_func": get_soulxsinger_pre_process_func,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,17 +0,0 @@
from .convnext import ConvNeXtV2Block
from .decoder import CFMDecoder
from .flow_matching import FlowMatchingTransformer
from .llama import DiffLlama
from .mel_transform import MelSpectrogramEncoder
from .vocoder import Vocoder
from .whisper_encoder import WhisperEncoder
__all__ = [
"ConvNeXtV2Block",
"FlowMatchingTransformer",
"CFMDecoder",
"DiffLlama",
"MelSpectrogramEncoder",
"Vocoder",
"WhisperEncoder",
]
@@ -1,46 +0,0 @@
import torch
import torch.nn as nn
class GRN(nn.Module):
def __init__(self, dim):
super().__init__()
self.gamma = nn.Parameter(torch.zeros(1, 1, dim))
self.beta = nn.Parameter(torch.zeros(1, 1, dim))
def forward(self, x):
Gx = torch.norm(x, p=2, dim=1, keepdim=True)
Nx = Gx / (Gx.mean(dim=-1, keepdim=True) + 1e-6)
return self.gamma * (x * Nx) + self.beta + x
# ref: https://github.com/SWivid/F5-TTS/blob/main/src/f5_tts/model/modules.py#L247
class ConvNeXtV2Block(nn.Module):
def __init__(
self,
dim: int,
intermediate_dim: int,
dilation: int = 1,
):
super().__init__()
padding = (dilation * (7 - 1)) // 2
self.dwconv = nn.Conv1d(
dim, dim, kernel_size=7, padding=padding, groups=dim, dilation=dilation
) # depthwise conv
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.pwconv1 = nn.Linear(dim, intermediate_dim) # pointwise/1x1 convs, implemented with linear layers
self.act = nn.GELU()
self.grn = GRN(intermediate_dim)
self.pwconv2 = nn.Linear(intermediate_dim, dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
x = x.transpose(1, 2) # b n d -> b d n
x = self.dwconv(x)
x = x.transpose(1, 2) # b d n -> b n d
x = self.norm(x)
x = self.pwconv1(x)
x = self.act(x)
x = self.grn(x)
x = self.pwconv2(x)
return residual + x
@@ -1,24 +0,0 @@
import torch
import torch.nn as nn
from vllm_omni.diffusion.models.soulx_singer.modules.flow_matching import FlowMatchingTransformer
class CFMDecoder(nn.Module):
def __init__(self, config):
super().__init__()
self.model = FlowMatchingTransformer(cfg=config, **config)
def forward(self, mel, x_mask, decoder_inp, is_prompt):
outputs = self.model(mel, x_mask, decoder_inp, is_prompt)
noise, x, flow_pred, final_mask, prompt_len = outputs["output"]
return noise, x, flow_pred, final_mask, prompt_len
def reverse_diffusion(self, pt_mel, pt_decoder_inp, gt_decoder_inp, n_timesteps=32, cfg=1):
diffusion_cond = torch.cat([pt_decoder_inp, gt_decoder_inp], dim=1)
diffusion_cond_emb = self.model.cond_emb(diffusion_cond)
diffusion_prompt = pt_mel
generated = self.model.reverse_diffusion(diffusion_cond_emb, diffusion_prompt, n_timesteps=n_timesteps, cfg=cfg)
return generated
@@ -1,423 +0,0 @@
# https://github.com/open-mmlab/Amphion/blob/main/models/svc/flow_matching_transformer/fmt_model.py
# Copyright (c) 2023 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import math
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import LlamaConfig
from vllm_omni.diffusion.models.soulx_singer.modules.llama import DiffLlama
class FlowMatchingTransformer(nn.Module):
def __init__(
self,
mel_dim=100,
hidden_size=1024,
num_layers=12,
num_heads=16,
cfg_drop_prob=0.2,
use_embedding=True,
cond_codebook_size=1024,
cond_scale_factor=1,
sigma=1e-5,
time_scheduler="linear",
cfg=None,
):
super().__init__()
self.cfg = cfg
if cfg is not None:
mel_dim = getattr(cfg, "mel_dim", mel_dim)
hidden_size = getattr(cfg, "hidden_size", hidden_size)
num_layers = getattr(cfg, "num_layers", num_layers)
num_heads = getattr(cfg, "num_heads", num_heads)
cfg_drop_prob = getattr(cfg, "cfg_drop_prob", cfg_drop_prob)
cond_codebook_size = getattr(cfg, "cond_codebook_size", cond_codebook_size)
time_scheduler = getattr(cfg, "time_scheduler", time_scheduler)
sigma = getattr(cfg, "sigma", sigma)
cond_scale_factor = getattr(cfg, "cond_scale_factor", cond_scale_factor)
self.mel_dim = mel_dim
self.hidden_size = hidden_size
self.num_layers = num_layers
self.num_heads = num_heads
self.cfg_drop_prob = cfg_drop_prob
self.cond_codebook_size = cond_codebook_size
self.time_scheduler = time_scheduler
self.sigma = sigma
self.cond_scale_factor = cond_scale_factor
if use_embedding:
self.cond_emb = nn.Embedding(cond_codebook_size, self.hidden_size)
else:
self.cond_emb = nn.Linear(cond_codebook_size, self.hidden_size)
if cond_scale_factor != 1:
self.do_resampling = True
assert np.log2(cond_scale_factor).is_integer()
up_layers = []
for _ in range(int(np.log2(cond_scale_factor))):
up_layers.extend(
[
nn.ConvTranspose1d(hidden_size, hidden_size, kernel_size=4, stride=2, padding=1),
nn.GELU(),
]
)
self.resampling_layers = nn.Sequential(*up_layers)
else:
self.do_resampling = False
### REPA: Use the Wav2Vec2Bert features to align. ###
self.use_repa = "repa" in cfg
self.repa_layer_index = None
if self.use_repa:
self.repa_layer_index = cfg.repa.layer_index
self.repa_mlp_layer = nn.Sequential(
nn.Linear(hidden_size, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, cfg.repa.output_dim),
)
### CTC: Use the ASR loss ###
self.use_ctc = "ctc" in cfg
self.ctc_layer_index = None
if self.use_ctc:
self.ctc_layer_index = cfg.ctc.layer_index
self.ctc_mlp_layer = nn.Sequential(
nn.Linear(hidden_size, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, cfg.ctc.output_dim),
)
self.reset_parameters()
# DiffLlama config must be overridden manually to make sure the
# internal RoPE head dimension is correct
llama_config = LlamaConfig(
vocab_size=0,
bos_token_id=None,
eos_token_id=None,
hidden_size=hidden_size,
num_attention_heads=num_heads,
num_hidden_layers=num_layers,
rms_norm_eps=1e-6,
output_hidden_states=False,
output_attentions=False,
use_cache=False,
)
self.diff_estimator = DiffLlama(
mel_dim=mel_dim, hidden_size=hidden_size, num_heads=num_heads, num_layers=num_layers, config=llama_config
)
self.sigma = sigma
@torch.no_grad()
def forward_diffusion(self, x, t, is_prompt=None):
"""
x: (B, T, mel_dim)
t: (B,)
"""
new_t = t
t = t.unsqueeze(-1).unsqueeze(-1)
z = torch.randn(x.shape, dtype=x.dtype, device=x.device, requires_grad=False) # (B, T, mel_dim)
# get prompt len
if torch.rand(1) <= self.cfg_drop_prob:
prompt_len = torch.zeros(x.shape[0]).to(x)
is_prompt = torch.zeros_like(x[:, :, 0])
else:
if is_prompt is None:
prompt_len = torch.randint(min(x.shape[1] // 4, 5), int(x.shape[1] * 0.4), (x.shape[0],)).to(
x.device
) # (B,)
# get is_prompt
is_prompt = torch.zeros_like(x[:, :, 0]) # (B, T)
col_indices = torch.arange(is_prompt.shape[1]).repeat(is_prompt.shape[0], 1).to(prompt_len) # (B, T)
is_prompt[col_indices < prompt_len.unsqueeze(1)] = 1 # (B, T) 1 if prompt
else:
prompt_len = is_prompt.sum(dim=1) # (B,)
mask = torch.ones_like(x[:, :, 0]) # mask if 1, not mask if 0
mask[is_prompt.bool()] = 0
mask = mask[:, :, None]
# flow matching: xt = (1 - (1 - sigma) * t) * x0 + t * x; where x0 ~ N(0, 1), x is a sample
# flow gt: x - (1 - sigma) * x0 = x - (1 - sigma) * noise
xt = ((1 - (1 - self.sigma) * t) * z + t * x) * mask + x * (1 - mask)
return xt, z, new_t, prompt_len, mask
def loss_t(self, x, x_mask, t, cond=None, is_prompt=None):
xt, z, new_t, prompt_len, mask = self.forward_diffusion(x, t, is_prompt)
noise = z
# drop all condition for cfg, so if prompt_len is 0, we also drop cond
if cond is not None:
cond = cond * torch.where(
prompt_len > 0,
torch.ones_like(prompt_len),
torch.zeros_like(prompt_len),
).to(cond.device).unsqueeze(-1).unsqueeze(-1)
dit_output = self.diff_estimator(xt, new_t, cond, x_mask, return_dict=True)
flow_pred = dit_output["output"] # (B, T, mel_dim)
# final mask used for loss calculation
final_mask = mask * x_mask[..., None] # (B, T, 1)
results = {"output": (noise, x, flow_pred, final_mask, prompt_len)}
if self.use_repa:
repa_hidden_states = dit_output["hidden_states"][self.repa_layer_index] # (B, T, hidden_size)
repa_pred = self.repa_mlp_layer(repa_hidden_states) # (B, T, repa_dim)
results["repa"] = repa_pred
if self.use_ctc:
ctc_hidden_states = dit_output["hidden_states"][self.ctc_layer_index] # (B, T, hidden_size)
ctc_pred = self.ctc_mlp_layer(ctc_hidden_states) # (B, T, ctc_dim)
results["ctc"] = ctc_pred
return results
def compute_loss(self, x, x_mask, cond=None, is_prompt=None):
# x0: (B, T, num_quantizer)
# x_mask: (B, T) mask is 0 for padding
t = torch.rand(x.shape[0], device=x.device, requires_grad=False)
t = torch.clamp(t, 1e-5, 1.0)
# generation is harder at the start than later; use a cosine
# scheduler for timestep t when time_scheduler == "cos".
if self.time_scheduler == "cos":
t = 1 - torch.cos(t * math.pi * 0.5)
else:
pass
return self.loss_t(x, x_mask, t, cond, is_prompt)
def reset_parameters(self):
def _reset_parameters(m):
if isinstance(m, nn.MultiheadAttention):
if m._qkv_same_embed_dim:
nn.init.normal_(m.in_proj_weight, std=0.02)
else:
nn.init.normal_(m.q_proj_weight, std=0.02)
nn.init.normal_(m.k_proj_weight, std=0.02)
nn.init.normal_(m.v_proj_weight, std=0.02)
if m.in_proj_bias is not None:
nn.init.constant_(m.in_proj_bias, 0.0)
nn.init.constant_(m.out_proj.bias, 0.0)
if m.bias_k is not None:
nn.init.xavier_normal_(m.bias_k)
if m.bias_v is not None:
nn.init.xavier_normal_(m.bias_v)
elif (
isinstance(m, nn.Conv1d)
or isinstance(m, nn.ConvTranspose1d)
or isinstance(m, nn.Conv2d)
or isinstance(m, nn.ConvTranspose2d)
):
m.weight.data.normal_(0.0, 0.02)
elif isinstance(m, nn.Linear):
m.weight.data.normal_(mean=0.0, std=0.02)
if m.bias is not None:
m.bias.data.zero_()
elif isinstance(m, nn.Embedding):
m.weight.data.normal_(mean=0.0, std=0.02)
if m.padding_idx is not None:
m.weight.data[m.padding_idx].zero_()
self.apply(_reset_parameters)
@torch.no_grad()
def reverse_diffusion(
self,
cond,
prompt,
x_mask=None,
prompt_mask=None,
n_timesteps=10,
cfg=1.0,
rescale_cfg=0.75,
):
h = 1.0 / n_timesteps
prompt_len = prompt.shape[1]
target_len = cond.shape[1] - prompt_len
trunk_dtype = cond.dtype
if x_mask is None:
x_mask = torch.ones(cond.shape[0], target_len, dtype=trunk_dtype, device=cond.device)
if prompt_mask is None:
prompt_mask = torch.ones(cond.shape[0], prompt_len, dtype=trunk_dtype, device=cond.device)
xt_mask = torch.cat([prompt_mask, x_mask], dim=1)
z = torch.randn(
(cond.shape[0], target_len, self.mel_dim),
dtype=trunk_dtype,
device=cond.device,
requires_grad=False,
)
xt = z
h_tensor = torch.tensor(h, dtype=trunk_dtype, device=cond.device)
# t from 0 to 1: x0 = z ~ N(0, 1)
for i in range(n_timesteps):
xt_input = torch.cat([prompt, xt], dim=1)
t = torch.full(
(z.shape[0],),
fill_value=(i + 0.5) * h,
dtype=trunk_dtype,
device=z.device,
)
flow_pred = self.diff_estimator(xt_input, t, cond, xt_mask)
flow_pred = flow_pred[:, prompt_len:, :]
# cfg
if cfg > 0:
uncond_flow_pred = self.diff_estimator(xt, t, torch.zeros_like(cond)[:, : xt.shape[1], :], x_mask)
pos_flow_pred_std = flow_pred.std()
flow_pred_cfg = flow_pred + cfg * (flow_pred - uncond_flow_pred)
rescale_flow_pred = flow_pred_cfg * pos_flow_pred_std / flow_pred_cfg.std()
flow_pred = rescale_cfg * rescale_flow_pred + (1 - rescale_cfg) * flow_pred_cfg
xt = xt + flow_pred * h_tensor
return xt
@torch.no_grad()
def reverse_diffusion_v2(
self,
cond,
prompt,
x_mask=None,
prompt_mask=None,
n_timesteps=10,
cfg=1.0,
rescale_cfg=0.75,
):
h = 1.0 / n_timesteps
prompt_len = prompt.shape[1]
target_len = cond.shape[1] - prompt_len * 2
if x_mask is None:
x_mask = torch.ones(cond.shape[0], target_len).to(cond.device) # (B, T)
if prompt_mask is None:
prompt_mask = torch.ones(cond.shape[0], prompt_len).to(cond.device) # (B, prompt_len)
xt_mask = torch.cat([prompt_mask, x_mask, prompt_mask], dim=1)
z = torch.randn(
(cond.shape[0], target_len, self.mel_dim),
dtype=cond.dtype,
device=cond.device,
requires_grad=False,
)
xt = z
# t from 0 to 1: x0 = z ~ N(0, 1)
for i in range(n_timesteps):
xt_input = torch.cat([prompt, xt, prompt], dim=1)
t = (0 + (i + 0.5) * h) * torch.ones(z.shape[0], dtype=z.dtype, device=z.device)
flow_pred = self.diff_estimator(xt_input, t, cond, xt_mask)
flow_pred = flow_pred[:, prompt_len:-prompt_len, :]
# cfg
if cfg > 0:
uncond_flow_pred = self.diff_estimator(xt, t, torch.zeros_like(cond)[:, : xt.shape[1], :], x_mask)
pos_flow_pred_std = flow_pred.std()
flow_pred_cfg = flow_pred + cfg * (flow_pred - uncond_flow_pred)
rescale_flow_pred = flow_pred_cfg * pos_flow_pred_std / flow_pred_cfg.std()
flow_pred = rescale_cfg * rescale_flow_pred + (1 - rescale_cfg) * flow_pred_cfg
dxt = flow_pred * h
xt = xt + dxt
return xt
def forward(
self,
x: torch.Tensor,
x_mask: torch.Tensor,
cond_code: torch.Tensor,
is_prompt: torch.Tensor | None = None,
):
"""
Args:
x: (B, T, mel_dim)
x_mask: (B, T)
cond_code: (B, T), Note that cond_code might be not at 50Hz!
"""
T = x.shape[1]
cond = self.cond_emb(cond_code) # (B, T, hidden_size)
if self.do_resampling:
# Align to the frame rate of Mels
cond = self.resampling_layers(cond.transpose(1, 2)).transpose(1, 2)
# print("cond_code: {}, after resampling: {}".format(cond_code.shape, cond.shape))
if cond.shape[1] >= T: # Check time dimension
cond = cond[:, :T, :]
else:
padding_frames = T - cond.shape[1]
last_frame = cond[:, -1:, :]
padding = last_frame.repeat(1, padding_frames, 1)
cond = torch.cat([cond, padding], dim=1)
return self.compute_loss(x, x_mask, cond, is_prompt)
if __name__ == "__main__":
model_cfg = {
"mel_dim": 128,
"hidden_size": 256,
"num_layers": 8,
"num_heads": 8,
"cfg_drop_prob": 0.2,
"use_embedding": False,
"cond_codebook_size": 256,
"cond_scale_factor": 1,
"sigma": 1e-5,
"time_scheduler": "cos",
}
device = "cuda"
x = torch.randn(2, 100, 128).to(device)
x_mask = torch.ones(2, 100).to(device)
# cond_code = torch.randint(0, 16384, (2, 25)).to(device)
cond_code = torch.randn(2, 100, 256).to(device)
model = FlowMatchingTransformer(cfg=model_cfg, **model_cfg).to(device)
outputs = model(x, x_mask, cond_code)
print(outputs)
noise, x, flow_pred, final_mask, prompt_len = outputs["output"]
final_mask = final_mask.squeeze(-1)
flow_gt = x - (1 - 1e-5) * noise
# [B, n_frames, D]
diff_loss = F.l1_loss(flow_pred, flow_gt, reduction="none").float() * final_mask.unsqueeze(-1)
diff_loss = torch.mean(diff_loss, dim=2).sum() / final_mask.sum()
print("diff_loss:", diff_loss.item())
diffusion_cond = torch.randn(2, 150, 256).to(device)
diffusion_cond_emb = model.cond_emb(diffusion_cond)
diffusion_prompt = torch.randn(2, 50, 128).to(device)
n_timesteps = 32
generated = model.reverse_diffusion(diffusion_cond_emb, diffusion_prompt, n_timesteps=n_timesteps)
print("generated:", generated.shape)
@@ -1,393 +0,0 @@
import math
import torch
import torch.nn as nn
from cache_dit import ForwardPattern
from transformers import LlamaConfig, LlamaModel
from transformers.modeling_outputs import BaseModelOutputWithPast
from transformers.models.llama.modeling_llama import LlamaDecoderLayer
from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput, SequenceParallelOutput
class SinusoidalPosEmb(nn.Module):
def __init__(self, dim):
super().__init__()
self.dim = dim
def forward(self, x):
device = x.device
dtype = x.dtype
half_dim = self.dim // 2
emb = math.log(10000) / (half_dim - 1)
emb = torch.exp(torch.arange(half_dim, device=device, dtype=dtype) * -emb)
emb = x[:, None] * emb[None, :]
emb = torch.cat((emb.sin(), emb.cos()), dim=-1)
return emb
class LlamaAdaptiveRMSNorm(nn.Module):
def __init__(self, hidden_size=1024, eps=1e-6, dim_cond=1024):
super().__init__()
self.to_weight = nn.Linear(dim_cond, hidden_size)
nn.init.zeros_(self.to_weight.weight)
nn.init.ones_(self.to_weight.bias)
self.variance_epsilon = eps
self._is_hf_initialized = True
def forward(self, hidden_states, cond_embedding):
input_dtype = hidden_states.dtype
variance = hidden_states.to(torch.float32).pow(2).mean(-1, keepdim=True)
hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
weight = self.to_weight(cond_embedding)
if len(weight.shape) == 2:
weight = weight.unsqueeze(1)
return (weight * hidden_states).to(input_dtype)
class LlamaNARDecoderLayer(LlamaDecoderLayer):
def __init__(self, config: LlamaConfig, layer_idx: int):
super().__init__(config, layer_idx)
self.input_layernorm = LlamaAdaptiveRMSNorm(
config.hidden_size,
eps=config.rms_norm_eps,
dim_cond=config.hidden_size,
)
self.post_attention_layernorm = LlamaAdaptiveRMSNorm(
config.hidden_size,
eps=config.rms_norm_eps,
dim_cond=config.hidden_size,
)
def forward(
self,
hidden_states: torch.Tensor,
cond_embedding: torch.Tensor,
attention_mask: torch.Tensor | None = None,
position_embeddings: tuple[torch.Tensor, torch.Tensor] | None = None,
output_attentions: bool | None = False,
cache_position: torch.LongTensor | None = None,
**kwargs,
) -> tuple[torch.FloatTensor, ...]:
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states, cond_embedding=cond_embedding)
# RoPE is computed by the parent model and passed in as (cos, sin).
hidden_states, self_attn_weights = self.self_attn(
hidden_states=hidden_states,
position_embeddings=position_embeddings,
attention_mask=attention_mask,
cache_position=cache_position,
output_attentions=output_attentions,
**kwargs,
)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.post_attention_layernorm(
hidden_states,
cond_embedding=cond_embedding,
)
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states
outputs = (hidden_states,)
if output_attentions:
outputs += (self_attn_weights,)
return outputs
class DiffLlama(LlamaModel):
# Regional torch.compile in DiffusionModelRunner targets these blocks.
_repeated_blocks = ["LlamaNARDecoderLayer"]
_layerwise_offload_blocks_attrs = ["layers"]
# cache-dit: Llama-style backbone with static condition embeddings injected
# before the decoder loop (like Stable Audio Open). Pattern_3 handles the
# cross-attention-style conditioning on the ``layers`` ModuleList.
_cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"layers": ForwardPattern.Pattern_3,
},
)
# Sequence parallelism: shard mel sequence before the first decoder layer and
# gather after the output projection. Tensor parallelism still requires
# migrating attention to vLLM parallel linear layers.
_sp_plan = {
"layers.0": {
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
},
"mel_out_mlp": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
@staticmethod
def _is_transformer_block(name: str, module: nn.Module) -> bool:
del module
return name.startswith("layers.") and name.split(".")[-1].isdigit()
_hsdp_shard_conditions = [_is_transformer_block]
def __init__(
self,
mel_dim=100,
hidden_size=1024,
num_heads=16,
num_layers=16,
dropout=0.1,
ffn_dropout=0.1,
attention_dropout=0.0,
config: LlamaConfig | None = None,
):
if config is None:
config = LlamaConfig(
vocab_size=32000,
hidden_size=hidden_size,
intermediate_size=hidden_size * 4,
num_hidden_layers=num_layers,
num_attention_heads=num_heads,
num_key_value_heads=num_heads,
max_position_embeddings=4096,
rms_norm_eps=1e-6,
attention_dropout=attention_dropout,
use_cache=False,
attn_implementation="eager",
)
config.use_cache = False
super().__init__(config)
# Keep custom decoder layers on the same attention backend.
layer_config = LlamaConfig(
vocab_size=config.vocab_size,
hidden_size=hidden_size,
intermediate_size=hidden_size * 4,
num_hidden_layers=num_layers,
num_attention_heads=num_heads,
num_key_value_heads=num_heads,
max_position_embeddings=4096,
rms_norm_eps=config.rms_norm_eps,
attention_dropout=attention_dropout,
use_cache=False,
attn_implementation="eager",
bos_token_id=None,
eos_token_id=None,
)
self.layers = nn.ModuleList(
[
LlamaNARDecoderLayer(
layer_config,
layer_idx=i,
)
for i in range(num_layers)
]
)
self.norm = LlamaAdaptiveRMSNorm(hidden_size, dim_cond=hidden_size)
self.diff_step_embedding = SinusoidalPosEmb(hidden_size)
self.diff_step_mlp = nn.Sequential(
nn.Linear(hidden_size, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, hidden_size),
)
self.cond_mlp = nn.Sequential(
nn.Linear(hidden_size, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, hidden_size),
)
self.mel_mlp = nn.Sequential(
nn.Linear(mel_dim, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, hidden_size),
)
self.mel_out_mlp = nn.Sequential(
nn.Linear(hidden_size, hidden_size * 4),
nn.SiLU(),
nn.Linear(hidden_size * 4, mel_dim),
)
self.embed_tokens = None
self.post_init()
def _prepare_decoder_attention_mask(
self,
attention_mask,
input_shape,
inputs_embeds,
past_key_values_length=0,
):
"""
Build a 4D additive padding mask.
This model is non-autoregressive, so no causal mask is added here.
"""
combined_attention_mask = None
def _expand_mask(mask: torch.Tensor, dtype: torch.dtype, tgt_len: int | None = None):
bsz, src_len = mask.size()
tgt_len = tgt_len if tgt_len is not None else src_len
expanded_mask = (
mask[:, None, None, :]
.expand(
bsz,
1,
tgt_len,
src_len,
)
.to(dtype)
)
inverted_mask = 1.0 - expanded_mask
return inverted_mask.masked_fill(
inverted_mask.to(torch.bool),
torch.finfo(dtype).min,
)
if attention_mask is not None:
expanded_attn_mask = _expand_mask(
attention_mask,
inputs_embeds.dtype,
tgt_len=input_shape[-1],
).to(inputs_embeds.device)
combined_attention_mask = (
expanded_attn_mask if combined_attention_mask is None else expanded_attn_mask + combined_attention_mask
)
return combined_attention_mask
def forward(
self,
x,
diffusion_step,
cond,
x_mask,
input_ids: torch.LongTensor = None,
attention_mask: torch.Tensor | None = None,
position_ids: torch.LongTensor | None = None,
past_key_values: list[torch.FloatTensor] | None = None,
inputs_embeds: torch.FloatTensor | None = None,
use_cache: bool | None = None,
output_attentions: bool | None = None,
output_hidden_states: bool | None = None,
return_dict: bool | None = False,
) -> BaseModelOutputWithPast | torch.Tensor | dict:
batch_size, seq_length, _ = x.shape
cond_embedding = self.cond_mlp(cond)
x = self.mel_mlp(x)
diffusion_step = self.diff_step_embedding(diffusion_step).to(device=x.device, dtype=x.dtype)
diffusion_step = self.diff_step_mlp(diffusion_step)
x = x + cond_embedding
inputs_embeds = x
attention_mask = x_mask
output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions
output_hidden_states = (
output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states
)
# KV cache is disabled because this diffusion decoder processes full sequences.
if past_key_values is not None:
raise NotImplementedError("KV cache is disabled for this non-autoregressive DiffLlama implementation.")
if position_ids is None:
device = input_ids.device if input_ids is not None else inputs_embeds.device
position_ids = torch.arange(
0,
seq_length,
dtype=torch.long,
device=device,
)
position_ids = position_ids.unsqueeze(0).expand(batch_size, -1)
else:
position_ids = position_ids.view(batch_size, seq_length).long()
if attention_mask is None:
attention_mask = torch.ones(
(batch_size, seq_length),
dtype=torch.bool,
device=inputs_embeds.device,
)
attention_mask = self._prepare_decoder_attention_mask(
attention_mask,
(batch_size, seq_length),
inputs_embeds,
past_key_values_length=0,
)
hidden_states = inputs_embeds
all_hidden_states = () if output_hidden_states else None
all_self_attns = () if output_attentions else None
all_layer_hidden_states = []
cache_position = torch.arange(
0,
seq_length,
device=hidden_states.device,
dtype=torch.long,
)
# LlamaAttention expects precomputed rotary embeddings.
position_embeddings = self.rotary_emb(hidden_states, position_ids)
for idx, decoder_layer in enumerate(self.layers):
if output_hidden_states:
all_hidden_states += (hidden_states,)
if self.gradient_checkpointing and self.training:
raise NotImplementedError
layer_outputs = decoder_layer(
hidden_states,
cond_embedding=diffusion_step,
attention_mask=attention_mask,
position_embeddings=position_embeddings,
output_attentions=output_attentions,
cache_position=cache_position,
)
hidden_states = layer_outputs[0]
all_layer_hidden_states.append(hidden_states.clone())
if output_attentions:
all_self_attns += (layer_outputs[1],)
hidden_states = self.norm(hidden_states, cond_embedding=diffusion_step)
if output_hidden_states:
all_hidden_states += (hidden_states,)
hidden_states = self.mel_out_mlp(hidden_states)
if return_dict:
return {
"output": hidden_states,
"hidden_states": all_layer_hidden_states,
"attentions": all_self_attns,
}
return hidden_states
@@ -1,151 +0,0 @@
import math
from typing import Any
import numpy as np
import torch
import torch.nn as nn
from vllm_omni.utils.audio import mel_filter_bank as librosa_mel_fn
def dynamic_range_compression(x, c=1, clip_val=1e-5):
return np.log(np.clip(x, a_min=clip_val, a_max=None) * c)
def dynamic_range_decompression(x, c=1):
return np.exp(x) / c
def dynamic_range_compression_torch(x, c=1, clip_val=1e-5):
return torch.log(torch.clamp(x, min=clip_val) * c)
def dynamic_range_decompression_torch(x, c=1):
return torch.exp(x) / c
def spectral_normalize_torch(magnitudes):
output = dynamic_range_compression_torch(magnitudes)
return output
def spectral_de_normalize_torch(magnitudes):
output = dynamic_range_decompression_torch(magnitudes)
return output
class MelSpectrogram(nn.Module):
def __init__(
self,
n_fft,
num_mels,
sampling_rate,
hop_size,
win_size,
fmin,
fmax,
center=False,
):
super().__init__()
self.n_fft = n_fft
self.hop_size = hop_size
self.win_size = win_size
self.sampling_rate = sampling_rate
self.num_mels = num_mels
self.fmin = fmin
self.fmax = fmax
self.center = center
mel_basis = {}
hann_window = {}
mel = librosa_mel_fn(sr=sampling_rate, n_fft=n_fft, n_mels=num_mels, fmin=fmin, fmax=fmax)
mel_basis = mel.to(torch.float32)
hann_window = torch.hann_window(win_size)
self.register_buffer("mel_basis", mel_basis)
self.register_buffer("hann_window", hann_window)
def forward(self, y):
y = torch.nn.functional.pad(
y.unsqueeze(1),
(
int((self.n_fft - self.hop_size) / 2),
int((self.n_fft - self.hop_size) / 2),
),
mode="reflect",
)
y = y.squeeze(1)
spec = torch.stft(
y,
self.n_fft,
hop_length=self.hop_size,
win_length=self.win_size,
window=self.hann_window,
center=self.center,
pad_mode="reflect",
normalized=False,
onesided=True,
return_complex=True,
)
spec = torch.view_as_real(spec)
spec = torch.sqrt(spec.pow(2).sum(-1) + (1e-9))
spec = torch.matmul(self.mel_basis, spec)
spec = spectral_normalize_torch(spec)
return spec
def load_mel_spectrogram():
return load_mel_spectrogram_from_cfg(None)
def _get_from_mapping(cfg: Any, key: str, default: Any = None) -> Any:
"""Safely read a field from a dict/OmegaConf-like object."""
if cfg is None:
return default
if isinstance(cfg, dict):
return cfg.get(key, default)
return getattr(cfg, key, default)
def load_mel_spectrogram_from_cfg(audio_cfg: Any | None = None) -> MelSpectrogram:
"""Build MelSpectrogram from `audio_config`-like config.
Expected keys (either in dict or Hydra/OmegaConf object):
- hop_size, sample_rate (or sampling_rate), n_fft, num_mels, win_size, fmin, fmax
"""
# Defaults keep current behavior.
mel_cfg: dict[str, Any] = {
"hop_size": _get_from_mapping(audio_cfg, "hop_size", 480),
"sampling_rate": _get_from_mapping(
audio_cfg,
"sampling_rate",
_get_from_mapping(audio_cfg, "sample_rate", 24000),
),
"n_fft": _get_from_mapping(audio_cfg, "n_fft", 1920),
"num_mels": _get_from_mapping(audio_cfg, "num_mels", 128),
"win_size": _get_from_mapping(audio_cfg, "win_size", 1920),
"fmin": _get_from_mapping(audio_cfg, "fmin", 0),
"fmax": _get_from_mapping(audio_cfg, "fmax", 12000),
}
mel_model = MelSpectrogram(**mel_cfg)
mel_model.eval()
return mel_model
class MelSpectrogramEncoder(nn.Module):
def __init__(self, audio_config: dict | None = None):
super().__init__()
self.model = load_mel_spectrogram_from_cfg(audio_config)
audio_config = audio_config or {}
self.mel_mean = audio_config.get("mel_mean", -4.92)
self.mel_var = audio_config.get("mel_var", 8.14)
def forward(self, x):
x = self.model(x).transpose(1, 2)
x = (x - self.mel_mean) / math.sqrt(self.mel_var)
return x
@@ -1,958 +0,0 @@
import math
import numpy as np
import packaging.version as version
import torch
import torch.nn as nn
# ---------------------- note transcription common layers ----------------------
class LayerNorm(nn.LayerNorm):
def __init__(self, nout: int, dim: int = -1, eps: float = 1e-5):
"""Construct an LayerNorm object."""
super().__init__(nout, eps=eps)
self.dim = dim
def forward(self, x):
if self.dim == -1:
return super().forward(x)
return super().forward(x.transpose(1, -1)).transpose(1, -1)
class Reshape(nn.Module):
def __init__(self, *args):
super().__init__()
self.shape = args
def forward(self, x):
return x.view(self.shape)
class Permute(nn.Module):
def __init__(self, *args):
super().__init__()
self.args = args
def forward(self, x):
return x.permute(self.args)
class LambdaLayer(nn.Module):
def __init__(self, lambd):
super().__init__()
self.lambd = lambd
def forward(self, x):
return self.lambd(x)
class ResidualBlock(nn.Module):
"""
Implements a residual block pattern:
[Norm] → [Conv1d] → [Scale] → [Activation] → [Conv1d]
repeated n times, each as a residual unit.
"""
def __init__(
self,
channels: int,
kernel_size: int,
dilation: int,
n: int = 2,
c_multiple: int = 2,
ln_eps: float = 1e-12,
):
super().__init__()
self.blocks = nn.ModuleList(
[
nn.Sequential(
LayerNorm(channels, dim=1, eps=ln_eps),
nn.Conv1d(
channels,
c_multiple * channels,
kernel_size,
dilation=dilation,
padding=(dilation * (kernel_size - 1)) // 2,
),
LambdaLayer(lambda x: x * kernel_size**-0.5), # scaling for stability
nn.LeakyReLU(),
nn.Conv1d(c_multiple * channels, channels, kernel_size=1, dilation=dilation),
)
for _ in range(n)
]
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
nonpadding = (x.abs().sum(1) > 0).float()[:, None, :]
for block in self.blocks:
x_res = block(x)
x = (x + x_res) * nonpadding
return x
class ConvBlocks(nn.Module):
"""
Decodes the expanded phoneme encoding into spectrograms.
"""
def __init__(
self,
hidden_size,
out_dims,
dilations,
kernel_size,
layers_in_block=2,
c_multiple=2,
ln_eps=1e-5,
is_btc=True,
post_net_kernel=3,
):
super().__init__()
self.is_btc = is_btc
res_blocks = [
ResidualBlock(
hidden_size,
kernel_size=kernel_size,
dilation=1,
n=layers_in_block,
c_multiple=c_multiple,
ln_eps=ln_eps,
)
for _ in range(1)
] # default 1
self.res_blocks = nn.Sequential(*res_blocks)
# Output normalization and conv
self.last_norm = LayerNorm(hidden_size, dim=1, eps=ln_eps)
self.post_net1 = nn.Conv1d(hidden_size, out_dims, kernel_size=post_net_kernel, padding=post_net_kernel // 2)
def forward(self, x: torch.Tensor, nonpadding: torch.Tensor | None = None) -> torch.Tensor:
if self.is_btc:
x = x.transpose(1, 2)
if nonpadding is None:
nonpadding = (x.abs().sum(1) > 0).float()[:, None, :]
elif self.is_btc:
nonpadding = nonpadding.transpose(1, 2)
x = self.res_blocks(x) * nonpadding
x = self.last_norm(x) * nonpadding
x = self.post_net1(x) * nonpadding
if self.is_btc:
x = x.transpose(1, 2)
return x
def Linear(in_features: int, out_features: int, bias: bool = True, init_type: str = "xavier"):
m = nn.Linear(in_features, out_features, bias)
if init_type == "xavier":
nn.init.xavier_uniform_(m.weight)
elif init_type == "kaiming":
nn.init.kaiming_normal_(m.weight, mode="fan_in")
if bias:
nn.init.constant_(m.bias, 0.0)
return m
def Embedding(num_embeddings, embedding_dim, padding_idx=None, init_type="normal"):
m = nn.Embedding(num_embeddings, embedding_dim, padding_idx=padding_idx)
if init_type == "normal":
nn.init.normal_(m.weight, mean=0, std=embedding_dim**-0.5)
elif init_type == "kaiming":
nn.init.kaiming_normal_(m.weight, mode="fan_in")
if padding_idx is not None:
nn.init.constant_(m.weight[padding_idx], 0)
return m
# ---------------------- note transcription espnet attention ----------------------
class MultiHeadedAttention(nn.Module):
"""Multi-Head Attention layer.
Args:
n_head (int): The number of heads.
n_feat (int): The number of features.
dropout_rate (float): Dropout rate.
"""
def __init__(self, n_head, n_feat, dropout_rate, flash=False):
"""Construct an MultiHeadedAttention object."""
super().__init__()
assert n_feat % n_head == 0
# We assume d_v always equals d_k
self.d_k = n_feat // n_head
self.h = n_head
self.linear_q = nn.Linear(n_feat, n_feat)
self.linear_k = nn.Linear(n_feat, n_feat)
self.linear_v = nn.Linear(n_feat, n_feat)
self.linear_out = nn.Linear(n_feat, n_feat)
self.attn = None
self.dropout = nn.Dropout(p=dropout_rate)
self.dropout_rate = dropout_rate
self.flash = flash
def forward_qkv(self, query, key, value):
"""Transform query, key and value.
Args:
query (torch.Tensor): Query tensor (#batch, time1, size).
key (torch.Tensor): Key tensor (#batch, time2, size).
value (torch.Tensor): Value tensor (#batch, time2, size).
Returns:
torch.Tensor: Transformed query tensor (#batch, n_head, time1, d_k).
torch.Tensor: Transformed key tensor (#batch, n_head, time2, d_k).
torch.Tensor: Transformed value tensor (#batch, n_head, time2, d_k).
"""
n_batch = query.size(0)
q = self.linear_q(query).view(n_batch, -1, self.h, self.d_k)
k = self.linear_k(key).view(n_batch, -1, self.h, self.d_k)
v = self.linear_v(value).view(n_batch, -1, self.h, self.d_k)
q = q.transpose(1, 2) # (batch, head, time1, d_k)
k = k.transpose(1, 2) # (batch, head, time2, d_k)
v = v.transpose(1, 2) # (batch, head, time2, d_k)
return q, k, v
def forward_attention(self, value, scores, mask):
"""Compute attention context vector.
Args:
value (torch.Tensor): Transformed value (#batch, n_head, time2, d_k).
scores (torch.Tensor): Attention score (#batch, n_head, time1, time2).
mask (torch.Tensor): Mask (#batch, 1, time2) or (#batch, time1, time2).
Returns:
torch.Tensor: Transformed value (#batch, time1, d_model)
weighted by the attention score (#batch, time1, time2).
"""
n_batch = value.size(0)
if mask is not None:
mask = mask.unsqueeze(1).eq(0) # (batch, 1, *, time2)
min_value = float(np.finfo(torch.tensor(0, dtype=scores.dtype).numpy().dtype).min)
scores = scores.masked_fill(mask, min_value)
self.attn = torch.softmax(scores, dim=-1).masked_fill(mask, 0.0) # (batch, head, time1, time2)
else:
self.attn = torch.softmax(scores, dim=-1) # (batch, head, time1, time2)
p_attn = self.dropout(self.attn)
x = torch.matmul(p_attn, value) # (batch, head, time1, d_k)
x = x.transpose(1, 2).contiguous().view(n_batch, -1, self.h * self.d_k) # (batch, time1, d_model)
return self.linear_out(x) # (batch, time1, d_model)
def forward(self, query, key, value, mask):
"""Compute scaled dot product attention.
Args:
query (torch.Tensor): Query tensor (#batch, time1, size).
key (torch.Tensor): Key tensor (#batch, time2, size).
value (torch.Tensor): Value tensor (#batch, time2, size).
mask (torch.Tensor): Mask tensor (#batch, 1, time2) or
(#batch, time1, time2).
Returns:
torch.Tensor: Output tensor (#batch, time1, d_model).
"""
q, k, v = self.forward_qkv(query, key, value)
if version.parse(torch.__version__) >= version.parse("2.0") and self.flash:
n_batch = value.size(0)
x = torch.nn.functional.scaled_dot_product_attention(
q, k, v, attn_mask=mask.unsqueeze(1) if mask is not None else None, dropout_p=self.dropout_rate
)
x = x.transpose(1, 2).contiguous().view(n_batch, -1, self.h * self.d_k) # (batch, time1, d_model)
return self.linear_out(x)
else:
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k)
return self.forward_attention(v, scores, mask)
class RelPositionMultiHeadedAttention(MultiHeadedAttention):
"""Multi-Head Attention layer with relative position encoding.
Paper: https://arxiv.org/abs/1901.02860
Args:
n_head (int): The number of heads.
n_feat (int): The number of features.
dropout_rate (float): Dropout rate.
"""
def __init__(self, n_head, n_feat, dropout_rate):
"""Construct an RelPositionMultiHeadedAttention object."""
super().__init__(n_head, n_feat, dropout_rate)
# linear transformation for positional encoding
self.linear_pos = nn.Linear(n_feat, n_feat, bias=False)
# these two learnable bias are used in matrix c and matrix d
# as described in https://arxiv.org/abs/1901.02860 Section 3.3
self.pos_bias_u = nn.Parameter(torch.Tensor(self.h, self.d_k))
self.pos_bias_v = nn.Parameter(torch.Tensor(self.h, self.d_k))
torch.nn.init.xavier_uniform_(self.pos_bias_u)
torch.nn.init.xavier_uniform_(self.pos_bias_v)
def rel_shift(self, x, zero_triu=False):
"""Compute relative positinal encoding.
Args:
x (torch.Tensor): Input tensor (batch, time, size).
zero_triu (bool): If true, return the lower triangular part of the matrix.
Returns:
torch.Tensor: Output tensor.
"""
zero_pad = torch.zeros((*x.size()[:3], 1), device=x.device, dtype=x.dtype)
x_padded = torch.cat([zero_pad, x], dim=-1)
x_padded = x_padded.view(*x.size()[:2], x.size(3) + 1, x.size(2))
x = x_padded[:, :, 1:].view_as(x)
if zero_triu:
ones = torch.ones((x.size(2), x.size(3)))
x = x * torch.tril(ones, x.size(3) - x.size(2))[None, None, :, :]
return x
def forward(self, query, key, value, pos_emb, mask):
"""Compute 'Scaled Dot Product Attention' with rel. positional encoding.
Args:
query (torch.Tensor): Query tensor (#batch, time1, size).
key (torch.Tensor): Key tensor (#batch, time2, size).
value (torch.Tensor): Value tensor (#batch, time2, size).
pos_emb (torch.Tensor): Positional embedding tensor (#batch, time2, size).
mask (torch.Tensor): Mask tensor (#batch, 1, time2) or
(#batch, time1, time2).
Returns:
torch.Tensor: Output tensor (#batch, time1, d_model).
"""
q, k, v = self.forward_qkv(query, key, value)
q = q.transpose(1, 2) # (batch, time1, head, d_k)
n_batch_pos = pos_emb.size(0)
p = self.linear_pos(pos_emb).view(n_batch_pos, -1, self.h, self.d_k)
p = p.transpose(1, 2) # (batch, head, time1, d_k)
# (batch, head, time1, d_k)
q_with_bias_u = (q + self.pos_bias_u).transpose(1, 2)
# (batch, head, time1, d_k)
q_with_bias_v = (q + self.pos_bias_v).transpose(1, 2)
# compute attention score
# first compute matrix a and matrix c
# as described in https://arxiv.org/abs/1901.02860 Section 3.3
# (batch, head, time1, time2)
matrix_ac = torch.matmul(q_with_bias_u, k.transpose(-2, -1))
# compute matrix b and matrix d
# (batch, head, time1, time2)
matrix_bd = torch.matmul(q_with_bias_v, p.transpose(-2, -1))
matrix_bd = self.rel_shift(matrix_bd)
scores = (matrix_ac + matrix_bd) / math.sqrt(self.d_k) # (batch, head, time1, time2)
return self.forward_attention(v, scores, mask)
# ---------------------- note transcription positional encoding ----------------------
class PositionalEncoding(torch.nn.Module):
"""Positional encoding.
Args:
d_model (int): Embedding dimension.
max_len (int): Maximum input length.
reverse (bool): Whether to reverse the input position.
"""
def __init__(self, d_model, max_len=5000, reverse=False):
"""Construct an PositionalEncoding object."""
super().__init__()
self.d_model = d_model
self.reverse = reverse
self.xscale = math.sqrt(self.d_model)
self.pe = None
self.extend_pe(torch.tensor(0.0).expand(1, max_len))
def extend_pe(self, x):
"""Reset the positional encodings."""
if self.pe is not None:
if self.pe.size(1) >= x.size(1):
if self.pe.dtype != x.dtype or self.pe.device != x.device:
self.pe = self.pe.to(dtype=x.dtype, device=x.device)
return
pe = torch.zeros(x.size(1), self.d_model)
if self.reverse:
position = torch.arange(x.size(1) - 1, -1, -1.0, dtype=torch.float32).unsqueeze(1)
else:
position = torch.arange(0, x.size(1), dtype=torch.float32).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, self.d_model, 2, dtype=torch.float32) * -(math.log(10000.0) / self.d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.pe = pe.to(device=x.device, dtype=x.dtype)
def forward(self, x: torch.Tensor):
"""Add positional encoding.
Args:
x (torch.Tensor): Input tensor (batch, time, `*`).
Returns:
torch.Tensor: Encoded tensor (batch, time, `*`).
"""
self.extend_pe(x)
x = x * self.xscale + self.pe[:, : x.size(1)]
return x
class RelPositionalEncoding(PositionalEncoding):
"""Relative positional encoding module.
See : Appendix B in https://arxiv.org/abs/1901.02860
Args:
d_model (int): Embedding dimension.
max_len (int): Maximum input length.
"""
def __init__(self, d_model, max_len=5000):
super().__init__(d_model, max_len, reverse=True)
def forward(self, x):
"""Compute positional encoding.
Args:
x (torch.Tensor): Input tensor (batch, time, `*`).
Returns:
torch.Tensor: Encoded tensor (batch, time, `*`).
torch.Tensor: Positional embedding tensor (1, time, `*`).
"""
self.extend_pe(x)
x = x * self.xscale
pos_emb = self.pe[:, : x.size(1)]
return x, pos_emb
# ---------------------- note transcription conformer layers ----------------------
class ConvolutionModule(nn.Module):
def __init__(self, channels: int, kernel_size: int, activation: nn.Module = nn.ReLU(), bias: bool = True):
super().__init__()
if (kernel_size - 1) % 2 != 0:
raise ValueError("kernel_size should be a odd number for 'SAME' padding")
self.pointwise_conv1 = nn.Conv1d(channels, 2 * channels, kernel_size=1, bias=bias)
self.depthwise_conv = nn.Conv1d(
channels, channels, kernel_size=kernel_size, padding=(kernel_size - 1) // 2, groups=channels, bias=bias
)
self.norm = nn.BatchNorm1d(channels)
self.pointwise_conv2 = nn.Conv1d(channels, channels, kernel_size=1, bias=bias)
self.activation = activation
def forward(self, x):
# (batch, time, channels) -> (batch, channels, time)
x = x.transpose(1, 2)
x = self.pointwise_conv1(x)
x = nn.functional.glu(x, dim=1)
x = self.depthwise_conv(x)
x = self.norm(x)
x = self.activation(x)
x = self.pointwise_conv2(x)
x = x.transpose(1, 2)
return x
class MultiLayeredConv1d(nn.Module):
"""
Multi-layered Conv1d that replaces feed-forward in Transformer block.
Reference:
FastSpeech: Fast, Robust and Controllable Text to Speech
https://arxiv.org/pdf/1905.09263.pdf
"""
def __init__(self, in_chans, hidden_chans, kernel_size):
super().__init__()
padding = (kernel_size - 1) // 2
self.w_1 = nn.Conv1d(in_chans, hidden_chans, kernel_size, padding=padding)
self.w_2 = nn.Conv1d(hidden_chans, in_chans, kernel_size, padding=padding)
def forward(self, x):
"""
Args:
x (torch.Tensor): (batch, time, in_chans)
Returns:
torch.Tensor: (batch, time, in_chans)
"""
# Conv1d expects (batch, channels, time)
x = x.transpose(1, 2)
x = torch.relu(self.w_1(x))
x = self.w_2(x)
x = x.transpose(1, 2)
return x
class Swish(nn.Module):
"""Swish activation function."""
def forward(self, x):
return x * torch.sigmoid(x)
class EncoderLayer(nn.Module):
"""
A single encoder layer: optional macaron FFN, self-attn, conv, FFN, norms.
Args:
size (int): Input dimension.
self_attn (nn.Module): Self-attention module.
feed_forward (nn.Module): Standard feed-forward network.
feed_forward_macaron (nn.Module): Optional, second FFN in macaron structure.
conv_module (nn.Module): Optional, convolution module.
normalize_before (bool): If True, use pre-layer norm. Else, post-layer norm.
concat_after (bool): If True, concat attn in/out, else residual.
"""
def __init__(
self,
size,
self_attn,
feed_forward,
feed_forward_macaron,
conv_module,
normalize_before=True,
concat_after=False,
):
super().__init__()
self.size = size
self.self_attn = self_attn
self.feed_forward = feed_forward
self.feed_forward_macaron = feed_forward_macaron
self.conv_module = conv_module
self.norm_ff = LayerNorm(size)
self.norm_mha = LayerNorm(size)
self.ff_scale = 0.5 if feed_forward_macaron is not None else 1.0
self.norm_ff_macaron = LayerNorm(size) if feed_forward_macaron is not None else None
if conv_module is not None:
self.norm_conv = LayerNorm(size)
self.norm_final = LayerNorm(size)
else:
self.norm_conv = None
self.norm_final = None
self.normalize_before = normalize_before
self.concat_after = concat_after
if self.concat_after:
self.concat_linear = nn.Linear(2 * size, size)
def forward(self, x_input, mask, cache=None) -> tuple:
"""
Forward pass for the encoder layer.
Args:
x_input (Tensor or Tuple): Input tensor of shape (batch, time, size)
or tuple (x, pos_emb) where pos_emb is the positional embedding.
mask (Tensor): Mask tensor of shape (batch, time) or (batch, 1, time).
cache (Tensor, optional): Input cache for streaming (can be None).
Returns:
Tuple[Tensor, Tensor]: (outputs, mask), where outputs either includes
pos_emb or not, depending on input.
"""
# Unpack possible (x, pos_emb) tuple input
if isinstance(x_input, tuple):
x, pos_emb = x_input
else:
x, pos_emb = x_input, None
# --- Macaron Feed-Forward (first FFN) ---
if self.feed_forward_macaron is not None:
residual = x
if self.normalize_before:
x = self.norm_ff_macaron(x)
x = residual + self.ff_scale * self.feed_forward_macaron(x)
if not self.normalize_before:
x = self.norm_ff_macaron(x)
# --- Self-Attention ---
residual = x
if self.normalize_before:
x = self.norm_mha(x)
# Handle cache for streaming
if cache is not None:
assert cache.shape == (x.shape[0], x.shape[1] - 1, self.size)
x_q = x[:, -1:, :]
residual = residual[:, -1:, :]
mask = mask[:, -1:, :] if mask is not None else None
else:
x_q = x
# Apply self-attention (with or without positional embeddings)
if pos_emb is not None:
attn_out = self.self_attn(x_q, x, x, pos_emb, mask)
else:
attn_out = self.self_attn(x_q, x, x, mask)
if self.concat_after:
x = torch.cat((x, attn_out), dim=-1)
x = residual + self.concat_linear(x)
else:
x = residual + attn_out
if not self.normalize_before:
x = self.norm_mha(x)
# --- Convolution Module ---
if self.conv_module is not None:
residual = x
if self.normalize_before:
x = self.norm_conv(x)
x = residual + self.conv_module(x)
if not self.normalize_before:
x = self.norm_conv(x)
# --- Feed-Forward (second FFN) ---
residual = x
if self.normalize_before:
x = self.norm_ff(x)
x = residual + self.ff_scale * self.feed_forward(x)
if not self.normalize_before:
x = self.norm_ff(x)
# --- Final normalization, if present ---
if self.norm_final is not None:
x = self.norm_final(x)
# --- Append to cache if streaming ---
if cache is not None:
x = torch.cat([cache, x], dim=1)
# --- Final return, with or without positional embedding ---
if pos_emb is not None:
return (x, pos_emb), mask
else:
return x, mask
class ConformerLayers(nn.Module):
def __init__(self, hidden_size, num_layers, kernel_size=9, num_heads=4, use_last_norm=True, save_hidden=False):
super().__init__()
self.use_last_norm = use_last_norm
self.layers = nn.ModuleList()
positionwise_layer = MultiLayeredConv1d
positionwise_layer_args = (hidden_size, hidden_size * 4, 1)
self.pos_embed = RelPositionalEncoding(hidden_size)
self.encoder_layers = nn.ModuleList(
[
EncoderLayer(
hidden_size,
RelPositionMultiHeadedAttention(num_heads, hidden_size, 0.0),
positionwise_layer(*positionwise_layer_args),
positionwise_layer(*positionwise_layer_args),
ConvolutionModule(hidden_size, kernel_size, Swish()),
)
for _ in range(num_layers)
]
)
if self.use_last_norm:
self.layer_norm = nn.LayerNorm(hidden_size)
else:
self.layer_norm = nn.Linear(hidden_size, hidden_size)
self.save_hidden = save_hidden
if save_hidden:
self.hiddens = []
def forward(self, x, padding_mask=None):
"""
:param x: [B, T, H]
:param padding_mask: [B, T]
:return: [B, T, H]
"""
self.hiddens = []
nonpadding_mask = x.abs().sum(-1) > 0
x = self.pos_embed(x)
for layer in self.encoder_layers:
x, mask = layer(x, nonpadding_mask[:, None, :])
if self.save_hidden:
self.hiddens.append(x[0])
x = x[0]
x = self.layer_norm(x) * nonpadding_mask.float()[:, :, None]
return x
# ---------------------- note transcription backbone layers ----------------------
class UnetDown(nn.Module):
def __init__(
self,
hidden_size,
n_layers,
kernel_size,
down_rates,
channel_multiples=None,
is_btc=True,
constant_channels=False,
):
super().__init__()
assert n_layers == len(down_rates) # downs, down sample rate
down_rates = [int(i) for i in down_rates]
self.n_layers = n_layers
self.hidden_size = hidden_size
self.is_btc = is_btc
channel_multiples = channel_multiples if channel_multiples is not None else down_rates
self.layers = nn.ModuleList()
self.downs = nn.ModuleList()
in_channels = hidden_size
for i in range(self.n_layers):
out_channels = int(in_channels * channel_multiples[i]) if not constant_channels else in_channels
self.layers.append(
nn.Sequential(
ResidualBlock(in_channels, kernel_size, dilation=1, n=1, c_multiple=1, ln_eps=1e-5),
nn.Conv1d(in_channels, out_channels, kernel_size, padding=(kernel_size - 1) // 2),
ResidualBlock(out_channels, kernel_size, dilation=1, n=1, c_multiple=1, ln_eps=1e-5),
)
)
self.downs.append(nn.Sequential(nn.AvgPool1d(down_rates[i])))
in_channels = out_channels
self.last_norm = LayerNorm(out_channels, dim=1, eps=1e-6)
self.post_net = nn.Conv1d(out_channels, out_channels, kernel_size=kernel_size, padding=kernel_size // 2)
def forward(self, x, **kwargs):
# x [B, T, C]
if self.is_btc:
x = x.transpose(1, 2) # [B, C, T]
skip_xs = []
for i in range(self.n_layers):
skip_x = self.layers[i](x)
x = self.downs[i](skip_x)
if self.is_btc:
skip_xs.append(skip_x.transpose(1, 2)) # [B, T, C]
else:
skip_xs.append(skip_x)
x = self.post_net(self.last_norm(x))
if self.is_btc:
x = x.transpose(1, 2)
return x, skip_xs
class UnetMid(nn.Module):
def __init__(self, hidden_size, kernel_size, n_layers=None, in_dims=None, out_dims=None, is_btc=True, net=None):
super().__init__()
in_dims = in_dims if in_dims is not None else hidden_size
out_dims = out_dims if out_dims is not None else hidden_size
self.pre = nn.Conv1d(in_dims, hidden_size, kernel_size, padding=kernel_size // 2)
self.post = nn.Conv1d(hidden_size, out_dims, kernel_size, padding=kernel_size // 2)
self.is_btc = is_btc
if net is not None:
self.net = net
else:
self.net = ConvBlocks(
hidden_size,
out_dims=hidden_size,
dilations=None,
kernel_size=kernel_size,
layers_in_block=2,
c_multiple=2,
post_net_kernel=3,
is_btc=is_btc,
)
def forward(self, x, cond=None, **kwargs):
# x [B, T, C]
if self.is_btc:
x = self.pre(x.transpose(1, 2)).transpose(1, 2)
else:
x = self.pre(x)
if cond is None:
cond = 0
x = self.net(x + cond)
if self.is_btc:
x = self.post(x.transpose(1, 2)).transpose(1, 2)
else:
x = self.post(x)
return x
class UnetUp(nn.Module):
def __init__(
self,
hidden_size,
n_layers,
kernel_size,
up_rates,
channel_multiples=None,
is_btc=True,
constant_channels=False,
use_skip_layer=False,
skip_scale=1.0,
):
super().__init__()
assert n_layers == len(up_rates) # this is reversed in up module, from the output to the interface with middle
up_rates = [int(i) for i in up_rates]
self.n_layers = n_layers
self.hidden_size = hidden_size
self.is_btc = is_btc
self.skip_scale = skip_scale
channel_multiples = channel_multiples if channel_multiples is not None else up_rates
# in_channels = int(np.cumprod(channel_multiples)[-1] * hidden_size) if not constant_channels else hidden_size
self.in_channels_lst = (
(np.cumprod([1] + channel_multiples) * hidden_size).astype(int)
if not constant_channels
else [hidden_size for _ in range(self.n_layers + 1)]
)
in_channels = self.in_channels_lst[-1]
self.ups = nn.ModuleList()
self.skip_layers = nn.ModuleList()
self.layers = nn.ModuleList()
for i in range(self.n_layers - 1, -1, -1):
out_channels = self.in_channels_lst[i] if not constant_channels else in_channels
self.ups.append(
nn.Sequential(
nn.ConvTranspose1d(
in_channels,
in_channels,
kernel_size=kernel_size,
stride=up_rates[i],
padding=kernel_size // 2,
output_padding=up_rates[i] - 1,
),
LayerNorm(in_channels, dim=1, eps=1e-6),
nn.LeakyReLU(),
)
)
self.layers.append(
nn.Sequential(
nn.Conv1d(in_channels * 2, out_channels, kernel_size, padding=(kernel_size - 1) // 2),
ResidualBlock(out_channels, kernel_size, dilation=1, n=1, c_multiple=1, ln_eps=1e-5),
)
)
if use_skip_layer:
self.skip_layers.append(
ResidualBlock(in_channels, kernel_size, dilation=1, n=1, c_multiple=1, ln_eps=1e-5)
)
else:
self.skip_layers.append(nn.Identity())
in_channels = out_channels
self.out_channels = out_channels
self.last_norm = LayerNorm(out_channels, dim=1, eps=1e-6)
self.post_net = nn.Conv1d(out_channels, out_channels, kernel_size=kernel_size, padding=kernel_size // 2)
def forward(self, x, skips, **kwargs):
# x [B, T, C]
if self.is_btc:
x = x.transpose(1, 2) # [B, C, T]
for i in range(self.n_layers):
x = self.ups[i](x)
skip_x = (
skips[self.n_layers - i - 1] if not self.is_btc else skips[self.n_layers - i - 1].transpose(1, 2)
) # [B, T, C] -> [B, C, T]
skip_x = self.skip_layers[i](skip_x) * self.skip_scale
x = torch.cat((x, skip_x), dim=1) # [B, C, T]
x = self.layers[i](x)
x = self.post_net(self.last_norm(x))
if self.is_btc:
x = x.transpose(1, 2)
return x
class Unet(nn.Module):
def __init__(
self,
hidden_size,
down_layers,
up_layers,
kernel_size,
updown_rates,
mid_layers=None,
channel_multiples=None,
is_btc=True,
constant_channels=False,
mid_net=None,
use_skip_layer=False,
skip_scale=1.0,
):
super().__init__()
assert len(updown_rates) == down_layers == up_layers, f"{len(updown_rates)}, {down_layers}, {up_layers}"
if channel_multiples is not None:
assert len(channel_multiples) == len(updown_rates)
else:
channel_multiples = updown_rates
self.down = UnetDown(
hidden_size, down_layers, kernel_size, updown_rates, channel_multiples, is_btc, constant_channels
)
down_out_dims = int(np.cumprod(channel_multiples)[-1] * hidden_size) if not constant_channels else hidden_size
self.mid = UnetMid(
hidden_size,
kernel_size,
mid_layers,
in_dims=down_out_dims,
out_dims=down_out_dims,
is_btc=is_btc,
net=mid_net,
)
self.up = UnetUp(
hidden_size,
up_layers,
kernel_size,
updown_rates,
channel_multiples,
is_btc,
constant_channels,
use_skip_layer,
skip_scale,
)
def forward(self, x, mid_cond=None, **kwargs):
x, skips = self.down(x)
x = self.mid(x, mid_cond)
x = self.up(x, skips)
return x
class BackboneNet(nn.Module):
def __init__(self, hparams):
super().__init__()
self.hidden_size = hidden_size = hparams["hidden_size"]
updown_rates = [2, 2, 2]
channel_multiples = [1, 1, 1]
if hparams.get("updown_rates", None) is not None:
updown_rates = [int(i) for i in hparams.get("updown_rates", None).split("-")]
if hparams.get("channel_multiples", None) is not None:
channel_multiples = [float(i) for i in hparams.get("channel_multiples", None).split("-")]
assert len(updown_rates) == len(channel_multiples)
# convs
if hparams.get("bkb_net", "conv") == "conv":
self.net = Unet(
hidden_size,
down_layers=len(updown_rates),
mid_layers=hparams.get("bkb_layers", 12),
up_layers=len(updown_rates),
kernel_size=3,
updown_rates=updown_rates,
channel_multiples=channel_multiples,
is_btc=True,
constant_channels=False,
mid_net=None,
use_skip_layer=hparams.get("unet_skip_layer", False),
)
# conformer
elif hparams.get("bkb_net", "conv") == "conformer":
mid_net = ConformerLayers(
hidden_size,
num_layers=hparams.get("bkb_layers", 12),
kernel_size=hparams.get("conformer_kernel", 9),
num_heads=4,
)
self.net = Unet(
hidden_size,
down_layers=len(updown_rates),
up_layers=len(updown_rates),
kernel_size=3,
updown_rates=updown_rates,
channel_multiples=channel_multiples,
is_btc=True,
constant_channels=False,
mid_net=mid_net,
use_skip_layer=hparams.get("unet_skip_layer", False),
)
def forward(self, x):
return self.net(x)
@@ -1,7 +0,0 @@
"""Preprocess neural network modules for SoulX-Singer."""
from .pipeline import SoulXPreprocessPipeline
__all__ = [
"SoulXPreprocessPipeline",
]
@@ -1,326 +0,0 @@
"""Lyric transcription (Paraformer + Parakeet) for SoulX preprocess."""
import os
import re
import tempfile
from pathlib import Path
from typing import Any
import numpy as np
import soundfile as sf
import torch
import torch.nn as nn
from omegaconf import open_dict
from vllm.logger import init_logger
from .utils import get_audio_duration
logger = init_logger(__name__)
def _import_funasr_automodel():
"""Import FunASR without shelling out to ffmpeg at module import time."""
import subprocess
from functools import wraps
real_check_output = subprocess.check_output
@wraps(real_check_output)
def _safe_check_output(cmd, *args, **kwargs):
if cmd and Path(str(cmd[0])).name == "ffmpeg":
raise FileNotFoundError("ffmpeg unavailable for FunASR import probe")
return real_check_output(cmd, *args, **kwargs)
subprocess.check_output = _safe_check_output
try:
from funasr import AutoModel
finally:
subprocess.check_output = real_check_output
return AutoModel
def build_words_with_gaps(raw_words, raw_timestamps, audio_duration: float):
words, word_durs = [], []
prev = 0.0
for w, t in zip(raw_words, raw_timestamps):
s, e = float(t[0]), float(t[1])
if s > prev:
words.append("<SP>")
word_durs.append(s - prev)
words.append(w)
word_durs.append(e - s)
prev = e
if audio_duration > prev:
if not words:
words.append("<SP>")
word_durs.append(audio_duration)
elif words[-1] != "<SP>":
words.append("<SP>")
word_durs.append(audio_duration - prev)
else:
word_durs[-1] += audio_duration - prev
return words, word_durs
class ParaformerASR(nn.Module):
"""Mandarin/Cantonese ASR via FunASR Paraformer."""
def __init__(self, model_path: str, device: str):
super().__init__()
from vllm_omni.diffusion.models.soulx_singer.utils import _patch_torchaudio_load
_patch_torchaudio_load()
AutoModel = _import_funasr_automodel()
self.asr = AutoModel(model=model_path, disable_update=True, device=device)
logger.info("Loaded Paraformer ASR from %s", model_path)
def _generate(self, source: str) -> tuple[list[str], list[float], float]:
out = self.asr.generate(source, output_timestamp=True)[0]
raw_words = out["text"].replace("@", "").split(" ")
raw_timestamps = [[t[0] / 1000, t[1] / 1000] for t in out["timestamp"]]
duration = float(raw_timestamps[-1][1]) if raw_timestamps else 0.0
return raw_words, raw_timestamps, duration
def _maybe_refine_with_sidecar_f0(
self,
wav_path: str,
words: list[str],
word_durs: list[float],
*,
sample_rate: int,
hop_size: int,
) -> tuple[list[str], list[float]]:
"""Match upstream ``_ASRZhModel.process``: refine using ``{wav}_f0.npy`` if present."""
f0_path = os.path.splitext(wav_path)[0] + "_f0.npy"
if not os.path.isfile(f0_path):
return words, word_durs
f0 = np.load(f0_path)
if f0.size == 0:
return words, word_durs
return refine_word_durs_with_f0(
words,
word_durs,
f0,
sample_rate=sample_rate,
hop_size=hop_size,
)
@torch.inference_mode()
def forward(self, audio: str | np.ndarray, sample_rate: int = 16000) -> tuple[list[str], list[float]]:
if isinstance(audio, str):
raw_words, raw_timestamps, _ = self._generate(audio)
audio_duration = get_audio_duration(audio)
words, word_durs = build_words_with_gaps(raw_words, raw_timestamps, audio_duration)
return self._maybe_refine_with_sidecar_f0(
audio,
words,
word_durs,
sample_rate=24000,
hop_size=480,
)
waveform = np.asarray(audio, dtype=np.float32).squeeze()
audio_duration = len(waveform) / float(sample_rate)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
try:
sf.write(tmp_path, waveform, sample_rate)
raw_words, raw_timestamps, _ = self._generate(tmp_path)
words, word_durs = build_words_with_gaps(raw_words, raw_timestamps, audio_duration)
return self._maybe_refine_with_sidecar_f0(
tmp_path,
words,
word_durs,
sample_rate=24000,
hop_size=480,
)
finally:
Path(tmp_path).unlink(missing_ok=True)
Path(os.path.splitext(tmp_path)[0] + "_f0.npy").unlink(missing_ok=True)
def _disable_nemo_cuda_graphs(model) -> None:
decoding_cfg = model.cfg.decoding
with open_dict(decoding_cfg):
if hasattr(decoding_cfg, "greedy") and decoding_cfg.greedy is not None:
decoding_cfg.greedy.use_cuda_graph_decoder = False
if hasattr(decoding_cfg, "beam") and decoding_cfg.beam is not None:
decoding_cfg.beam.allow_cuda_graphs = False
model.change_decoding_strategy(decoding_cfg)
class ParakeetASR(nn.Module):
"""English ASR via NeMo Parakeet-TDT."""
def __init__(self, model_path: str, device: str):
super().__init__()
try:
import nemo.collections.asr as nemo_asr # type: ignore
except Exception as e: # pragma: no cover
raise ImportError(
"NeMo (nemo_toolkit) is required for ASR English but is not available in this Python env."
) from e
self.asr = nemo_asr.models.ASRModel.restore_from(
restore_path=model_path,
map_location=device,
)
self.asr.eval()
_disable_nemo_cuda_graphs(self.asr)
logger.info("Loaded Parakeet ASR from %s", model_path)
@staticmethod
def _clean_word(word: str) -> str:
return re.sub(r"[\?\.,:]", "", word).strip()
@staticmethod
def _extract_word_segments(output: Any) -> list[dict[str, Any]]:
ts = getattr(output, "timestamp", None)
if not ts or not isinstance(ts, dict):
return []
word_ts = ts.get("word")
return word_ts if isinstance(word_ts, list) else []
@torch.inference_mode()
def forward(self, audio: str | np.ndarray, sample_rate: int = 16000) -> tuple[list[str], list[float]]:
if isinstance(audio, str):
sources = [audio]
duration = 0.0
else:
waveform = np.asarray(audio, dtype=np.float32).squeeze()
duration = len(waveform) / float(sample_rate)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
sf.write(tmp_path, waveform, sample_rate)
sources = [tmp_path]
try:
outputs = self.asr.transcribe(
sources,
timestamps=True,
batch_size=1,
num_workers=0,
)
finally:
if not isinstance(audio, str):
Path(sources[0]).unlink(missing_ok=True)
output = outputs[0] if outputs else None
raw_words: list[str] = []
raw_timestamps: list[list[float]] = []
if output is not None:
for w in self._extract_word_segments(output):
s, e = float(w.get("start", 0.0)), float(w.get("end", 0.0))
word = self._clean_word(str(w.get("word", "")))
if word:
raw_words.append(word)
raw_timestamps.append([s, e])
if raw_timestamps:
duration = max(duration, float(raw_timestamps[-1][1]))
return build_words_with_gaps(raw_words, raw_timestamps, duration)
def refine_word_durs_with_f0(
words: list[str],
word_durs: list[float],
f0: np.ndarray,
*,
sample_rate: int = 24000,
hop_size: int = 480,
) -> tuple[list[str], list[float]]:
boundaries = np.cumsum([0, *[int(dur * sample_rate / hop_size) for dur in word_durs]]).tolist()
sil_tolerance = 5
ext_tolerance = 5
new_words: list[str] = []
new_word_durs: list[float] = []
if words:
new_words.append(words[0])
new_word_durs.append(word_durs[0])
for i in range(1, len(words)):
word = words[i]
if word == "<SP>":
start_frame = boundaries[i]
end_frame = boundaries[i + 1]
num_frames = end_frame - start_frame
frame_idx = start_frame
unvoiced_count = 0
while frame_idx < end_frame:
if f0[frame_idx] <= 1:
unvoiced_count += 1
if unvoiced_count >= sil_tolerance:
frame_idx -= sil_tolerance - 1
break
else:
unvoiced_count = 0
frame_idx += 1
voice_frames = frame_idx - start_frame
if voice_frames >= int(num_frames * 0.9):
new_word_durs[-1] += word_durs[i]
elif voice_frames >= ext_tolerance:
dur = voice_frames * hop_size / sample_rate
new_word_durs[-1] += dur
new_words.append("<SP>")
new_word_durs.append(word_durs[i] - dur)
else:
new_words.append(word)
new_word_durs.append(word_durs[i])
else:
new_words.append(word)
new_word_durs.append(word_durs[i])
return new_words, new_word_durs
class LyricModel(nn.Module):
def __init__(
self,
zh_model_path: str,
en_model_path: str,
device: str = "cuda",
*,
target_sr: int = 24000,
hop_size: int = 480,
):
super().__init__()
self.device_str = device
self.en_model_path = en_model_path
self.target_sr = target_sr
self.hop_size = hop_size
self.zh_asr = ParaformerASR(zh_model_path, device=device)
self.en_asr: nn.Module | None = None
@torch.inference_mode()
def forward(
self,
audio: str | np.ndarray,
language: str = "Mandarin",
*,
sample_rate: int = 16000,
f0: np.ndarray | None = None,
) -> tuple[list[str], list[float]]:
if language not in {"Mandarin", "Cantonese", "English"}:
raise ValueError(f"Unsupported language: {language}")
if language.lower() == "english":
if self.en_asr is None:
self.en_asr = ParakeetASR(self.en_model_path, device=self.device_str)
self.add_module("en_asr", self.en_asr)
words, durs = self.en_asr(audio, sample_rate=sample_rate)
else:
# Path-based ASR loads ``{wav}_f0.npy`` inside ParaformerASR (upstream parity).
if isinstance(audio, str):
words, durs = self.zh_asr(audio)
else:
words, durs = self.zh_asr(audio, sample_rate=sample_rate)
if f0 is not None and len(f0) > 0:
words, durs = refine_word_durs_with_f0(
words,
durs,
f0,
sample_rate=self.target_sr,
hop_size=self.hop_size,
)
return words, durs
@@ -1,96 +0,0 @@
"""Resample RMVPE F0 (16 kHz hop) onto the SoulX mel frame grid."""
from __future__ import annotations
import math
import time
from typing import TYPE_CHECKING
import numpy as np
from scipy.interpolate import interp1d
from vllm.logger import init_logger
from .rmvpe import RMVPE_SAMPLE_RATE
from .utils import load_mono_audio
if TYPE_CHECKING:
from .rmvpe import RMVPE
logger = init_logger(__name__)
RMVPE_HOP = 160
def interpolate_f0_to_mel_grid(
f0_16k: np.ndarray,
original_length: int,
original_sr: int,
*,
target_sr: int,
hop_size: int,
max_duration: float = 300.0,
) -> np.ndarray:
batch_max_length = int(max_duration * target_sr / hop_size)
duration_in_seconds = original_length / original_sr
effective_target_length = int(duration_in_seconds * target_sr)
original_frames = math.ceil(effective_target_length / hop_size)
target_frames = min(original_frames, batch_max_length)
t_16k = np.arange(len(f0_16k)) * (RMVPE_HOP / float(original_sr))
t_target = np.arange(target_frames) * (hop_size / float(target_sr))
if len(f0_16k) > 0:
f_interp = interp1d(
t_16k,
f0_16k,
kind="linear",
bounds_error=False,
fill_value=0.0,
assume_sorted=True,
)
f0 = f_interp(t_target)
else:
f0 = np.zeros(target_frames)
if len(f0) != target_frames:
f0 = f0[:target_frames] if len(f0) > target_frames else np.pad(f0, (0, target_frames - len(f0)))
return f0.astype(np.float32, copy=False)
def extract_f0_file(
rmvpe: RMVPE,
wav_path: str,
*,
target_sr: int,
hop_size: int,
f0_path: str | None = None,
thread: float = 0.03,
max_duration: float = 300.0,
verbose: bool = False,
) -> np.ndarray:
if verbose:
logger.info("[f0] extracting from %s", wav_path)
t0 = time.perf_counter()
audio, _ = load_mono_audio(wav_path, target_sr=RMVPE_SAMPLE_RATE)
f0_16k = rmvpe.infer_from_audio(audio, sample_rate=RMVPE_SAMPLE_RATE)
f0 = interpolate_f0_to_mel_grid(
np.asarray(f0_16k, dtype=np.float32),
original_length=audio.shape[-1],
original_sr=RMVPE_SAMPLE_RATE,
target_sr=target_sr,
hop_size=hop_size,
max_duration=max_duration,
)
if verbose:
voiced_ratio = float(np.mean(f0 > 0)) if len(f0) else 0.0
logger.info(
"[f0] done: frames=%s voiced_ratio=%.3f time=%.3fs",
len(f0),
voiced_ratio,
time.perf_counter() - t0,
)
if f0_path is not None:
np.save(f0_path, f0)
return f0
@@ -1,431 +0,0 @@
"""SoulX preprocess pipeline."""
from __future__ import annotations
import json
import tempfile
from collections.abc import Iterable
from pathlib import Path
from typing import Any, ClassVar
import numpy as np
import soundfile as sf
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportsComponentDiscovery
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.stack import SoulXPreprocessStack
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.utils import load_mono_audio, resample_mono
from vllm_omni.diffusion.models.soulx_singer.preprocess.metadata_utils import (
SegmentMetadata,
_merge_group,
convert_metadata,
)
from vllm_omni.diffusion.models.soulx_singer.utils import (
load_config,
load_wav,
preprocess_weight_paths,
resolve_preprocess_weights_root,
resolve_soulx_kind,
)
logger = init_logger(__name__)
def build_svs_prompt_meta(
preprocess: SoulXPreprocessPipeline,
*,
prompt_audio: str | tuple[np.ndarray, int],
prompt_meta_raw: dict[str, Any],
metadata_processor,
vocal_sep: bool | None = None,
) -> dict[str, Any]:
"""Build SVS prompt_meta; in-memory prompt wav is trimmed to mel2note length."""
if isinstance(prompt_audio, str):
return metadata_processor.process(prompt_meta_raw, prompt_audio)
prompt_meta = metadata_processor.process(prompt_meta_raw, None)
prompt_vocal, prompt_sr = preprocess._extract_vocal(prompt_audio, vocal_sep=vocal_sep)
if prompt_sr != metadata_processor.sample_rate:
prompt_vocal = resample_mono(
prompt_vocal,
orig_sr=prompt_sr,
target_sr=metadata_processor.sample_rate,
)
max_samples = prompt_meta["mel2note"].shape[1] * metadata_processor.hop_size
segment_wav = np.asarray(prompt_vocal[:max_samples], dtype=np.float32)
prompt_meta["wav"] = torch.from_numpy(segment_wav).unsqueeze(0).float().to(metadata_processor.device)
return prompt_meta
class SoulXPreprocessPipeline(nn.Module, SupportAudioInput, SupportsComponentDiscovery):
"""Lazy-loaded preprocess stack integrated with vLLM-Omni diffusion lifecycle."""
support_audio_input: ClassVar[bool] = True
weights_sources: ClassVar[tuple] = ()
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = [
"stack.vocal_sep",
"stack.lyric",
"stack.rosvot",
]
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = ["stack.rmvpe"]
def __init__(
self,
*,
od_config: OmniDiffusionConfig,
prefix: str = "",
vocal_sep: bool = True,
midi_transcribe: bool = True,
max_merge_duration_ms: int = 60000,
verbose: bool = False,
extra_args: dict[str, Any] | None = None,
) -> None:
super().__init__()
del prefix
self.od_config = od_config
extra_args = extra_args or {}
self.kind = resolve_soulx_kind(od_config.model)
hf_config = load_config(str(Path(od_config.model) / "config.yaml"))
audio_config = hf_config.audio
if self.kind == "svc" and midi_transcribe:
logger.warning("SVC model (config.json): midi_transcribe is ignored; lyric/MIDI transcription is SVS-only.")
midi_transcribe = False
self.vocal_sep = vocal_sep
self.midi_transcribe = midi_transcribe
self.max_merge_duration_ms = max_merge_duration_ms
self.verbose = verbose
self.target_sr = int(audio_config.sample_rate)
self.hop_size = int(audio_config.hop_size)
self._metadata_processor = None
self._weights_root: Path | None = None
self.stack: SoulXPreprocessStack | None = None
self._ensure_stack(extra_args)
def _ensure_stack(self, extra_args: dict[str, Any] | None = None) -> None:
extra_args = extra_args or {}
weights_root = resolve_preprocess_weights_root(self.od_config)
if self.stack is not None and self._weights_root == weights_root:
return
weights = preprocess_weight_paths(weights_root)
device = str(get_local_device())
self.stack = SoulXPreprocessStack(
weights,
device,
target_sr=self.target_sr,
hop_size=self.hop_size,
verbose=self.verbose,
)
self._weights_root = weights_root
def forward(self, req) -> DiffusionOutput:
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import (
SOULX_PREPROCESSED_KEY,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.pre_process import (
build_metadata_processor,
build_preprocess_payload,
build_warmup_payload,
is_warmup_request,
)
from vllm_omni.inputs.data import OmniTextPrompt
extra_args = dict(getattr(req.sampling_params, "extra_args", None) or {})
prompt = OmniTextPrompt(prompt=req.prompts[0]) if isinstance(req.prompts[0], str) else req.prompts[0]
info = prompt.get("additional_information") or {}
if isinstance(info, dict):
extra_args.update(dict(info.get("extra_args") or {}))
self._ensure_stack(extra_args)
if self._metadata_processor is None:
self._metadata_processor = build_metadata_processor(self.od_config)
device = get_local_device()
if is_warmup_request(req):
payload = build_warmup_payload(
self.kind,
metadata_processor=self._metadata_processor,
device=device,
sample_rate=self.target_sr,
)
else:
payload = build_preprocess_payload(
self.kind,
prompt=prompt,
extra_args=extra_args,
preprocess=self,
metadata_processor=self._metadata_processor,
device=device,
sample_rate=self.target_sr,
)
return DiffusionOutput(
output={
"payload": {},
"metadata": {SOULX_PREPROCESSED_KEY: payload},
},
to_cpu=True,
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
del weights
self._ensure_stack({})
return set()
def _extract_vocal(
self,
audio: str | tuple[np.ndarray, int],
*,
vocal_sep: bool | None = None,
) -> tuple[np.ndarray, int]:
if self.stack is None:
self._ensure_stack({})
use_sep = self.vocal_sep if vocal_sep is None else vocal_sep
if use_sep:
return self.stack.extract_vocal(audio)
return load_mono_audio(audio)
def extract_f0(self, vocal: np.ndarray, sample_rate: int) -> np.ndarray:
if self.stack is None:
self._ensure_stack({})
return self.stack.extract_f0(vocal, sample_rate)
def iter_svs_metadata(
self,
audio_source: str | tuple[np.ndarray, int],
*,
language: str = "Mandarin",
vocal_sep: bool | None = None,
max_merge_duration_ms: int | None = None,
):
"""Yield merged SVS metadata dicts one segment at a time."""
if isinstance(audio_source, tuple):
vocal, sample_rate = audio_source
origin_wav_fn = ""
else:
vocal, sample_rate = self._extract_vocal(str(audio_source), vocal_sep=vocal_sep)
origin_wav_fn = str(audio_source)
with tempfile.TemporaryDirectory(prefix="soulx_preprocess_") as work_dir:
work = Path(work_dir)
vocal_path = work / "vocal.wav"
sf.write(vocal_path, vocal, sample_rate)
vocal_f0_path = str(vocal_path).replace(".wav", "_f0.npy")
vocal_f0 = self.stack.extract_f0(str(vocal_path), sample_rate=sample_rate, f0_path=vocal_f0_path)
if not self.midi_transcribe:
end_ms = int(len(vocal) / sample_rate * 1000)
item = SegmentMetadata(
item_name="full",
wav_fn=str(vocal_path),
language=language,
start_time_ms=0,
end_time_ms=end_ms,
note_text=["<SP>"],
note_dur=[end_ms / 1000.0],
note_pitch=[0],
note_type=[1],
)
yield convert_metadata(item)
return
segments = self.stack.ensure_segmenter().forward(
vocal,
sample_rate,
vocal_f0,
base_name="vocal",
origin_wav_fn=origin_wav_fn,
verbose=self.verbose,
)
cut_dir = work / "cut_wavs"
cut_dir.mkdir(parents=True, exist_ok=True)
lyric = self.stack.ensure_lyric()
rosvot = self.stack.ensure_rosvot()
long_cut_dir = work / "long_cut_wavs"
max_dur = max_merge_duration_ms or self.max_merge_duration_ms
max_gap_ms = 10000
current_group: list[Any] = []
current_len = 0
prev_end = -1
def _flush_group() -> dict[str, Any]:
merged_item = _merge_group(vocal, sample_rate, current_group, long_cut_dir)
merged_f0_path = merged_item.wav_fn.replace(".wav", "_f0.npy")
self.stack.extract_f0(merged_item.wav_fn, sample_rate=sample_rate, f0_path=merged_f0_path)
return convert_metadata(merged_item)
for seg in segments:
seg_key = seg["item_name"]
seg_wav_path = cut_dir / f"{seg_key}.wav"
sf.write(seg_wav_path, seg["wav"], seg["sample_rate"])
seg_f0_path = str(seg_wav_path).replace(".wav", "_f0.npy")
self.stack.extract_f0(str(seg_wav_path), sample_rate=seg["sample_rate"], f0_path=seg_f0_path)
words, durs = lyric.forward(
str(seg_wav_path),
language,
sample_rate=seg["sample_rate"],
)
seg_item = {
"item_name": seg_key,
"wav_fn": str(seg_wav_path),
"start_time_ms": seg["start_time_ms"],
"end_time_ms": seg["end_time_ms"],
"origin_wav_fn": origin_wav_fn or str(vocal_path),
"words": words,
"word_durs": durs,
"language": language,
}
meta_item = rosvot.transcribe(seg_item, segment_info=seg_item, verbose=self.verbose)
start_time = int(meta_item.get("start_time_ms", seg["start_time_ms"]))
end_time = int(meta_item.get("end_time_ms", seg["end_time_ms"]))
if current_group and (
start_time - prev_end > max_gap_ms or current_len + end_time - start_time > max_dur
):
yield _flush_group()
current_group = []
current_len = 0
current_group.append(meta_item)
current_len += end_time - start_time
prev_end = end_time
if current_group:
yield _flush_group()
@staticmethod
def build_svs_payload_from_paths(
*,
prompt_metadata_path: str,
target_metadata_path: str,
audio_path: str,
metadata_processor,
) -> dict[str, Any]:
with open(prompt_metadata_path, encoding="utf-8") as f:
prompt_metadata = json.load(f)
with open(target_metadata_path, encoding="utf-8") as f:
target_metadata = json.load(f)
if not prompt_metadata:
raise ValueError("prompt_metadata is empty")
if not target_metadata:
raise ValueError("target_metadata is empty")
prompt_meta = metadata_processor.process(prompt_metadata[0], audio_path)
return {
"kind": "svs",
"prompt_meta": prompt_meta,
"target_meta_list": target_metadata,
}
def build_svs_payload_from_audio(
self,
*,
prompt_audio: str | tuple[np.ndarray, int],
target_audio: str | tuple[np.ndarray, int],
metadata_processor,
language: str = "Mandarin",
vocal_sep: bool | None = None,
prompt_vocal_sep: bool | None = None,
target_vocal_sep: bool | None = None,
prompt_max_merge_duration_ms: int | None = None,
target_max_merge_duration_ms: int | None = None,
) -> dict[str, Any]:
p_sep = prompt_vocal_sep if prompt_vocal_sep is not None else vocal_sep
t_sep = target_vocal_sep if target_vocal_sep is not None else vocal_sep
p_merge = (
prompt_max_merge_duration_ms if prompt_max_merge_duration_ms is not None else self.max_merge_duration_ms
)
t_merge = (
target_max_merge_duration_ms if target_max_merge_duration_ms is not None else self.max_merge_duration_ms
)
prompt_list = list(
self.iter_svs_metadata(
prompt_audio,
language=language,
vocal_sep=p_sep,
max_merge_duration_ms=p_merge,
)
)
target_list = list(
self.iter_svs_metadata(
target_audio,
language=language,
vocal_sep=t_sep,
max_merge_duration_ms=t_merge,
)
)
if not prompt_list or not target_list:
raise ValueError("SVS preprocess produced empty metadata")
# Prompt wav is trimmed to mel2note length, not the metadata time window.
prompt_meta = build_svs_prompt_meta(
self,
prompt_audio=prompt_audio,
prompt_meta_raw=prompt_list[0],
metadata_processor=metadata_processor,
vocal_sep=vocal_sep,
)
return {
"kind": "svs",
"prompt_meta": prompt_meta,
"target_meta_list": target_list,
}
@staticmethod
def build_svc_payload_from_paths(
*,
prompt_wav_path: str,
target_wav_path: str,
prompt_f0_path: str,
target_f0_path: str,
sample_rate: int,
device: torch.device | str,
) -> dict[str, Any]:
prompt_wav = load_wav(prompt_wav_path, sample_rate).to(device)
target_wav = load_wav(target_wav_path, sample_rate).to(device)
prompt_f0 = torch.from_numpy(np.load(prompt_f0_path)).unsqueeze(0).to(device)
target_f0 = torch.from_numpy(np.load(target_f0_path)).unsqueeze(0).to(device)
return {
"kind": "svc",
"prompt_wav": prompt_wav,
"target_wav": target_wav,
"prompt_f0": prompt_f0,
"target_f0": target_f0,
}
def build_svc_payload_from_audio(
self,
*,
prompt_audio: str | tuple[np.ndarray, int],
target_audio: str | tuple[np.ndarray, int],
sample_rate: int,
device: torch.device | str,
vocal_sep: bool | None = None,
) -> dict[str, Any]:
prompt_vocal, prompt_sr = self._extract_vocal(prompt_audio, vocal_sep=vocal_sep)
target_vocal, target_sr = self._extract_vocal(target_audio, vocal_sep=vocal_sep)
if prompt_sr != sample_rate:
prompt_vocal = resample_mono(prompt_vocal, orig_sr=prompt_sr, target_sr=sample_rate)
if target_sr != sample_rate:
target_vocal = resample_mono(target_vocal, orig_sr=target_sr, target_sr=sample_rate)
prompt_f0 = self.extract_f0(prompt_vocal, sample_rate)
target_f0 = self.extract_f0(target_vocal, sample_rate)
return {
"kind": "svc",
"prompt_wav": torch.from_numpy(prompt_vocal).unsqueeze(0).to(device),
"target_wav": torch.from_numpy(target_vocal).unsqueeze(0).to(device),
"prompt_f0": torch.from_numpy(prompt_f0).unsqueeze(0).to(device),
"target_f0": torch.from_numpy(target_f0).unsqueeze(0).to(device),
}
@@ -1,593 +0,0 @@
"""RMVPE F0 extractor.
All model code is inlined (originally from RickyL-2000/ROSVOT
``modules/pe/rmvpe/``) with two minor substitutions:
* ``librosa.filters.mel`` → ``torchaudio.functional.melscale_fbanks``
(already used elsewhere in vllm-omni);
* ``librosa.sequence.viterbi`` → hand-written numpy viterbi (non-default path).
* ``pyworld`` (deprecated) → removed.
"""
from __future__ import annotations
import math
from typing import ClassVar
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchaudio.functional import melscale_fbanks
from torchaudio.transforms import Resample
from vllm.logger import init_logger
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportsComponentDiscovery
logger = init_logger(__name__)
RMVPE_SAMPLE_RATE = 16000
_N_CLASS = 360
_N_MELS = 128
_MEL_FMIN = 30
_MEL_FMAX = 8000
_WINDOW_LENGTH = 1024
_CONST = 1997.3794084376191
class ConvBlockRes(nn.Module):
def __init__(self, in_channels: int, out_channels: int, momentum: float = 0.01):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False),
nn.BatchNorm2d(out_channels, momentum=momentum),
nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False),
nn.BatchNorm2d(out_channels, momentum=momentum),
nn.ReLU(),
)
if in_channels != out_channels:
self.shortcut = nn.Conv2d(in_channels, out_channels, (1, 1))
self._has_shortcut = True
else:
self._has_shortcut = False
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self._has_shortcut:
return self.conv(x) + self.shortcut(x)
return self.conv(x) + x
class ResEncoderBlock(nn.Module):
def __init__(self, in_channels: int, out_channels: int, kernel_size, n_blocks: int = 1, momentum: float = 0.01):
super().__init__()
self.n_blocks = n_blocks
self.conv = nn.ModuleList()
self.conv.append(ConvBlockRes(in_channels, out_channels, momentum))
for _ in range(n_blocks - 1):
self.conv.append(ConvBlockRes(out_channels, out_channels, momentum))
self.kernel_size = kernel_size
if self.kernel_size is not None:
self.pool = nn.AvgPool2d(kernel_size=kernel_size)
def forward(self, x: torch.Tensor) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
for i in range(self.n_blocks):
x = self.conv[i](x)
if self.kernel_size is not None:
return x, self.pool(x)
return x
class ResDecoderBlock(nn.Module):
def __init__(self, in_channels: int, out_channels: int, stride, n_blocks: int = 1, momentum: float = 0.01):
super().__init__()
out_padding = (0, 1) if stride == (1, 2) else (1, 1)
self.n_blocks = n_blocks
self.conv1 = nn.Sequential(
nn.ConvTranspose2d(
in_channels,
out_channels,
kernel_size=(3, 3),
stride=stride,
padding=(1, 1),
output_padding=out_padding,
bias=False,
),
nn.BatchNorm2d(out_channels, momentum=momentum),
nn.ReLU(),
)
self.conv2 = nn.ModuleList()
self.conv2.append(ConvBlockRes(out_channels * 2, out_channels, momentum))
for _ in range(n_blocks - 1):
self.conv2.append(ConvBlockRes(out_channels, out_channels, momentum))
def forward(self, x: torch.Tensor, concat_tensor: torch.Tensor) -> torch.Tensor:
x = self.conv1(x)
x = torch.cat((x, concat_tensor), dim=1)
for i in range(self.n_blocks):
x = self.conv2[i](x)
return x
class Encoder(nn.Module):
def __init__(
self,
in_channels: int,
in_size: int,
n_encoders: int,
kernel_size,
n_blocks: int,
out_channels: int = 16,
momentum: float = 0.01,
):
super().__init__()
self.n_encoders = n_encoders
self.bn = nn.BatchNorm2d(in_channels, momentum=momentum)
self.layers = nn.ModuleList()
self.latent_channels: list[list[int]] = []
for _ in range(self.n_encoders):
self.layers.append(ResEncoderBlock(in_channels, out_channels, kernel_size, n_blocks, momentum=momentum))
self.latent_channels.append([out_channels, in_size])
in_channels = out_channels
out_channels *= 2
in_size //= 2
self.out_size = in_size
self.out_channel = out_channels
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
concat_tensors: list[torch.Tensor] = []
x = self.bn(x)
for i in range(self.n_encoders):
_c, x = self.layers[i](x)
concat_tensors.append(_c)
return x, concat_tensors
class Intermediate(nn.Module):
def __init__(self, in_channels: int, out_channels: int, n_inters: int, n_blocks: int, momentum: float = 0.01):
super().__init__()
self.n_inters = n_inters
self.layers = nn.ModuleList()
self.layers.append(ResEncoderBlock(in_channels, out_channels, None, n_blocks, momentum))
for _ in range(n_inters - 1):
self.layers.append(ResEncoderBlock(out_channels, out_channels, None, n_blocks, momentum))
def forward(self, x: torch.Tensor) -> torch.Tensor:
for i in range(self.n_inters):
x = self.layers[i](x)
return x
class Decoder(nn.Module):
def __init__(self, in_channels: int, n_decoders: int, stride, n_blocks: int, momentum: float = 0.01):
super().__init__()
self.layers = nn.ModuleList()
self.n_decoders = n_decoders
for _ in range(self.n_decoders):
out_channels = in_channels // 2
self.layers.append(ResDecoderBlock(in_channels, out_channels, stride, n_blocks, momentum))
in_channels = out_channels
def forward(self, x: torch.Tensor, concat_tensors: list[torch.Tensor]) -> torch.Tensor:
for i in range(self.n_decoders):
x = self.layers[i](x, concat_tensors[-1 - i])
return x
class TimbreFilter(nn.Module):
def __init__(self, latent_rep_channels: list[list[int]]):
super().__init__()
self.layers = nn.ModuleList()
for ch in latent_rep_channels:
self.layers.append(ConvBlockRes(ch[0], ch[0]))
def forward(self, x_tensors: list[torch.Tensor]) -> list[torch.Tensor]:
return [layer(t) for layer, t in zip(self.layers, x_tensors)]
class DeepUnet0(nn.Module):
def __init__(
self,
kernel_size,
n_blocks: int,
en_de_layers: int = 5,
inter_layers: int = 4,
in_channels: int = 1,
en_out_channels: int = 16,
):
super().__init__()
self.encoder = Encoder(in_channels, _N_MELS, en_de_layers, kernel_size, n_blocks, en_out_channels)
self.intermediate = Intermediate(
self.encoder.out_channel // 2, self.encoder.out_channel, inter_layers, n_blocks
)
self.tf = TimbreFilter(self.encoder.latent_channels)
self.decoder = Decoder(self.encoder.out_channel, en_de_layers, kernel_size, n_blocks)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x, concat_tensors = self.encoder(x)
x = self.intermediate(x)
x = self.decoder(x, concat_tensors)
return x
class BiGRU(nn.Module):
def __init__(self, input_features: int, hidden_features: int, num_layers: int):
super().__init__()
self.gru = nn.GRU(input_features, hidden_features, num_layers=num_layers, batch_first=True, bidirectional=True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.gru(x)[0]
class E2E0(nn.Module):
"""E2E0 pitch estimator network (DeepUnet0 + CNN + BiGRU + classifier)."""
def __init__(
self,
n_blocks: int,
n_gru: int,
kernel_size,
en_de_layers: int = 5,
inter_layers: int = 4,
in_channels: int = 1,
en_out_channels: int = 16,
):
super().__init__()
self.unet = DeepUnet0(kernel_size, n_blocks, en_de_layers, inter_layers, in_channels, en_out_channels)
self.cnn = nn.Conv2d(en_out_channels, 3, (3, 3), padding=(1, 1))
if n_gru:
self.fc = nn.Sequential(
BiGRU(3 * _N_MELS, 256, n_gru),
nn.Linear(512, _N_CLASS),
nn.Dropout(0.25),
nn.Sigmoid(),
)
else:
self.fc = nn.Sequential(
nn.Linear(3 * _N_MELS, _N_CLASS),
nn.Dropout(0.25),
nn.Sigmoid(),
)
def forward(self, mel: torch.Tensor) -> torch.Tensor:
mel = mel.transpose(-1, -2).unsqueeze(1)
x = self.cnn(self.unet(mel)).transpose(1, 2).flatten(-2)
x = self.fc(x)
return x
class MelSpectrogram(nn.Module):
"""Mel-spectrogram extractor matching ROSVOT's MelSpectrogram.
Log-mel spectrogram via torch.stft + adaptive filterbank.
"""
def __init__(
self,
n_mel_channels: int = _N_MELS,
sampling_rate: int = RMVPE_SAMPLE_RATE,
win_length: int = _WINDOW_LENGTH,
hop_length: int = 160,
n_fft: int | None = None,
mel_fmin: float = _MEL_FMIN,
mel_fmax: float = _MEL_FMAX,
clamp: float = 1e-5,
):
super().__init__()
n_fft = win_length if n_fft is None else n_fft
self.hann_window: dict[str, torch.Tensor] = {}
mel_basis = melscale_fbanks(
n_freqs=n_fft // 2 + 1,
f_min=float(mel_fmin),
f_max=float(mel_fmax) if mel_fmax is not None else 8000.0,
n_mels=n_mel_channels,
sample_rate=int(sampling_rate),
norm="slaney",
mel_scale="htk",
).T # (n_freqs, n_mels) → (n_mels, n_freqs) to match librosa/layernorm convention
self.register_buffer("mel_basis", mel_basis) # (n_mels, n_freqs)
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
self.sampling_rate = sampling_rate
self.n_mel_channels = n_mel_channels
self.clamp = clamp
def forward(self, audio: torch.Tensor, keyshift: float = 0, speed: float = 1, center: bool = True) -> torch.Tensor:
factor = 2 ** (keyshift / 12)
n_fft_new = int(np.round(self.n_fft * factor))
win_length_new = int(np.round(self.win_length * factor))
hop_length_new = int(np.round(self.hop_length * speed))
key = str(keyshift) + "_" + str(audio.device)
if key not in self.hann_window:
self.hann_window[key] = torch.hann_window(win_length_new).to(audio.device)
if center:
pad_left = win_length_new // 2
pad_right = (win_length_new + 1) // 2
audio = F.pad(audio, (pad_left, pad_right))
fft = torch.stft(
audio,
n_fft=n_fft_new,
hop_length=hop_length_new,
win_length=win_length_new,
window=self.hann_window[key],
center=False,
return_complex=True,
)
magnitude = fft.abs()
if keyshift != 0:
size = self.n_fft // 2 + 1
if magnitude.size(1) < size:
magnitude = F.pad(magnitude, (0, 0, 0, size - magnitude.size(1)))
magnitude = magnitude[:, :size, :] * self.win_length / win_length_new
# register_buffer confuses type checkers; cast to Tensor explicitly
mel_basis: torch.Tensor = self.mel_basis # type: ignore[assignment]
mel_output = torch.matmul(mel_basis, magnitude)
log_mel = torch.log(torch.clamp(mel_output, min=self.clamp))
return log_mel
def to_local_average_f0(hidden: torch.Tensor, center: torch.Tensor | None = None, thread: float = 0.03) -> np.ndarray:
"""Decode hidden representation to F0 via local weighted average."""
idx = torch.arange(_N_CLASS, device=hidden.device)[None, None, :]
idx_cents = idx * 20 + _CONST
if center is None:
center = torch.argmax(hidden, dim=2, keepdim=True)
start = torch.clip(center - 4, min=0)
end = torch.clip(center + 5, max=_N_CLASS)
idx_mask = (idx >= start) & (idx < end)
weights = hidden * idx_mask
product_sum = torch.sum(weights * idx_cents, dim=2)
weight_sum = torch.sum(weights, dim=2)
cents = product_sum / (weight_sum + (weight_sum == 0))
f0 = 10 * 2 ** (cents / 1200)
uv = hidden.max(dim=2)[0] < thread
f0 = f0 * ~uv
return f0.cpu().numpy()
def numpy_viterbi(prob: np.ndarray, transition: np.ndarray) -> np.ndarray:
"""Viterbi decoding on log-probabilities (numpy).
Args:
prob: Emission probabilities, shape (n_states, n_obs).
transition: Transition matrix, shape (n_states, n_states), row-stochastic.
Returns:
path: Most likely state sequence, shape (n_obs,).
"""
n_states, n_obs = prob.shape
log_emit = np.log(prob + 1e-12)
log_trans = np.log(transition + 1e-12)
delta = log_emit[:, 0] + np.zeros(n_states) # initial: log(pi) omitted (uniform)
psi = np.zeros((n_obs, n_states), dtype=np.int64)
for t in range(1, n_obs):
scores = delta[:, None] + log_trans # (n_states, n_states)
psi[t] = np.argmax(scores, axis=0)
delta = scores[psi[t], np.arange(n_states)] + log_emit[:, t]
# Backtrack
path = np.zeros(n_obs, dtype=np.int64)
path[-1] = np.argmax(delta)
for t in range(n_obs - 2, -1, -1):
path[t] = psi[t + 1, path[t + 1]]
return path
def to_viterbi_f0(hidden: torch.Tensor, thread: float = 0.03) -> np.ndarray:
"""Decode hidden representation to F0 via Viterbi.
Uses a hand-written numpy Viterbi decoder (replacing
``librosa.sequence.viterbi``) to avoid the librosa dependency.
"""
if not hasattr(to_viterbi_f0, "_transition"):
xx, yy = np.meshgrid(range(_N_CLASS), range(_N_CLASS))
transition = np.maximum(30 - abs(xx - yy), 0)
transition = transition / transition.sum(axis=1, keepdims=True)
to_viterbi_f0._transition = transition # type: ignore[attr-defined]
prob = hidden.squeeze(0).cpu().numpy()
prob = prob.T
prob = prob / prob.sum(axis=0, keepdims=True)
path = numpy_viterbi(prob, to_viterbi_f0._transition) # type: ignore[attr-defined]
center = torch.from_numpy(path).unsqueeze(0).unsqueeze(-1).to(hidden.device)
return to_local_average_f0(hidden, center=center, thread=thread)
def resample_align_curve(
points: np.ndarray, original_timestep: float, target_timestep: float, align_length: int = -1
) -> np.ndarray:
"""Align a curve from one time grid to another via linear interpolation."""
t_max = (len(points) - 1) * original_timestep
curve_interp = np.interp(
np.arange(0, t_max, target_timestep),
original_timestep * np.arange(len(points)),
points,
).astype(points.dtype)
if align_length > 0:
if len(curve_interp) > align_length:
curve_interp = curve_interp[:align_length]
elif len(curve_interp) < align_length:
curve_interp = np.concatenate(
[curve_interp, np.full(align_length - len(curve_interp), fill_value=curve_interp[-1])],
axis=0,
)
return curve_interp
class RMVPE(nn.Module, SupportAudioInput, SupportsComponentDiscovery):
"""RMVPE F0 extractor (SoulX-Singer).
Wraps the E2E0 model with mel-spectrogram front-end and decoding
post-processing.
"""
support_audio_input: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = []
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = ["."]
_layerwise_offload_blocks_attrs: ClassVar[list[str]] = []
def __init__(self, model_path: str | None = None, hop_length: int = 160, device: str | None = None):
super().__init__()
self.resample_kernel: dict[str, Resample] = {}
if device is None:
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
self.device = torch.device(device)
self.model = E2E0(4, 1, (2, 2)).eval()
if model_path is not None:
ckpt = torch.load(model_path, map_location=self.device)
state_dict = ckpt if not isinstance(ckpt, dict) else ckpt.get("model", ckpt)
self.model.load_state_dict(state_dict, strict=False)
logger.info("Loaded RMVPE weights from %s", model_path)
self.model = self.model.to(self.device)
self.mel_extractor = MelSpectrogram(
_N_MELS,
RMVPE_SAMPLE_RATE,
_WINDOW_LENGTH,
hop_length,
None,
_MEL_FMIN,
_MEL_FMAX,
).to(self.device)
self.hop_length = hop_length
# ---- internal helpers ----
@torch.no_grad()
def mel2hidden(self, mel: torch.Tensor) -> torch.Tensor:
n_frames = mel.shape[-1]
pad_len = 32 * ((n_frames - 1) // 32 + 1) - n_frames
mel = F.pad(mel, (0, pad_len), mode="constant")
hidden = self.model(mel)
return hidden[:, :n_frames]
def decode(self, hidden: torch.Tensor, thread: float = 0.03, use_viterbi: bool = False) -> np.ndarray:
if use_viterbi:
return to_viterbi_f0(hidden, thread=thread)
return to_local_average_f0(hidden, thread=thread)
def postprocess(self, f0: np.ndarray, fmin: float = 50, fmax: float = 1000, min_gap: int = 2) -> np.ndarray:
f0[f0 < fmin] = 0
f0[f0 > fmax] = 0
# Eliminate glitch: if positive f0 values span fewer than min_gap frames
# between zeros, zero them out.
for idx in range(f0.shape[0] - min_gap - 1):
if f0[idx] == 0 and f0[idx + min_gap + 1] == 0 and np.sum(f0[idx : idx + min_gap + 2]) > 0:
f0[idx : idx + min_gap + 2] = 0
return f0
# ---- single audio inference ----
def infer_from_audio(
self, audio: np.ndarray, sample_rate: int = 16000, thread: float = 0.03, use_viterbi: bool = False
) -> np.ndarray:
audio_t = torch.from_numpy(audio).float().unsqueeze(0).to(self.device)
if sample_rate == RMVPE_SAMPLE_RATE:
audio_res = audio_t
else:
key_str = str(sample_rate)
if key_str not in self.resample_kernel:
self.resample_kernel[key_str] = Resample(sample_rate, RMVPE_SAMPLE_RATE, lowpass_filter_width=128)
self.resample_kernel[key_str] = self.resample_kernel[key_str].to(self.device)
audio_res = self.resample_kernel[key_str](audio_t)
mel = self.mel_extractor(audio_res, center=True)
hidden = self.mel2hidden(mel)
f0 = self.decode(hidden, thread=thread, use_viterbi=use_viterbi).squeeze(0)
return f0
def get_pitch(
self,
waveform,
sample_rate: int,
hop_size: int,
length: int,
interp_uv: bool = False,
fmin: float = 50,
fmax: float = 1000,
) -> tuple[np.ndarray, np.ndarray]:
f0 = self.infer_from_audio(waveform, sample_rate=sample_rate)
f0 = self.postprocess(f0, fmin, fmax)
uv = f0 == 0
time_step = hop_size / sample_rate
f0_res = resample_align_curve(f0, 0.01, time_step, length)
uv_res = resample_align_curve(uv.astype(np.float32), 0.01, time_step, length) > 0.5
if not interp_uv:
f0_res[uv_res] = 0
return f0_res, uv_res
# ---- batch audio inference ----
def infer_from_audio_batch(
self, audios, sample_rate: int = 16000, thread: float = 0.03, use_viterbi: bool = False
) -> list[np.ndarray]:
sizes: list[int] | None = None
if isinstance(audios, list):
audios = [torch.from_numpy(a).float() for a in audios]
sizes = [math.ceil((a.shape[0] + 1) / self.hop_length) for a in audios]
audios = nn.utils.rnn.pad_sequence(audios, batch_first=True, padding_value=0.0).to(self.device)
elif isinstance(audios, torch.Tensor):
if audios.device != self.device:
audios = audios.to(self.device)
else:
raise NotImplementedError(f"Unsupported audio type: {type(audios)}")
if sample_rate == RMVPE_SAMPLE_RATE:
audios_res = audios
else:
key_str = str(sample_rate)
if key_str not in self.resample_kernel:
self.resample_kernel[key_str] = Resample(sample_rate, RMVPE_SAMPLE_RATE, lowpass_filter_width=128)
self.resample_kernel[key_str] = self.resample_kernel[key_str].to(self.device)
audios_res = self.resample_kernel[key_str](audios)
mels = self.mel_extractor(audios_res, center=True)
hiddens = self.mel2hidden(mels)
f0 = self.decode(hiddens, thread=thread, use_viterbi=use_viterbi)
f0s: list[np.ndarray] = []
for i in range(f0.shape[0]):
f0s.append(f0[i, : sizes[i]] if sizes is not None else f0[i, :])
return f0s
def get_pitch_batch(
self,
waveforms,
sample_rate: int,
hop_size: int,
lengths: list[int],
interp_uv: bool = False,
fmin: float = 50,
fmax: float = 1000,
) -> tuple[list[np.ndarray], list[np.ndarray]]:
f0s = self.infer_from_audio_batch(waveforms, sample_rate=sample_rate)
f0s_res: list[np.ndarray] = []
uvs_res: list[np.ndarray] = []
for idx, f0 in enumerate(f0s):
f0 = self.postprocess(f0, fmin, fmax, min_gap=6)
uv = f0 == 0
length = lengths[idx]
time_step = hop_size / sample_rate
f0_res = resample_align_curve(f0, 0.01, time_step, length)
uv_res = resample_align_curve(uv.astype(np.float32), 0.01, time_step, length) > 0.5
if not interp_uv:
f0_res[uv_res] = 0
f0s_res.append(f0_res)
uvs_res.append(uv_res)
return f0s_res, uvs_res
def release_cuda(self):
self.model = self.model.cpu()
self.mel_extractor = self.mel_extractor.cpu()
@@ -1,619 +0,0 @@
"""ROSVOT note transcription."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any, ClassVar
import numpy as np
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportsComponentDiscovery
from vllm_omni.diffusion.models.soulx_singer.modules.mel_transform import spectral_normalize_torch
from vllm_omni.diffusion.models.soulx_singer.modules.note_transcription import (
BackboneNet,
ConvBlocks,
Embedding,
)
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.utils import (
boundary2Interval,
denorm_f0,
f0_to_coarse,
load_model_ckpt,
load_mono_audio,
load_rosvot_config,
norm_interp_f0,
pad_or_cut_xd,
resample_mono,
)
from vllm_omni.utils.audio import mel_filter_bank as librosa_mel_fn
logger = init_logger(__name__)
def regulate_real_note_itv(
note_itv: np.ndarray,
note_bd: np.ndarray,
word_bd: np.ndarray,
word_durs: np.ndarray,
hop_size: int,
audio_sample_rate: int,
) -> tuple[np.ndarray, np.ndarray]:
assert note_itv.shape[0] == np.sum(note_bd) + 1
assert np.sum(word_bd) <= np.sum(note_bd)
assert word_durs.shape[0] == np.sum(word_bd) + 1, f"{word_durs.shape[0]} {np.sum(word_bd) + 1}"
word_bd = np.cumsum(word_bd) * word_bd
word_itv = np.zeros((word_durs.shape[0], 2))
word_offsets = np.cumsum(word_durs)
note2words = np.zeros(note_itv.shape[0], dtype=int)
for idx in range(len(word_offsets) - 1):
word_itv[idx, 1] = word_itv[idx + 1, 0] = word_offsets[idx]
word_itv[-1, 1] = word_offsets[-1]
note_itv_secs = note_itv * hop_size / audio_sample_rate
for idx, itv in enumerate(note_itv):
start_idx, end_idx = itv
if word_bd[start_idx] > 0:
word_dur_idx = word_bd[start_idx]
note_itv_secs[idx, 0] = word_itv[word_dur_idx, 0]
note2words[idx] = word_dur_idx
if word_bd[end_idx] > 0:
word_dur_idx = word_bd[end_idx] - 1
note_itv_secs[idx, 1] = word_itv[word_dur_idx, 1]
note2words[idx] = word_dur_idx
note2words += 1
return note_itv_secs, note2words
def regulate_ill_slur(
notes: np.ndarray, note_itv: np.ndarray, note2words: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
res_note2words: list[int] = []
res_note_itv: list[list[float]] = []
res_notes: list[int] = []
note_idx = 0
note_idx_end = 0
while note_idx <= len(notes) - 1:
while note_idx <= note_idx_end < len(notes) and note2words[note_idx] == note2words[note_idx_end]:
note_idx_end += 1
res_note2words.append(note2words[note_idx])
res_note_itv.append(note_itv[note_idx].tolist())
res_notes.append(notes[note_idx])
for idx in range(note_idx + 1, note_idx_end):
if notes[idx] == notes[idx - 1]:
res_note_itv[-1][1] = note_itv[idx][1]
else:
res_note_itv.append(note_itv[idx].tolist())
res_note2words.append(note2words[idx])
res_notes.append(notes[idx])
note_idx = note_idx_end
return (
np.array(res_notes, dtype=notes.dtype),
np.array(res_note_itv, dtype=note_itv.dtype),
np.array(res_note2words, dtype=note2words.dtype),
)
def regulate_boundary(bd_logits, threshold, min_gap=18, ref_bd=None, ref_bd_min_gap=8, non_padding=None):
# this doesn't preserve gradient
device = bd_logits.device
bd_logits = torch.sigmoid(bd_logits).data.cpu()
# bd_logits[0] = bd_logits[-1] = 1e-5 # avoid itv invalid problem
bd = (bd_logits > threshold).long()
bd_res = torch.zeros_like(bd).long()
for i in range(bd.shape[0]):
bd_i = bd[i]
last_bd_idx = -1
start = -1
for j in range(bd_i.shape[0]):
if bd_i[j] == 1:
if 0 <= start < j:
continue
elif start < 0:
start = j
else:
if 0 <= start < j:
if j - 1 > start:
bd_idx = start + int(torch.argmax(bd_logits[i, start:j]).item())
else:
bd_idx = start
if bd_idx - last_bd_idx < min_gap and last_bd_idx > 0:
bd_idx = round((bd_idx + last_bd_idx) / 2)
bd_res[i, last_bd_idx] = 0
bd_res[i, bd_idx] = 1
last_bd_idx = bd_idx
start = -1
# assert ref_bd_min_gap <= min_gap // 2
if ref_bd is not None and ref_bd_min_gap > 0:
ref = ref_bd.data.cpu()
for i in range(bd_res.shape[0]):
ref_bd_i = ref[i]
ref_bd_i_js = []
for j in range(ref_bd_i.shape[0]):
if ref_bd_i[j] == 1:
ref_bd_i_js.append(j)
seg_sum = torch.sum(bd_res[i, max(0, j - ref_bd_min_gap) : j + ref_bd_min_gap])
if seg_sum == 0:
bd_res[i, j] = 1
elif seg_sum == 1 and bd_res[i, j] != 1:
bd_res[i, max(0, j - ref_bd_min_gap) : j + ref_bd_min_gap] = ref_bd_i[
max(0, j - ref_bd_min_gap) : j + ref_bd_min_gap
]
elif seg_sum > 1:
for k in range(1, ref_bd_min_gap + 1):
if bd_res[i, max(0, j - k)] == 1 and ref_bd_i[max(0, j - k)] != 1:
bd_res[i, max(0, j - k)] = 0
break
if (
bd_res[i, min(bd_res.shape[1] - 1, j + k)] == 1
and ref_bd_i[min(bd_res.shape[1] - 1, j + k)] != 1
):
bd_res[i, min(bd_res.shape[1] - 1, j + k)] = 0
break
bd_res[i, j] = 1
# final check
assert torch.sum(bd_res[i, ref_bd_i_js]) == len(ref_bd_i_js), (
f"{torch.sum(bd_res[i, ref_bd_i_js])} {len(ref_bd_i_js)}"
)
bd_res = bd_res.to(device)
# force valid begin and end
bd_res[:, 0] = 0
if non_padding is not None:
for i in range(bd_res.shape[0]):
bd_res[i, sum(non_padding[i]) - 1 :] = 0
else:
bd_res[:, -1] = 0
return bd_res
def _align_word(word_durs: list[float], mel_len: int, hop_size: int, audio_sample_rate: int) -> np.ndarray:
mel2word = np.zeros([mel_len], int)
start_time = 0.0
for i_word, wd in enumerate(word_durs):
start_frame = int(start_time * audio_sample_rate / hop_size + 0.5)
end_frame = int((start_time + wd) * audio_sample_rate / hop_size + 0.5)
mel2word[start_frame:end_frame] = i_word + 1
start_time += wd
return mel2word
class PitchDecoder(nn.Module):
def __init__(self, hparams):
super().__init__()
self.hidden_size = hidden_size = hparams["hidden_size"]
self.dropout = hparams.get("dropout", 0.0)
self.note_bd_out = nn.Linear(hidden_size, 1)
self.note_bd_temperature = max(1e-7, hparams.get("note_bd_temperature", 1.0))
# note prediction
self.pitch_attn_num_head = hparams.get("pitch_attn_num_head", 1)
self.multihead_dot_attn = nn.Linear(hidden_size, self.pitch_attn_num_head)
self.post = ConvBlocks(
hidden_size,
out_dims=hidden_size,
dilations=None,
kernel_size=3,
layers_in_block=1,
c_multiple=1,
post_net_kernel=3,
)
self.pitch_out = nn.Linear(hidden_size, hparams.get("note_num", 100) + 4)
self.note_num = hparams.get("note_num", 100)
self.note_start = hparams.get("note_start", 30)
self.pitch_temperature = max(1e-7, hparams.get("note_pitch_temperature", 1.0))
def forward(self, feat, note_bd, train=True):
bsz, T, _ = feat.shape
attn = torch.sigmoid(self.multihead_dot_attn(feat)) # [B, T, C] -> [B, T, num_head]
attn_feat = feat.unsqueeze(3) * attn.unsqueeze(2) # [B, T, C, 1] x [B, T, 1, num_head] -> [B, T, C, num_head]
attn_feat = torch.mean(attn_feat, dim=-1) # [B, T, C, num_head] -> [B, T, C]
mel2note = torch.cumsum(note_bd, 1)
note_length = torch.max(torch.sum(note_bd, dim=1)).item() + 1 # max length
note_lengths = torch.sum(note_bd, dim=1) + 1 # [B]
attn = torch.mean(attn, dim=-1, keepdim=True) # [B, T, num_head] -> [B, T, 1]
denom = mel2note.new_zeros(bsz, note_length, dtype=attn.dtype).scatter_add_(
dim=1, index=mel2note, src=attn.squeeze(-1)
) # [B, T] -> [B, note_length] count the note frames of each note (with padding excluded)
frame2note = mel2note.unsqueeze(-1).repeat(1, 1, self.hidden_size) # [B, T] -> [B, T, C], with padding included
note_aggregate = frame2note.new_zeros(bsz, note_length, self.hidden_size, dtype=attn_feat.dtype).scatter_add_(
dim=1, index=frame2note, src=attn_feat
) # [B, T, C] -> [B, note_length, C]
note_aggregate = note_aggregate / (denom.unsqueeze(-1) + 1e-5)
note_logits = self.post(note_aggregate)
note_logits = self.pitch_out(note_logits) / self.pitch_temperature
# note_logits = torch.clamp(note_logits, min=-16., max=16.) # don't know need it or not
note_pred = torch.softmax(note_logits, dim=-1) # [B, note_length, note_num]
note_pred = torch.argmax(note_pred, dim=-1) # [B, note_length]
# for some reason, note idx maybe 130 (why?)
note_pred[note_pred > self.note_num] = 0
note_pred[note_pred < self.note_start] = 0
return note_lengths, note_logits, note_pred
class MidiExtractor(nn.Module):
def __init__(self, hparams):
super().__init__()
self.hparams = hparams.copy()
self.hidden_size = hidden_size = hparams["hidden_size"]
self.note_bd_threshold = hparams.get("note_bd_threshold", 0.5)
self.note_bd_min_gap = round(
hparams.get("note_bd_min_gap", 100) * hparams["audio_sample_rate"] / 1000 / hparams["hop_size"]
)
self.note_bd_ref_min_gap = round(
hparams.get("note_bd_ref_min_gap", 50) * hparams["audio_sample_rate"] / 1000 / hparams["hop_size"]
)
self.mel_proj = nn.Conv1d(hparams["use_mel_bins"], hidden_size, kernel_size=3, padding=1)
self.mel_encoder = ConvBlocks(
hidden_size,
out_dims=hidden_size,
dilations=None,
kernel_size=3,
layers_in_block=2,
c_multiple=1,
post_net_kernel=3,
)
self.use_pitch = hparams.get("use_pitch_embed", True)
if self.use_pitch:
self.pitch_embed = Embedding(300, hidden_size, 0, "kaiming")
self.uv_embed = Embedding(3, hidden_size, 0, "kaiming")
self.use_wbd = hparams.get("use_wbd", True)
if self.use_wbd:
self.word_bd_embed = Embedding(3, hidden_size, 0, "kaiming")
self.cond_encoder = ConvBlocks(
hidden_size,
out_dims=hidden_size,
dilations=None,
kernel_size=3,
layers_in_block=1,
c_multiple=1,
post_net_kernel=3,
)
# backbone
self.net = BackboneNet(hparams)
# note bd prediction
self.note_bd_out = nn.Linear(hidden_size, 1)
self.note_bd_temperature = max(1e-7, hparams.get("note_bd_temperature", 1.0))
# note prediction
self.pitch_decoder = PitchDecoder(hparams)
def run_encoder(self, mel, word_bd=None, pitch=None, uv=None):
mel_embed = self.mel_proj(mel.transpose(1, 2)).transpose(1, 2)
mel_embed = self.mel_encoder(mel_embed)
pitch_embed = word_bd_embed = 0
if self.use_pitch and pitch is not None and uv is not None:
pitch_embed = self.pitch_embed(pitch) + self.uv_embed(uv) # [B, T, C]
if self.use_wbd and word_bd is not None:
word_bd_embed = self.word_bd_embed(word_bd)
feat = self.cond_encoder(mel_embed + pitch_embed + word_bd_embed)
return feat
def forward(self, mel, word_bd=None, note_bd=None, pitch=None, uv=None, non_padding=None, train=True):
ret = {}
bsz, T, _ = mel.shape
feat = self.run_encoder(mel, word_bd, pitch, uv)
feat = self.net(feat) # [B, T, C]
# note bd prediction
# dropout has been dropped (inference mode)
note_bd_logits = self.note_bd_out(feat).squeeze(-1) / self.note_bd_temperature
note_bd_logits = torch.clamp(note_bd_logits, min=-16.0, max=16.0)
ret["note_bd_logits"] = note_bd_logits # [B, T]
if note_bd is None or not train:
note_bd = regulate_boundary(
note_bd_logits,
self.note_bd_threshold,
self.note_bd_min_gap,
word_bd,
self.note_bd_ref_min_gap,
non_padding,
)
ret["note_bd_pred"] = note_bd # [B, T]
# note pitch prediction
note_lengths, note_logits, note_pred = self.pitch_decoder(feat, note_bd, train)
ret["note_lengths"], ret["note_logits"], ret["note_pred"] = note_lengths, note_logits, note_pred
return ret
class MelNet(nn.Module):
def __init__(self, hparams, device="cpu") -> None:
super().__init__()
self.n_fft = hparams["fft_size"]
self.num_mels = hparams["audio_num_mel_bins"]
self.sampling_rate = hparams["audio_sample_rate"]
self.hop_size = hparams["hop_size"]
self.win_size = hparams["win_size"]
self.fmin = hparams["fmin"]
self.fmax = hparams["fmax"]
self.device = device
mel = librosa_mel_fn(
sr=self.sampling_rate,
n_fft=self.n_fft,
n_mels=self.num_mels,
fmin=self.fmin,
fmax=self.fmax,
)
self.mel_basis = mel.float().to(self.device)
self.hann_window = torch.hann_window(self.win_size).to(self.device)
def to(self, device, **kwagrs):
super().to(device=device, **kwagrs)
self.mel_basis = self.mel_basis.to(device)
self.hann_window = self.hann_window.to(device)
self.device = device
def forward(self, y, center=False, complex=False):
if isinstance(y, np.ndarray):
y = torch.FloatTensor(y)
if len(y.shape) == 1:
y = y.unsqueeze(0)
y = y.clamp(min=-1.0, max=1.0).to(self.device)
pad_length = math.ceil(y.shape[1] / self.hop_size) * self.hop_size - y.shape[1]
y = torch.nn.functional.pad(
y.unsqueeze(1),
[int((self.n_fft - self.hop_size) / 2), int((self.n_fft - self.hop_size) / 2 + pad_length)],
mode="reflect",
)
y = y.squeeze(1)
spec = torch.stft(
y,
self.n_fft,
hop_length=self.hop_size,
win_length=self.win_size,
window=self.hann_window,
center=center,
pad_mode="reflect",
normalized=False,
onesided=True,
return_complex=True,
)
if not complex:
spec = torch.view_as_real(spec)
spec = torch.sqrt(spec.pow(2).sum(-1) + (1e-9)) # [B, n_fft, T]
spec = torch.matmul(self.mel_basis, spec)
spec = spectral_normalize_torch(spec)
spec = spec.transpose(1, 2) # [B, T, n_fft]
else:
B, C, T, _ = spec.shape
spec = spec.transpose(1, 2) # [B, T, n_fft, 2]
return spec
class RosvotModel(nn.Module, SupportAudioInput, SupportsComponentDiscovery):
support_audio_input: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = ["."]
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = []
_layerwise_offload_blocks_attrs: ClassVar[list[str]] = ["midi.net", "midi.pitch_decoder", "midi.cond_encoder"]
def __init__(
self,
rosvot_ckpt: str | Path,
*,
config_path: str | Path = "",
pe: nn.Module | None = None,
the: float = 0.85,
verbose: bool = False,
rosvot_source_dir: str | Path | None = None,
):
super().__init__()
self.verbose = verbose
ckpt = Path(rosvot_ckpt)
resolved_config = Path(config_path) if config_path else ckpt.with_name("config.yaml")
self.hparams = load_rosvot_config(resolved_config, hparams_str=f"note_bd_threshold={the}")
if verbose:
logger.info("ROSVOT config: %s", resolved_config)
self.midi = MidiExtractor(self.hparams)
self.mel_net = MelNet(self.hparams)
self.pe = pe if pe is not None and self.hparams.get("use_pitch_embed", False) else None
self._checkpoint_path = str(ckpt)
self.load_checkpoint(str(ckpt), verbose=verbose)
self.eval()
def load_checkpoint(self, checkpoint_path: str | None = None, *, verbose: bool = False) -> None:
load_model_ckpt(self.midi, checkpoint_path or self._checkpoint_path, verbose=verbose)
@torch.no_grad()
def forward(self, wav: torch.Tensor, word_durs: list[float]) -> dict[str, Any]:
hparams = self.hparams
if wav.ndim == 1:
wav = wav.unsqueeze(0)
mel_len = (wav.shape[-1] + hparams["hop_size"] - 1) // hparams["hop_size"]
min_word_dur = hparams.get("min_word_dur", 20) / 1000
wd_raw = list(word_durs)
word_durs_filtered: list[float] = []
for i, wd in enumerate(wd_raw):
if wd < min_word_dur:
if i == 0 and len(wd_raw) > 1:
wd_raw[i + 1] += wd
elif word_durs_filtered:
word_durs_filtered[-1] += wd
else:
word_durs_filtered.append(wd)
mel2word = _align_word(word_durs_filtered, mel_len, hparams["hop_size"], hparams["audio_sample_rate"])
if mel2word.size > 0 and mel2word[0] == 0:
mel2word = mel2word + 1
real_len = min(mel_len, int(np.sum(mel2word > 0)))
T = math.ceil(min(real_len, hparams["max_frames"]) / hparams["frames_multiple"]) * hparams["frames_multiple"]
device = wav.device
self.mel_net.to(device)
wav_t = pad_or_cut_xd(wav.float(), T * hparams["hop_size"], 1)
pitch_coarse = uv_t = None
if self.pe is not None:
f0s, uvs = self.pe.get_pitch_batch(
wav_t,
sample_rate=hparams["audio_sample_rate"],
hop_size=hparams["hop_size"],
lengths=[real_len],
fmax=hparams["f0_max"],
fmin=hparams["f0_min"],
)
f0_1d, uv_1d = norm_interp_f0(f0s[0][:T])
f0_t = pad_or_cut_xd(torch.as_tensor(f0_1d, device=device, dtype=torch.float32), T, 0).unsqueeze(0)
uv_t = pad_or_cut_xd(torch.as_tensor(uv_1d, device=device, dtype=torch.float32), T, 0).long().unsqueeze(0)
pitch_coarse = f0_to_coarse(denorm_f0(f0_t, uv_t)).to(device)
mel = pad_or_cut_xd(self.mel_net(wav_t)[0], T, dim=0).unsqueeze(0)
mel_nonpadding_mask = torch.zeros(1, T, device=device)
mel_nonpadding_mask[:, :real_len] = 1.0
mel = (mel.transpose(1, 2) * mel_nonpadding_mask.unsqueeze(1)).transpose(1, 2)
mel_nonpadding = mel.abs().sum(-1) > 0
mel2word_t = pad_or_cut_xd(torch.as_tensor(mel2word, device=device, dtype=torch.long), T, 0)
word_bd = torch.zeros_like(mel2word_t)
word_bd[1:] = (mel2word_t[1:] != mel2word_t[:-1]).long()
word_bd[real_len:] = 0
word_bd = word_bd.unsqueeze(0)
outputs = self.midi(
mel=mel[:, :, : hparams.get("use_mel_bins", 80)],
word_bd=word_bd,
pitch=pitch_coarse,
uv=uv_t,
non_padding=mel_nonpadding,
)
outputs["word_durs_filtered"] = word_durs_filtered
outputs["real_len"] = real_len
outputs["word_bd"] = word_bd
return outputs
@staticmethod
def _load_wav(wav_src: str | np.ndarray, sample_rate: int, *, src_sample_rate: int | None = None) -> np.ndarray:
if isinstance(wav_src, str):
wav, _ = load_mono_audio(wav_src, target_sr=sample_rate)
return wav
wav = np.asarray(wav_src, dtype=np.float32)
if src_sample_rate is not None and src_sample_rate != sample_rate:
wav = resample_mono(wav, orig_sr=src_sample_rate, target_sr=sample_rate)
return wav
@staticmethod
def _normalize_note2words(note2words: list[int]) -> list[int]:
if not note2words:
return []
out = [note2words[0]]
for idx in range(1, len(note2words)):
out.append(max(note2words[idx], out[-1]))
return out
@staticmethod
def _build_ep_types(note2words: list[int], align_words: list[str]) -> list[int]:
ep_types: list[int] = []
prev = -1
for i, w in zip(note2words, align_words):
ep_types.append(1 if w == "<SP>" else (2 if i != prev else 3))
prev = i
return ep_types
@torch.no_grad()
def transcribe(
self,
item: dict[str, Any],
*,
segment_info: dict[str, Any] | None = None,
verbose: bool = False,
) -> dict[str, Any]:
if "word_durs" not in item:
raise ValueError('item must contain "word_durs" from lyric transcription')
if item.get("wav_fn"):
wav = self._load_wav(item["wav_fn"], self.hparams["audio_sample_rate"])
elif item.get("wav") is not None:
wav = self._load_wav(
item["wav"],
self.hparams["audio_sample_rate"],
src_sample_rate=item.get("sample_rate"),
)
else:
raise ValueError('item must contain "wav_fn" or "wav"')
device = next(self.parameters()).device
outputs = self(torch.from_numpy(wav).float().to(device), list(item["word_durs"]))
real_len = int(outputs["real_len"])
word_durs_filtered = outputs["word_durs_filtered"]
word_bd = outputs["word_bd"]
item_name = item.get("item_name", "")
note_lengths = outputs["note_lengths"].detach().cpu().numpy()
note_bd_pred = outputs["note_bd_pred"][0].detach().cpu().numpy()[:real_len]
note_pred = outputs["note_pred"][0].detach().cpu().numpy()[: note_lengths[0]]
if note_pred.shape == (0,):
rosvot_out = {"item_name": item_name, "pitches": [], "note_durs": [], "note2words": None}
else:
note_itv_pred = boundary2Interval(note_bd_pred)
word_bd_for_reg = word_bd[0].detach().cpu().numpy()[:real_len]
hop = self.hparams["hop_size"]
sr = self.hparams["audio_sample_rate"]
if self.hparams.get("infer_regulate_real_note_itv", True):
try:
note_itv_pred_secs, note2words = regulate_real_note_itv(
note_itv_pred,
note_bd_pred,
word_bd_for_reg,
np.array(word_durs_filtered),
hop,
sr,
)
note_pred, note_itv_pred_secs, note2words = regulate_ill_slur(
note_pred, note_itv_pred_secs, note2words
)
except Exception:
if verbose:
logger.exception("ROSVOT postprocess failed")
note_itv_pred_secs = note_itv_pred * hop / sr
note2words = None
else:
note_itv_pred_secs = note_itv_pred * hop / sr
note2words = None
rosvot_out = {
"item_name": item_name,
"pitches": note_pred.tolist(),
"note_durs": [float(itv[1] - itv[0]) for itv in note_itv_pred_secs],
"note2words": note2words.tolist() if note2words is not None else None,
}
note2words_raw = rosvot_out.get("note2words") or []
align_words = [item["words"][idx - 1] for idx in note2words_raw if 0 < idx <= len(item["words"])]
ep_types = self._build_ep_types(self._normalize_note2words(note2words_raw), align_words) if align_words else []
seg = segment_info or item
return {
"item_name": seg.get("item_name", item_name),
"wav_fn": seg.get("wav_fn", item.get("wav_fn", "")),
"origin_wav_fn": seg.get("origin_wav_fn", item.get("origin_wav_fn", "")),
"start_time_ms": seg.get("start_time_ms", ""),
"end_time_ms": seg.get("end_time_ms", ""),
"language": item.get("language", ""),
"note_text": align_words,
"note_dur": rosvot_out.get("note_durs", []),
"note_type": ep_types,
"note_pitch": rosvot_out.get("pitches", []),
}
@@ -1,267 +0,0 @@
"""Rule-based vocal segmentation from F0 contour (no neural net)."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import ClassVar
import numpy as np
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportsComponentDiscovery
logger = init_logger(__name__)
@dataclass(frozen=True)
class VocalSegmentConfig:
hop_ms: int = 20
smooth_ms: int = 200
start_ms: int = 120
end_ms: int = 200
prepad_ms: int = 80
postpad_ms: int = 120
min_len_ms: int = 1000
max_len_ms: int = 20000
short_seg_merge_gap_ms: int = 8000
small_gap_ms: int = 500
lookback_ms: int = 200
lookahead_ms: int = 100
def _moving_average(x: np.ndarray, win: int) -> np.ndarray:
if win <= 1:
return x
kernel = np.ones(win, dtype=np.float32) / float(win)
return np.convolve(x, kernel, mode="same")
def _merge_short_segments(
segments_ms: list[list[int]],
*,
min_len_ms: int,
max_len_ms: int,
short_seg_merge_gap_ms: int,
small_gap_ms: int,
) -> list[list[int]]:
if not segments_ms:
return []
merged: list[list[int]] = []
cur_start, cur_end = segments_ms[0]
for next_start, next_end in segments_ms[1:]:
cur_len = cur_end - cur_start
gap_ms = next_start - cur_end
merged_len = next_end - cur_start
should_merge = (cur_len < min_len_ms and gap_ms < short_seg_merge_gap_ms) or (
gap_ms < small_gap_ms and merged_len < max_len_ms
)
if should_merge:
cur_end = next_end
continue
if (cur_end - cur_start) >= min_len_ms:
merged.append([cur_start, cur_end])
cur_start, cur_end = next_start, next_end
if (cur_end - cur_start) >= min_len_ms:
merged.append([cur_start, cur_end])
if not merged:
return segments_ms
return merged
def _voiced_to_segments(
voiced: np.ndarray,
*,
hop_ms: int,
smooth_ms: int,
start_ms: int,
end_ms: int,
prepad_ms: int,
postpad_ms: int,
max_len_ms: int,
) -> list[list[int]]:
smooth_frames = max(1, int(round(smooth_ms / hop_ms)))
active = _moving_average(voiced.astype(np.float32), smooth_frames) >= 0.5
segments: list[list[int]] = []
start_idx = None
start_frames = max(1, int(round(start_ms / hop_ms)))
end_frames = max(1, int(round(end_ms / hop_ms)))
prepad_frames = max(0, int(round(prepad_ms / hop_ms)))
postpad_frames = max(0, int(round(postpad_ms / hop_ms)))
active_count = 0
inactive_count = 0
for i, flag in enumerate(active):
if flag:
active_count += 1
inactive_count = 0
else:
inactive_count += 1
active_count = 0
if start_idx is None:
if active_count >= start_frames:
start_idx = max(0, i - start_frames + 1 - prepad_frames)
else:
if inactive_count >= end_frames:
end_idx = min(len(active) - 1, i - end_frames + 1 + postpad_frames)
start_ms_val = start_idx * hop_ms
end_ms_val = end_idx * hop_ms + hop_ms
if end_ms_val > start_ms_val:
segments.append([int(start_ms_val), int(end_ms_val)])
start_idx = None
if start_idx is not None:
start_ms_val = start_idx * hop_ms
end_idx = min(len(active) - 1, len(active) - 1 + postpad_frames)
end_ms_val = end_idx * hop_ms + hop_ms
if end_ms_val > start_ms_val:
segments.append([int(start_ms_val), int(end_ms_val)])
def _split_segment(seg: list[int]) -> list[list[int]]:
start_ms_val, end_ms_val = seg
start_frame = int(start_ms_val // hop_ms)
end_frame = int((end_ms_val - 1) // hop_ms)
end_frame = max(start_frame, min(end_frame, len(active) - 1))
best_start = None
best_len = 0
cur_start = None
cur_len = 0
for idx in range(start_frame, end_frame + 1):
if not active[idx]:
if cur_start is None:
cur_start = idx
cur_len = 1
else:
cur_len += 1
else:
if cur_start is not None and cur_len > best_len:
best_start, best_len = cur_start, cur_len
cur_start = None
cur_len = 0
if cur_start is not None and cur_len > best_len:
best_start, best_len = cur_start, cur_len
if best_start is None:
split_frame = (start_frame + end_frame) // 2
else:
split_frame = best_start + best_len // 2
split_ms = split_frame * hop_ms
if split_ms <= start_ms_val:
split_ms = start_ms_val + hop_ms
if split_ms >= end_ms_val:
split_ms = end_ms_val - hop_ms
if split_ms <= start_ms_val or split_ms >= end_ms_val:
return [seg]
return [[start_ms_val, int(split_ms)], [int(split_ms), end_ms_val]]
queue = segments[:]
segments = []
while queue:
seg = queue.pop(0)
if (seg[1] - seg[0]) <= max_len_ms:
segments.append(seg)
continue
parts = _split_segment(seg)
if len(parts) == 1:
segments.append(seg)
else:
queue = parts + queue
return segments
class VocalSegmenter(nn.Module, SupportAudioInput, SupportsComponentDiscovery):
support_audio_input: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = []
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = ["."]
_layerwise_offload_blocks_attrs: ClassVar[list[str]] = []
def __init__(self, config: VocalSegmentConfig | None = None, *, verbose: bool = False):
super().__init__()
self.config = config or VocalSegmentConfig()
self.verbose = verbose
def forward(
self,
audio: np.ndarray,
sample_rate: int,
f0: np.ndarray,
*,
base_name: str = "vocal",
origin_wav_fn: str = "",
verbose: bool | None = None,
) -> list[dict]:
verbose = self.verbose if verbose is None else verbose
if verbose:
logger.info("[vocal detection] segmenting %s", base_name)
t0 = time.time()
cfg = self.config
y = np.asarray(audio, dtype=np.float32).squeeze()
sr = int(sample_rate)
segments_ms = _voiced_to_segments(
f0 > 0,
hop_ms=cfg.hop_ms,
smooth_ms=cfg.smooth_ms,
start_ms=cfg.start_ms,
end_ms=cfg.end_ms,
prepad_ms=cfg.prepad_ms,
postpad_ms=cfg.postpad_ms,
max_len_ms=cfg.max_len_ms,
)
segments_ms = _merge_short_segments(
segments_ms,
min_len_ms=cfg.min_len_ms,
max_len_ms=cfg.max_len_ms,
short_seg_merge_gap_ms=cfg.short_seg_merge_gap_ms,
small_gap_ms=cfg.small_gap_ms,
)
adjusted_segments: list[list[int]] = []
prev_end = 0
for start_ms, end_ms in segments_ms:
start_ms = max(0, start_ms - cfg.lookback_ms)
end_ms = min(end_ms + cfg.lookahead_ms, int(y.shape[0] / sr * 1000))
if start_ms < prev_end and adjusted_segments:
adjusted_segments[-1][1] = start_ms
adjusted_segments.append([start_ms, end_ms])
prev_end = end_ms
segment_infos = []
for idx, (start_ms, end_ms) in enumerate(adjusted_segments):
if end_ms - start_ms > cfg.max_len_ms:
start_ms = end_ms - cfg.max_len_ms
key = f"{base_name}_{idx}"
start_sample = int(start_ms * sr / 1000)
end_sample = int(end_ms * sr / 1000)
segment_infos.append(
{
"item_name": key,
"wav": y[start_sample:end_sample],
"sample_rate": sr,
"wav_fn": key,
"start_time_ms": int(start_sample * 1000 / sr),
"end_time_ms": int(end_sample * 1000 / sr),
"origin_wav_fn": origin_wav_fn,
"duration": int((end_sample - start_sample) * 1000 / sr),
}
)
if verbose:
logger.info(
"[vocal detection] done n_segments=%s time=%.3fs",
len(segment_infos),
time.time() - t0,
)
return segment_infos
@@ -1,145 +0,0 @@
"""Lazy-loaded SoulX preprocess model tree."""
import os
import tempfile
from pathlib import Path
from typing import ClassVar
import numpy as np
import soundfile as sf
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery
from .asr import LyricModel
from .mel_grid_f0 import extract_f0_file
from .rmvpe import RMVPE
from .rosvot import RosvotModel
from .segmenter import VocalSegmenter
from .utils import resample_mono
from .vocal_sep import MelBandRoformerSeparator
logger = init_logger(__name__)
class SoulXPreprocessStack(nn.Module, SupportsComponentDiscovery):
"""Unified nn.Module tree for SoulX-Singer audio preprocess."""
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = ["vocal_sep", "lyric", "rosvot"]
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = ["rmvpe"]
def __init__(
self,
weights: dict[str, str],
device: str,
*,
target_sr: int = 24000,
hop_size: int = 480,
verbose: bool = False,
) -> None:
super().__init__()
self.weights = weights
self.device_str = device
self.target_sr = target_sr
self.hop_size = hop_size
self.verbose = verbose
self._rmvpe: nn.Module | None = None
self._vocal_sep: nn.Module | None = None
self._segmenter: nn.Module | None = None
self._lyric: nn.Module | None = None
self._rosvot: nn.Module | None = None
@property
def device(self) -> torch.device:
return torch.device(self.device_str)
def ensure_rmvpe(self):
if self._rmvpe is None:
if self.verbose:
logger.info("[rmvpe] loading shared pitch model")
self._rmvpe = RMVPE(self.weights["rmvpe"], device=self.device_str)
self.add_module("rmvpe", self._rmvpe)
return self._rmvpe
def ensure_vocal_sep(self):
if self._vocal_sep is None:
if self.verbose:
logger.info("[vocal extraction] loading model")
self._vocal_sep = MelBandRoformerSeparator(
self.weights["sep_config"],
self.weights["sep_ckpt"],
use_half=False,
).to(self.device)
self.add_module("vocal_sep", self._vocal_sep)
if self.verbose:
logger.info("[vocal extraction] ready on %s, sr=%s", self.device_str, self._vocal_sep.sample_rate)
return self._vocal_sep
def ensure_segmenter(self):
if self._segmenter is None:
self._segmenter = VocalSegmenter(verbose=self.verbose)
self.add_module("segmenter", self._segmenter)
return self._segmenter
def ensure_lyric(self):
if self._lyric is None:
if self.verbose:
logger.info("[lyric transcription] loading ASR")
self._lyric = LyricModel(
self.weights["asr_zh"],
self.weights["asr_en"],
device=self.device_str,
target_sr=self.target_sr,
hop_size=self.hop_size,
)
self.add_module("lyric", self._lyric)
return self._lyric
def ensure_rosvot(self):
if self._rosvot is None:
if self.verbose:
logger.info("[note transcription] loading ROSVOT")
rosvot_src = os.environ.get("ROSVOT_SOURCE_DIR")
self._rosvot = RosvotModel(
self.weights["rosvot"],
pe=self.ensure_rmvpe(),
verbose=self.verbose,
rosvot_source_dir=rosvot_src,
).to(self.device)
self.add_module("rosvot", self._rosvot)
return self._rosvot
def extract_f0(self, vocal, sample_rate: int, *, f0_path: str | None = None) -> np.ndarray:
"""Extract F0 on the mel grid; ndarray inputs are spooled to a temp wav."""
rmvpe = self.ensure_rmvpe()
kwargs = dict(
target_sr=self.target_sr,
hop_size=self.hop_size,
f0_path=f0_path,
verbose=self.verbose,
)
if isinstance(vocal, str):
return extract_f0_file(rmvpe, vocal, **kwargs)
tmp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
sf.write(tmp_path, np.asarray(vocal, dtype=np.float32).squeeze(), int(sample_rate))
return extract_f0_file(rmvpe, tmp_path, **kwargs)
finally:
if tmp_path is not None:
Path(tmp_path).unlink(missing_ok=True)
def extract_vocal(self, audio: str | tuple[np.ndarray, int]) -> tuple[np.ndarray, int]:
sep = self.ensure_vocal_sep()
if isinstance(audio, tuple):
mix, sr = audio
mix = np.asarray(mix, dtype=np.float32).squeeze()
if sr != sep.sample_rate:
mix = resample_mono(mix, orig_sr=sr, target_sr=sep.sample_rate)
return sep.separate_mono(mix, self.device), sep.sample_rate
return sep.separate_path(str(audio), self.device)
@@ -1,260 +0,0 @@
"""Shared helpers for SoulX preprocess (I/O, config, checkpoints, pitch)."""
from pathlib import Path
from typing import Any
import numpy as np
import soundfile as sf
import torch
import torch.nn as nn
import torch.nn.functional as F
from omegaconf import OmegaConf
from vllm.logger import init_logger
from vllm.multimodal.audio import AudioResampler
from vllm.multimodal.media.audio import load_audio
logger = init_logger(__name__)
# --- audio I/O ---
def resample_mono(
audio: np.ndarray,
*,
orig_sr: int,
target_sr: int,
) -> np.ndarray:
"""Resample mono float waveform to ``target_sr``."""
audio = np.asarray(audio, dtype=np.float32).squeeze()
if orig_sr == target_sr:
return audio
resampler = AudioResampler(target_sr=float(target_sr))
return np.asarray(
resampler.resample(audio, orig_sr=float(orig_sr)),
dtype=np.float32,
)
def get_audio_duration(path: str) -> float:
"""Return audio duration in seconds for a file on disk."""
return float(sf.info(path).duration)
def load_mono_audio(
source: str | tuple[np.ndarray, int],
*,
target_sr: int | None = None,
) -> tuple[np.ndarray, int]:
if isinstance(source, tuple):
audio, sr = source
audio = np.asarray(audio, dtype=np.float32).squeeze()
if target_sr is not None and sr != target_sr:
audio = resample_mono(audio, orig_sr=sr, target_sr=target_sr)
sr = target_sr
return audio, sr
if target_sr is None:
audio, sr = load_audio(str(source), sr=None, mono=True)
else:
audio, sr = load_audio(str(source), sr=target_sr, mono=True)
return np.asarray(audio, dtype=np.float32).squeeze(), int(sr)
# --- tensor padding ---
def pad_or_cut_xd(values: torch.Tensor, tgt_len: int, dim: int = -1, pad_value=0) -> torch.Tensor:
ndim = values.ndim
if ndim == 1:
if values.shape[0] < tgt_len:
return F.pad(values, [0, tgt_len - values.shape[0]], value=pad_value)
return values[:tgt_len]
if ndim == 2:
if dim in (0, -2):
src_len = values.shape[0]
if src_len < tgt_len:
return F.pad(values, [0, 0, 0, tgt_len - src_len], value=pad_value)
return values[:tgt_len]
src_len = values.shape[1]
if src_len < tgt_len:
return F.pad(values, [0, tgt_len - src_len], value=pad_value)
return values[:, :tgt_len]
raise NotImplementedError(f"pad_or_cut_xd supports 1D/2D tensors, got ndim={ndim}")
# --- YAML config (OmegaConf) ---
def _apply_hparams_str(config: dict[str, Any], hparams_str: str) -> None:
if not hparams_str:
return
for item in hparams_str.split(","):
key, raw_value = item.split("=", maxsplit=1)
raw_value = raw_value.strip().strip("'\"")
node: dict[str, Any] = config
parts = key.split(".")
for part in parts[:-1]:
node = node.setdefault(part, {})
leaf = parts[-1]
current = node.get(leaf)
if raw_value in {"True", "False"}:
node[leaf] = raw_value == "True"
elif isinstance(current, bool):
node[leaf] = raw_value.lower() == "true"
elif isinstance(current, int):
node[leaf] = int(raw_value)
elif isinstance(current, float):
node[leaf] = float(raw_value)
else:
node[leaf] = type(current)(raw_value) if current is not None else raw_value
def load_yaml_config(
config: str | Path,
*,
hparams_str: str = "",
) -> dict[str, Any]:
"""Load YAML with optional ``base_config`` chain; returns a plain dict."""
config_path = Path(config).expanduser()
if not config_path.is_file():
raise FileNotFoundError(f"Config not found: {config_path}")
merged = OmegaConf.create({})
pending = [config_path.resolve()]
loaded: set[str] = set()
while pending:
path = pending.pop(0)
path_key = str(path)
if path_key in loaded:
continue
loaded.add(path_key)
current = OmegaConf.load(path_key)
base_configs = current.get("base_config")
if base_configs is not None:
entries = base_configs if OmegaConf.is_list(base_configs) else [base_configs]
for entry in entries:
base_path = Path(entry)
if not base_path.is_absolute():
base_path = (path.parent / entry).resolve()
if not base_path.is_file():
logger.warning("Skipping missing base config: %s", base_path)
continue
pending.insert(0, base_path)
merged = OmegaConf.merge(current, merged)
result = OmegaConf.to_container(merged, resolve=True)
if not isinstance(result, dict):
raise TypeError(f"Expected dict config, got {type(result)}")
_apply_hparams_str(result, hparams_str)
return result
def load_rosvot_config(config: str | Path, *, hparams_str: str = "") -> dict[str, Any]:
return load_yaml_config(config, hparams_str=hparams_str)
# --- checkpoint loading ---
def load_model_ckpt(
model: nn.Module,
checkpoint_path: str,
*,
model_name: str = "model",
verbose: bool = True,
) -> None:
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
if not isinstance(checkpoint, dict) or "state_dict" not in checkpoint:
raise ValueError(f"Unexpected ROSVOT checkpoint format: {checkpoint_path}")
state_dict = checkpoint["state_dict"]
if state_dict and "." in next(iter(state_dict)):
prefix = f"{model_name}."
state_dict = {k[len(prefix) :]: v for k, v in state_dict.items() if k.startswith(prefix)}
elif model_name in state_dict and isinstance(state_dict[model_name], dict):
state_dict = state_dict[model_name]
model.load_state_dict(state_dict, strict=True)
if verbose:
logger.info("Loaded ROSVOT weights from %s", checkpoint_path)
# --- pitch helpers (ROSVOT / RMVPE) ---
def f0_to_coarse(f0, f0_bin=256, f0_max=900.0, f0_min=50.0):
f0_mel_min = 1127 * np.log(1 + f0_min / 700)
f0_mel_max = 1127 * np.log(1 + f0_max / 700)
is_torch = isinstance(f0, torch.Tensor)
f0_mel = 1127 * (1 + f0 / 700).log() if is_torch else 1127 * np.log(1 + f0 / 700)
f0_mel[f0_mel > 0] = (f0_mel[f0_mel > 0] - f0_mel_min) * (f0_bin - 2) / (f0_mel_max - f0_mel_min) + 1
f0_mel[f0_mel <= 1] = 1
f0_mel[f0_mel > f0_bin - 1] = f0_bin - 1
return (f0_mel + 0.5).long() if is_torch else np.rint(f0_mel).astype(int)
def norm_f0(f0, uv, pitch_norm="log", f0_mean=400, f0_std=100):
is_torch = isinstance(f0, torch.Tensor)
if pitch_norm == "standard":
f0 = (f0 - f0_mean) / f0_std
if pitch_norm == "log":
f0 = torch.log2(f0 + 1e-8) if is_torch else np.log2(f0 + 1e-8)
if uv is not None:
f0[uv > 0] = 0
return f0
def norm_interp_f0(f0, pitch_norm="log", f0_mean=None, f0_std=None):
is_torch = isinstance(f0, torch.Tensor)
if is_torch:
device = f0.device
f0 = f0.detach().cpu().numpy()
uv = f0 == 0
f0 = norm_f0(f0, uv, pitch_norm, f0_mean, f0_std)
if sum(uv) == len(f0):
f0[uv] = 0
elif sum(uv) > 0:
f0[uv] = np.interp(np.where(uv)[0], np.where(~uv)[0], f0[~uv])
if is_torch:
uv_t = torch.FloatTensor(uv)
f0_t = torch.FloatTensor(f0)
return f0_t.to(device), uv_t.to(device)
return f0, uv
def denorm_f0(f0, uv, pitch_norm="log", f0_mean=400, f0_std=100, pitch_padding=None, min=50, max=900):
is_torch = isinstance(f0, torch.Tensor)
if pitch_norm == "standard":
f0 = f0 * f0_std + f0_mean
if pitch_norm == "log":
f0 = 2**f0
f0 = f0.clamp(min=min, max=max) if is_torch else np.clip(f0, a_min=min, a_max=max)
if uv is not None:
f0[uv > 0] = 0
if pitch_padding is not None:
f0[pitch_padding] = 0
return f0
def boundary2Interval(bd):
is_torch = isinstance(bd, torch.Tensor)
if is_torch:
device = bd.device
bd = bd.detach().cpu().numpy()
ret = np.zeros(shape=(bd.sum() + 1, 2), dtype=int)
ret_idx = 0
ret[0, 0] = 0
for i, u in enumerate(bd):
if i == 0:
continue
if u == 1:
ret[ret_idx, 1] = i
ret[ret_idx + 1, 0] = i
ret_idx += 1
ret[-1, 1] = bd.shape[0] - 1
if is_torch:
return torch.LongTensor(ret).to(device)
return ret
@@ -1,207 +0,0 @@
"""BS-RoFormer vocal separation adapter (external pip package + SoulX chunking)."""
from __future__ import annotations
from pathlib import Path
from typing import ClassVar
import numpy as np
import torch
import torch.nn as nn
import yaml
from omegaconf import DictConfig, OmegaConf
from vllm.logger import init_logger
from vllm.multimodal.media.audio import load_audio
from vllm_omni.diffusion.models.interface import SupportAudioInput, SupportsComponentDiscovery
from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend
logger = init_logger(__name__)
class MelBandRoformerSeparator(nn.Module, SupportAudioInput, SupportsComponentDiscovery):
support_audio_input: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = []
_encoder_modules: ClassVar[list[str]] = ["roformer"]
_vae_modules: ClassVar[list[str]] = []
_resident_modules: ClassVar[list[str]] = []
_layerwise_offload_blocks_attrs: ClassVar[list[str]] = ["roformer.layers"]
def __init__(
self,
config_path: str | Path,
checkpoint_path: str | Path | None = None,
*,
chunk_length_sec: float = 5,
use_half: bool = True,
):
super().__init__()
try:
from bs_roformer import MelBandRoformer as ExtModel
except ImportError as e:
raise ImportError(
'Install BS-RoFormer: pip install "vllm-omni[soulx-preprocess]" or pip install BS-RoFormer'
) from e
cfg = self._load_config(Path(config_path))
model_cfg = self._normalize_cfg(OmegaConf.to_container(cfg.model, resolve=True))
self.roformer = ExtModel(**model_cfg)
self.config = cfg
self.sample_rate = int(cfg.audio.sample_rate)
LayerWiseOffloadBackend.set_blocks_attr_names(self.roformer, ["layers"])
dtype = torch.float16 if use_half else torch.float32
self.roformer = self.roformer.to(dtype=dtype)
chunk = int(chunk_length_sec * self.sample_rate)
self.config.inference = getattr(self.config, "inference", {})
inf = self.config.inference
inf.chunk_size = chunk
inf.num_overlap = inf.get("num_overlap", 4)
inf.use_amp = False
inf.normalize = inf.get("normalize", True)
fade = chunk // 10
w = torch.ones(chunk)
w[:fade] = torch.linspace(0, 1, fade)
w[-fade:] = torch.linspace(1, 0, fade)
self.register_buffer("_window", w.to(dtype=dtype), persistent=False)
if checkpoint_path:
self.load_checkpoint(str(checkpoint_path), use_half=use_half)
else:
self.roformer.eval()
@staticmethod
def _normalize_cfg(cfg: dict) -> dict:
tuple_keys = {"multi_stft_resolutions_window_sizes", "freqs_per_bands", "multi_stft_window_sizes"}
def _fix(v):
if isinstance(v, list):
return tuple(_fix(x) for x in v)
if isinstance(v, dict):
return {k: _fix(val) for k, val in v.items()}
return v
for k in tuple_keys:
if k in cfg:
cfg[k] = _fix(cfg[k])
return cfg
@staticmethod
def _load_config(path: Path) -> DictConfig:
class Loader(yaml.SafeLoader):
pass
Loader.add_constructor(
"tag:yaml.org,2002:python/tuple",
lambda loader, node: tuple(loader.construct_sequence(node)),
)
with open(path) as f:
return OmegaConf.create(yaml.load(f, Loader=Loader))
def load_checkpoint(self, path: str, *, use_half: bool = True) -> None:
self.roformer.load_state_dict(torch.load(path, map_location="cpu"), strict=False)
if use_half:
self.roformer.half()
self.roformer.eval()
@property
def chunk_size(self) -> int:
return int(self.config.inference.chunk_size)
@property
def num_overlap(self) -> int:
return int(self.config.inference.num_overlap)
@property
def use_amp(self) -> bool:
return bool(self.config.inference.use_amp)
@property
def normalize_input(self) -> bool:
return bool(self.config.inference.normalize)
@property
def num_channels(self) -> int:
return int(self.config.audio.get("num_channels", 1))
def _prepare_mix(self, mix: np.ndarray) -> tuple[np.ndarray, tuple[float, float] | None]:
if mix.ndim == 1:
mix = mix[np.newaxis, :]
if self.num_channels == 2 and mix.shape[0] == 1:
mix = np.repeat(mix, 2, axis=0)
if not self.normalize_input:
return mix, None
mono = mix.mean(0)
return (mix - mono.mean()) / mono.std(), (float(mono.mean()), float(mono.std()))
@staticmethod
def _select_vocal(recon: torch.Tensor) -> torch.Tensor:
if recon.ndim == 4:
return recon[0, 0]
if recon.ndim == 3:
return recon[0]
if recon.ndim == 2:
return recon
raise ValueError(recon.shape)
@torch.inference_mode()
def forward(self, mix: torch.Tensor) -> torch.Tensor:
if mix.ndim == 1:
mix = mix.unsqueeze(0)
if self.num_channels == 2 and mix.shape[0] == 1:
mix = mix.repeat(2, 1)
model_dtype = next(self.roformer.parameters()).dtype
compute_dtype = torch.float32 if model_dtype == torch.float16 else model_dtype
if mix.dtype != compute_dtype:
mix = mix.to(dtype=compute_dtype)
chunk_size = self.chunk_size
step = chunk_size // self.num_overlap
fade = chunk_size // 10
border = chunk_size - step
n_samples = mix.shape[-1]
window = self._window.to(mix.device)
if n_samples > 2 * border and border > 0:
mix = nn.functional.pad(mix, (border, border), mode="reflect")
out = torch.zeros_like(mix)
weight = torch.zeros(mix.shape[-1], device=mix.device, dtype=mix.dtype)
pos = 0
with torch.cuda.amp.autocast(enabled=self.use_amp):
while pos < mix.shape[-1]:
part = mix[:, pos : pos + chunk_size]
seg_len = part.shape[-1]
pad_mode = "reflect" if seg_len > chunk_size // 2 else "constant"
part = nn.functional.pad(part, (0, chunk_size - seg_len), mode=pad_mode, value=0)
stem = self._select_vocal(self.roformer(part.unsqueeze(0)))
win = window.clone()
if pos == 0:
win[:fade] = 1
elif pos + step >= n_samples:
win[-fade:] = 1
out[:, pos : pos + seg_len] += stem[:, :seg_len] * win[:seg_len]
weight[pos : pos + seg_len] += win[:seg_len]
pos += step
out = out / weight
if n_samples > 2 * border and border > 0:
out = out[..., border:-border]
return out
@torch.inference_mode()
def separate_mono(self, mix: np.ndarray, device: torch.device) -> np.ndarray:
m, norm = self._prepare_mix(mix)
target_dtype = next(self.roformer.parameters()).dtype
v = self.forward(torch.as_tensor(m, dtype=target_dtype, device=device))
if norm:
v = v * norm[1] + norm[0]
v = v.mean(0) if v.ndim == 2 else v
return v.float().cpu().numpy().astype(np.float32)
def separate_path(self, path: str, device: torch.device) -> tuple[np.ndarray, int]:
mix, _ = load_audio(path, sr=self.sample_rate, mono=False)
return self.separate_mono(np.asarray(mix, dtype=np.float32), device), self.sample_rate
@@ -1,983 +0,0 @@
import accelerate
import numpy as np
import scipy
import torch
from omegaconf import DictConfig
from torch import nn, view_as_complex, view_as_real
from torch.nn.utils import remove_weight_norm, weight_norm
from torchaudio.functional.functional import _hz_to_mel, _mel_to_hz
def _deep_update_dict(base: dict, override: dict | None) -> dict:
"""Recursively merge `override` into `base` (dict only)."""
if not override:
return base
for k, v in override.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
base[k] = _deep_update_dict(base[k], v)
else:
base[k] = v
return base
def _get_vocoder_default_cfg() -> dict:
# Defaults keep current behavior.
return {
"preprocess": {
"hop_size": 480,
"sample_rate": 24000,
"max_length": 36000,
"n_fft": 1920,
"num_mels": 128,
"win_size": 1920,
"fmin": 0,
"fmax": 12000,
"mel_var": 8.14,
"mel_mean": -4.92,
"load_phone": False,
"load_chromagram": False,
},
"model": {
"vocos": {
"input_channels": 128,
"dim": 1024,
"intermediate_dim": 4096,
"num_layers": 30,
"n_fft": 1920,
"hop_size": 480,
"padding": "same",
},
"period_gan": {
"max_downsample_channels": 1024,
"channels": 64,
"channel_increasing_factor": 2,
},
"spec_gan": {
"stft_params": {
"fft_sizes": [128, 256, 512, 1024, 2048],
"hop_sizes": [32, 64, 128, 256, 512],
"win_lengths": [128, 256, 512, 1024, 2048],
"window": "hann_window",
},
"in_channels": 1,
"out_channels": 1,
"channels": 64,
"kernel_sizes": [5, 3],
"max_downsample_channels": 1024,
"down_scales": [2, 2, 2],
"use_weight_norm": True,
"use_complex": False,
},
},
"loss": {
"mel_loss": {"sample_rate": 24000},
"disc_loss_weight": 1.0,
"mel_loss_weight": 10.0,
"adv_loss_weight": 2.0,
"fm_loss_weight": 2.0,
"spec_fm_loss_weight": 1.0,
},
}
def safe_log(x: torch.Tensor, clip_val: float = 1e-7) -> torch.Tensor:
"""
Computes the element-wise logarithm of the input tensor with clipping to avoid near-zero values.
Args:
x (Tensor): Input tensor.
clip_val (float, optional): Minimum value to clip the input tensor. Defaults to 1e-7.
Returns:
Tensor: Element-wise logarithm of the input tensor with clipping applied.
"""
return torch.log(torch.clip(x, min=clip_val))
def symlog(x: torch.Tensor) -> torch.Tensor:
return torch.sign(x) * torch.log1p(x.abs())
def symexp(x: torch.Tensor) -> torch.Tensor:
return torch.sign(x) * (torch.exp(x.abs()) - 1)
class STFT(nn.Module):
def __init__(
self,
n_fft: int,
hop_length: int,
win_length: int,
center=True,
):
super().__init__()
self.center = center
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
window = torch.hann_window(win_length)
self.register_buffer("window", window)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (B, T * hop_length)
if not self.center:
pad = self.win_length - self.hop_length
x = torch.nn.functional.pad(x, (pad // 2, pad // 2), mode="reflect")
stft_spec = torch.stft(
x,
self.n_fft,
hop_length=self.hop_length,
win_length=self.win_length,
window=self.window,
center=self.center,
return_complex=False,
) # (B, n_fft // 2 + 1, T, 2)
rea = stft_spec[:, :, :, 0] # (B, n_fft // 2 + 1, T, 2)
imag = stft_spec[:, :, :, 1] # (B, n_fft // 2 + 1, T, 2)
log_mag = torch.log(
torch.abs(torch.sqrt(torch.pow(rea, 2) + torch.pow(imag, 2))) + 1e-5
) # (B, n_fft // 2 + 1, T)
phase = torch.atan2(imag, rea) # (B, n_fft // 2 + 1, T)
return log_mag, phase
class ISTFT(nn.Module):
"""
Custom implementation of ISTFT since torch.istft doesn't allow custom padding (other than `center=True`) with
windowing. This is because the NOLA (Nonzero Overlap Add) check fails at the edges.
See issue: https://github.com/pytorch/pytorch/issues/62323
Specifically, in the context of neural vocoding we are interested in "same" padding analogous to CNNs.
The NOLA constraint is met as we trim padded samples anyway.
Args:
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames.
win_length (int): The size of window frame and STFT filter.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, n_fft: int, hop_length: int, win_length: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
window = torch.hann_window(win_length)
self.register_buffer("window", window)
def forward(self, spec: torch.Tensor) -> torch.Tensor:
"""
Compute the Inverse Short Time Fourier Transform (ISTFT) of a complex spectrogram.
Args:
spec (Tensor): Input complex spectrogram of shape (B, N, T), where B is the batch size,
N is the number of frequency bins, and T is the number of time frames.
Returns:
Tensor: Reconstructed time-domain signal of shape (B, L), where L is the length of the output signal.
"""
if self.padding == "center":
# Fallback to pytorch native implementation
return torch.istft(
spec,
self.n_fft,
self.hop_length,
self.win_length,
self.window,
center=True,
)
elif self.padding == "same":
pad = (self.win_length - self.hop_length) // 2
else:
raise ValueError("Padding must be 'center' or 'same'.")
assert spec.dim() == 3, "Expected a 3D tensor as input"
B, N, T = spec.shape
# Inverse FFT
ifft = torch.fft.irfft(spec, self.n_fft, dim=1, norm="backward")
ifft = ifft * self.window[None, :, None]
# Overlap and Add
output_size = (T - 1) * self.hop_length + self.win_length
y = torch.nn.functional.fold(
ifft,
output_size=(1, output_size),
kernel_size=(1, self.win_length),
stride=(1, self.hop_length),
)[:, 0, 0, pad:-pad]
# Window envelope
window_sq = self.window.square().expand(1, T, -1).transpose(1, 2)
window_envelope = torch.nn.functional.fold(
window_sq,
output_size=(1, output_size),
kernel_size=(1, self.win_length),
stride=(1, self.hop_length),
).squeeze()[pad:-pad]
# Normalize
assert (window_envelope > 1e-11).all()
y = y / window_envelope
return y
class MDCT(nn.Module):
"""
Modified Discrete Cosine Transform (MDCT) module.
Args:
frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, frame_len: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.frame_len = frame_len
N = frame_len // 2
n0 = (N + 1) / 2
window = torch.from_numpy(scipy.signal.cosine(frame_len)).float()
self.register_buffer("window", window)
pre_twiddle = torch.exp(-1j * torch.pi * torch.arange(frame_len) / frame_len)
post_twiddle = torch.exp(-1j * torch.pi * n0 * (torch.arange(N) + 0.5) / N)
# view_as_real: NCCL Backend does not support ComplexFloat data type
# https://github.com/pytorch/pytorch/issues/71613
self.register_buffer("pre_twiddle", view_as_real(pre_twiddle))
self.register_buffer("post_twiddle", view_as_real(post_twiddle))
def forward(self, audio: torch.Tensor) -> torch.Tensor:
"""
Apply the Modified Discrete Cosine Transform (MDCT) to the input audio.
Args:
audio (Tensor): Input audio waveform of shape (B, T), where B is the batch size
and T is the length of the audio.
Returns:
Tensor: MDCT coefficients of shape (B, L, N), where L is the number of output frames
and N is the number of frequency bins.
"""
if self.padding == "center":
audio = torch.nn.functional.pad(audio, (self.frame_len // 2, self.frame_len // 2))
elif self.padding == "same":
# hop_length is 1/2 frame_len
audio = torch.nn.functional.pad(audio, (self.frame_len // 4, self.frame_len // 4))
else:
raise ValueError("Padding must be 'center' or 'same'.")
x = audio.unfold(-1, self.frame_len, self.frame_len // 2)
N = self.frame_len // 2
x = x * self.window.expand(x.shape)
x = torch.fft.fft(x * view_as_complex(self.pre_twiddle).expand(x.shape), dim=-1)[..., :N]
res = x * view_as_complex(self.post_twiddle).expand(x.shape) * np.sqrt(1 / N)
return torch.real(res) * np.sqrt(2)
class IMDCT(nn.Module):
"""
Inverse Modified Discrete Cosine Transform (IMDCT) module.
Args:
frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, frame_len: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.frame_len = frame_len
N = frame_len // 2
n0 = (N + 1) / 2
window = torch.from_numpy(scipy.signal.cosine(frame_len)).float()
self.register_buffer("window", window)
pre_twiddle = torch.exp(1j * torch.pi * n0 * torch.arange(N * 2) / N)
post_twiddle = torch.exp(1j * torch.pi * (torch.arange(N * 2) + n0) / (N * 2))
self.register_buffer("pre_twiddle", view_as_real(pre_twiddle))
self.register_buffer("post_twiddle", view_as_real(post_twiddle))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Apply the Inverse Modified Discrete Cosine Transform (IMDCT) to the input MDCT coefficients.
Args:
x (Tensor): Input MDCT coefficients of shape (B, L, N), where B is the batch size,
L is the number of frames, and N is the number of frequency bins.
Returns:
Tensor: Reconstructed audio waveform of shape (B, T), where T is the length of the audio.
"""
B, L, N = x.shape
Y = torch.zeros((B, L, N * 2), dtype=x.dtype, device=x.device)
Y[..., :N] = x
Y[..., N:] = -1 * torch.conj(torch.flip(x, dims=(-1,)))
y = torch.fft.ifft(Y * view_as_complex(self.pre_twiddle).expand(Y.shape), dim=-1)
y = torch.real(y * view_as_complex(self.post_twiddle).expand(y.shape)) * np.sqrt(N) * np.sqrt(2)
result = y * self.window.expand(y.shape)
output_size = (1, (L + 1) * N)
audio = torch.nn.functional.fold(
result.transpose(1, 2),
output_size=output_size,
kernel_size=(1, self.frame_len),
stride=(1, self.frame_len // 2),
)[:, 0, 0, :]
if self.padding == "center":
pad = self.frame_len // 2
elif self.padding == "same":
pad = self.frame_len // 4
else:
raise ValueError("Padding must be 'center' or 'same'.")
audio = audio[:, pad:-pad]
return audio
class FourierHead(nn.Module):
"""Base class for inverse fourier modules."""
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
raise NotImplementedError("Subclasses must implement the forward method.")
class ISTFTHead(FourierHead):
"""
ISTFT Head module for predicting STFT complex coefficients.
Args:
dim (int): Hidden dimension of the model.
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames, which should align with
the resolution of the input features.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, dim: int, n_fft: int, hop_length: int, padding: str = "same"):
super().__init__()
out_dim = n_fft + 2
self.out = torch.nn.Linear(dim, out_dim)
self.istft = ISTFT(n_fft=n_fft, hop_length=hop_length, win_length=n_fft, padding=padding)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the ISTFTHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x).transpose(1, 2)
mag, p = x.chunk(2, dim=1)
mag = torch.exp(mag)
mag = torch.clip(mag, max=1e2) # safeguard to prevent excessively large magnitudes
# wrapping happens here. These two lines produce real and imaginary value
x = torch.cos(p)
y = torch.sin(p)
# recalculating phase here does not produce anything new
# only costs time
# phase = torch.atan2(y, x)
# S = mag * torch.exp(phase * 1j)
# better directly produce the complex value
# Always compute complex values in float32 to avoid ComplexHalf warning (then cast audio back if needed)
orig_dtype = mag.dtype
mag, x, y = mag.float(), x.float(), y.float()
S = mag * (x + 1j * y)
audio = self.istft(S)
if orig_dtype != torch.float32:
audio = audio.to(orig_dtype)
return audio
class IMDCTSymExpHead(FourierHead):
"""
IMDCT Head module for predicting MDCT coefficients with symmetric exponential function
Args:
dim (int): Hidden dimension of the model.
mdct_frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
sample_rate (int, optional): The sample rate of the audio. If provided, the last layer will be initialized
based on perceptual scaling. Defaults to None.
clip_audio (bool, optional): If True, clip audio to [-1.0, 1.0]. Defaults to False.
"""
def __init__(
self,
dim: int,
mdct_frame_len: int,
padding: str = "same",
sample_rate: int | None = None,
clip_audio: bool = False,
):
super().__init__()
out_dim = mdct_frame_len // 2
self.out = nn.Linear(dim, out_dim)
self.imdct = IMDCT(frame_len=mdct_frame_len, padding=padding)
self.clip_audio = clip_audio
if sample_rate is not None:
# optionally init the last layer following mel-scale
m_max = _hz_to_mel(sample_rate // 2)
m_pts = torch.linspace(0, m_max, out_dim)
f_pts = _mel_to_hz(m_pts)
scale = 1 - (f_pts / f_pts.max())
with torch.no_grad():
self.out.weight.mul_(scale.view(-1, 1))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the IMDCTSymExpHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x)
x = symexp(x)
x = torch.clip(x, min=-1e2, max=1e2) # safeguard to prevent excessively large magnitudes
audio = self.imdct(x)
if self.clip_audio:
audio = torch.clip(x, min=-1.0, max=1.0)
return audio
class IMDCTCosHead(FourierHead):
"""
IMDCT Head module for predicting MDCT coefficients with parametrizing MDCT = exp(m) · cos(p)
Args:
dim (int): Hidden dimension of the model.
mdct_frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
clip_audio (bool, optional): If True, clip audio to [-1.0, 1.0]. Defaults to False.
"""
def __init__(
self,
dim: int,
mdct_frame_len: int,
padding: str = "same",
clip_audio: bool = False,
):
super().__init__()
self.clip_audio = clip_audio
self.out = nn.Linear(dim, mdct_frame_len)
self.imdct = IMDCT(frame_len=mdct_frame_len, padding=padding)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the IMDCTCosHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x)
m, p = x.chunk(2, dim=2)
m = torch.exp(m).clip(max=1e2) # safeguard to prevent excessively large magnitudes
audio = self.imdct(m * torch.cos(p))
if self.clip_audio:
audio = torch.clip(x, min=-1.0, max=1.0)
return audio
class ConvNeXtBlock(nn.Module):
"""ConvNeXt Block adapted from https://github.com/facebookresearch/ConvNeXt to 1D audio signal.
Args:
dim (int): Number of input channels.
intermediate_dim (int): Dimensionality of the intermediate layer.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
adanorm_num_embeddings (int, optional): Number of embeddings for AdaLayerNorm.
None means non-conditional LayerNorm. Defaults to None.
"""
def __init__(
self,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float,
adanorm_num_embeddings: int | None = None,
):
super().__init__()
self.dwconv = nn.Conv1d(dim, dim, kernel_size=7, padding=3, groups=dim) # depthwise conv
self.adanorm = adanorm_num_embeddings is not None
if adanorm_num_embeddings:
self.norm = AdaLayerNorm(adanorm_num_embeddings, dim, eps=1e-6)
else:
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.pwconv1 = nn.Linear(dim, intermediate_dim) # pointwise/1x1 convs, implemented with linear layers
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
self.gamma = (
nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True)
if layer_scale_init_value > 0
else None
)
def forward(self, x: torch.Tensor, cond_embedding_id: torch.Tensor | None = None) -> torch.Tensor:
residual = x
x = self.dwconv(x)
x = x.transpose(1, 2) # (B, C, T) -> (B, T, C)
if self.adanorm:
assert cond_embedding_id is not None
x = self.norm(x, cond_embedding_id)
else:
x = self.norm(x)
x = self.pwconv1(x)
x = self.act(x)
x = self.pwconv2(x)
if self.gamma is not None:
x = self.gamma * x
x = x.transpose(1, 2) # (B, T, C) -> (B, C, T)
x = residual + x
return x
class AdaLayerNorm(nn.Module):
"""
Adaptive Layer Normalization module with learnable embeddings per `num_embeddings` classes
Args:
num_embeddings (int): Number of embeddings.
embedding_dim (int): Dimension of the embeddings.
"""
def __init__(self, num_embeddings: int, embedding_dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.dim = embedding_dim
self.scale = nn.Embedding(num_embeddings=num_embeddings, embedding_dim=embedding_dim)
self.shift = nn.Embedding(num_embeddings=num_embeddings, embedding_dim=embedding_dim)
torch.nn.init.ones_(self.scale.weight)
torch.nn.init.zeros_(self.shift.weight)
def forward(self, x: torch.Tensor, cond_embedding_id: torch.Tensor) -> torch.Tensor:
scale = self.scale(cond_embedding_id)
shift = self.shift(cond_embedding_id)
x = nn.functional.layer_norm(x, (self.dim,), eps=self.eps)
x = x * scale + shift
return x
class ResBlock1(nn.Module):
"""
ResBlock adapted from HiFi-GAN V1 (https://github.com/jik876/hifi-gan) with dilated 1D convolutions,
but without upsampling layers.
Args:
dim (int): Number of input channels.
kernel_size (int, optional): Size of the convolutional kernel. Defaults to 3.
dilation (tuple[int], optional): Dilation factors for the dilated convolutions.
Defaults to (1, 3, 5).
lrelu_slope (float, optional): Negative slope of the LeakyReLU activation function.
Defaults to 0.1.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
"""
def __init__(
self,
dim: int,
kernel_size: int = 3,
dilation: tuple[int, int, int] = (1, 3, 5),
lrelu_slope: float = 0.1,
layer_scale_init_value: float | None = None,
):
super().__init__()
self.lrelu_slope = lrelu_slope
self.convs1 = nn.ModuleList(
[
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[0],
padding=self.get_padding(kernel_size, dilation[0]),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[1],
padding=self.get_padding(kernel_size, dilation[1]),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[2],
padding=self.get_padding(kernel_size, dilation[2]),
)
),
]
)
self.convs2 = nn.ModuleList(
[
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
]
)
self.gamma = nn.ParameterList(
[
(
nn.Parameter(layer_scale_init_value * torch.ones(dim, 1), requires_grad=True)
if layer_scale_init_value is not None
else None
),
(
nn.Parameter(layer_scale_init_value * torch.ones(dim, 1), requires_grad=True)
if layer_scale_init_value is not None
else None
),
(
nn.Parameter(layer_scale_init_value * torch.ones(dim, 1), requires_grad=True)
if layer_scale_init_value is not None
else None
),
]
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
for c1, c2, gamma in zip(self.convs1, self.convs2, self.gamma):
xt = torch.nn.functional.leaky_relu(x, negative_slope=self.lrelu_slope)
xt = c1(xt)
xt = torch.nn.functional.leaky_relu(xt, negative_slope=self.lrelu_slope)
xt = c2(xt)
if gamma is not None:
xt = gamma * xt
x = xt + x
return x
def remove_weight_norm(self):
for conv in self.convs1:
remove_weight_norm(conv)
for conv in self.convs2:
remove_weight_norm(conv)
@staticmethod
def get_padding(kernel_size: int, dilation: int = 1) -> int:
return int((kernel_size * dilation - dilation) / 2)
class Backbone(nn.Module):
"""Base class for the generator's backbone. It preserves the same temporal resolution across all layers."""
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
"""
Args:
x (Tensor): Input tensor of shape (B, C, L), where B is the batch size,
C denotes output features, and L is the sequence length.
Returns:
Tensor: Output of shape (B, L, H), where B is the batch size, L is the sequence length,
and H denotes the model dimension.
"""
raise NotImplementedError("Subclasses must implement the forward method.")
class VocosBackbone(Backbone):
"""
Vocos backbone module built with ConvNeXt blocks. Supports additional conditioning with Adaptive Layer Normalization
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
intermediate_dim (int): Intermediate dimension used in ConvNeXtBlock.
num_layers (int): Number of ConvNeXtBlock layers.
layer_scale_init_value (float, optional): Initial value for layer scaling. Defaults to `1 / num_layers`.
adanorm_num_embeddings (int, optional): Number of embeddings for AdaLayerNorm.
None means non-conditional model. Defaults to None.
"""
def __init__(
self,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
layer_scale_init_value: float | None = None,
adanorm_num_embeddings: int | None = None,
):
super().__init__()
self.input_channels = input_channels
self.embed = nn.Conv1d(input_channels, dim, kernel_size=7, padding=3)
self.adanorm = adanorm_num_embeddings is not None
if adanorm_num_embeddings:
self.norm = AdaLayerNorm(adanorm_num_embeddings, dim, eps=1e-6)
else:
self.norm = nn.LayerNorm(dim, eps=1e-6)
layer_scale_init_value = layer_scale_init_value or 1 / num_layers
self.convnext = nn.ModuleList(
[
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
layer_scale_init_value=layer_scale_init_value,
adanorm_num_embeddings=adanorm_num_embeddings,
)
for _ in range(num_layers)
]
)
self.final_layer_norm = nn.LayerNorm(dim, eps=1e-6)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, nn.Conv1d | nn.Linear):
nn.init.trunc_normal_(m.weight, std=0.02)
nn.init.constant_(m.bias, 0)
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
bandwidth_id = kwargs.get("bandwidth_id", None)
x = self.embed(x)
if self.adanorm:
assert bandwidth_id is not None
x = self.norm(x.transpose(1, 2), cond_embedding_id=bandwidth_id)
else:
x = self.norm(x.transpose(1, 2))
x = x.transpose(1, 2)
for conv_block in self.convnext:
x = conv_block(x, cond_embedding_id=bandwidth_id)
x = self.final_layer_norm(x.transpose(1, 2))
return x
class VocosResNetBackbone(Backbone):
"""
Vocos backbone module built with ResBlocks.
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
num_blocks (int): Number of ResBlock1 blocks.
layer_scale_init_value (float, optional): Initial value for layer scaling. Defaults to None.
"""
def __init__(
self,
input_channels,
dim,
num_blocks,
layer_scale_init_value=None,
):
super().__init__()
self.input_channels = input_channels
self.embed = weight_norm(nn.Conv1d(input_channels, dim, kernel_size=3, padding=1))
layer_scale_init_value = layer_scale_init_value or 1 / num_blocks / 3
self.resnet = nn.Sequential(
*[ResBlock1(dim=dim, layer_scale_init_value=layer_scale_init_value) for _ in range(num_blocks)]
)
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
x = self.embed(x)
x = self.resnet(x)
x = x.transpose(1, 2)
return x
class Vocos(nn.Module):
def __init__(
self,
input_channels: int = 256,
dim: int = 384,
intermediate_dim: int = 1152,
num_layers: int = 8,
n_fft: int = 800,
hop_size: int = 200,
padding: str = "same",
adanorm_num_embeddings=None,
cfg=None,
):
super().__init__()
input_channels = cfg.input_channels if cfg is not None and hasattr(cfg, "input_channels") else input_channels
dim = cfg.dim if cfg is not None and hasattr(cfg, "dim") else dim
intermediate_dim = (
cfg.intermediate_dim if cfg is not None and hasattr(cfg, "intermediate_dim") else intermediate_dim
)
num_layers = cfg.num_layers if cfg is not None and hasattr(cfg, "num_layers") else num_layers
adanorm_num_embeddings = (
cfg.adanorm_num_embeddings
if cfg is not None and hasattr(cfg, "adanorm_num_embeddings")
else adanorm_num_embeddings
)
n_fft = cfg.n_fft if cfg is not None and hasattr(cfg, "n_fft") else n_fft
hop_size = cfg.hop_size if cfg is not None and hasattr(cfg, "hop_size") else hop_size
padding = cfg.padding if cfg is not None and hasattr(cfg, "padding") else padding
self.backbone = VocosBackbone(
input_channels=input_channels,
dim=dim,
intermediate_dim=intermediate_dim,
num_layers=num_layers,
adanorm_num_embeddings=adanorm_num_embeddings,
)
self.head = ISTFTHead(dim, n_fft, hop_size, padding)
def forward(self, x):
x = self.backbone(x)
x = self.head(x)
return x[:, None, :]
class JsonHParams:
def __init__(self, **kwargs):
for k, v in kwargs.items():
if isinstance(v, dict):
v = JsonHParams(**v)
self[k] = v
def keys(self):
return self.__dict__.keys()
def items(self):
return self.__dict__.items()
def values(self):
return self.__dict__.values()
def __len__(self):
return len(self.__dict__)
def __getitem__(self, key):
return getattr(self, key)
def __setitem__(self, key, value):
return setattr(self, key, value)
def __contains__(self, key):
return key in self.__dict__
def __repr__(self):
return self.__dict__.__repr__()
def build_vocoder_model(cfg):
vocoder_model = Vocos(cfg=cfg.model.vocos)
vocoder_model.eval()
return vocoder_model
def load_checkpoint(build_model_func, cfg, ckpt_path):
model = build_model_func(cfg)
if ckpt_path is not None:
accelerate.load_checkpoint_and_dispatch(model, ckpt_path)
return model
def load_vocos_model(
ckpt_path: str | None = None,
config: DictConfig = None,
device: str = "cuda",
):
"""Load Vocos vocoder.
Args:
config: DictConfig, config for vocoder.
ckpt_path: str | None = None, path to checkpoint.
device: str, device to load model. Note: accelerate dispatch handles device placement.
"""
merged_cfg = _deep_update_dict(_get_vocoder_default_cfg(), config)
vocoder_cfg_obj = JsonHParams(**merged_cfg)
vocoder_model = load_checkpoint(build_vocoder_model, vocoder_cfg_obj, ckpt_path)
vocoder_model.eval()
for param in vocoder_model.parameters():
param.requires_grad = False
return vocoder_model
class Vocoder(nn.Module):
def __init__(self, vocoder: dict | None = None, ckpt_path: str | None = None):
super().__init__()
vocoder = vocoder or {}
model_cfg = vocoder.get("model_cfg") or vocoder.get("cfg")
self.model = load_vocos_model(
ckpt_path=ckpt_path,
config=model_cfg,
)
def forward(self, x):
x = self.model(x)
return x
@@ -1,96 +0,0 @@
"""Frozen Whisper encoder wrapper (wav -> encoder embeddings)."""
import torch
import torchaudio
from transformers import WhisperFeatureExtractor, WhisperModel
WHISPER_MEL_FRAMES = 3000 # 3000 frames at 16000 Hz
class WhisperEncoder:
"""Auxiliary content encoder for SVC.
Kept in FP32 for stable HF Whisper inference under vLLM-Omni's default
BF16 diffusion trunk (same idea as Stable Audio's FP32 VAE). Outputs are
cast to the trunk dtype before fusion with the CFM conditioner.
"""
def __init__(self, device: str | torch.device | None = None) -> None:
self.fe = WhisperFeatureExtractor.from_pretrained("openai/whisper-base")
self.model = WhisperModel.from_pretrained("openai/whisper-base")
self.device = torch.device(
device or ("cuda" if torch.cuda.is_available() else "cpu"),
)
self.model = self.model.to(device=self.device, dtype=torch.float32)
self.model.eval()
for param in self.model.parameters():
param.requires_grad_(False)
@property
def dtype(self) -> torch.dtype:
return torch.float32
def float(self) -> "WhisperEncoder":
self.model = self.model.float()
return self
def encode(
self,
wav: torch.Tensor,
sr: int,
*,
output_dtype: torch.dtype | None = None,
) -> torch.Tensor:
wav = (
torchaudio.functional.resample(wav, orig_freq=sr, new_freq=self.fe.sampling_rate)
if sr != self.fe.sampling_rate
else wav
)
wav_np = wav.detach().float().cpu().numpy().astype("float32", copy=False)
inputs = self.fe(
wav_np,
sampling_rate=self.fe.sampling_rate,
return_tensors="pt",
padding=False,
truncation=False,
return_attention_mask=True,
)
input_features = inputs.input_features
num_frames = input_features.shape[-1]
if num_frames < WHISPER_MEL_FRAMES:
pad = WHISPER_MEL_FRAMES - num_frames
input_features = torch.nn.functional.pad(input_features, (0, pad))
else:
input_features = input_features[..., :WHISPER_MEL_FRAMES]
input_features = input_features.to(device=wav.device, dtype=self.dtype)
if self.model.device != wav.device:
self.model = self.model.to(wav.device)
attention_mask = inputs.attention_mask.to(wav.device) if inputs.attention_mask is not None else None
with torch.inference_mode():
encoder_out = self.model.encoder(input_features).last_hidden_state
if attention_mask is not None:
valid_mel_frames = attention_mask.sum(dim=1)
valid_enc_frames = (valid_mel_frames + 1) // 2
max_valid_enc_frames = min(int(valid_enc_frames.max().item()), encoder_out.shape[1])
encoder_out = encoder_out[:, :max_valid_enc_frames, :]
valid_len = min(int(valid_enc_frames[0].item()), max_valid_enc_frames)
if valid_len < max_valid_enc_frames:
encoder_out[0, valid_len:, :] = 0
if output_dtype is not None and encoder_out.dtype != output_dtype:
encoder_out = encoder_out.to(dtype=output_dtype)
return encoder_out
if __name__ == "__main__":
torch.manual_seed(0)
audio = torch.randn(1, 24000 * 25).float().to("cuda")
encoder = WhisperEncoder()
whisper_encoder_out = encoder.encode(audio, sr=24000)
print(whisper_encoder_out.shape)
@@ -1,377 +0,0 @@
import os
import time
from collections.abc import Callable
from contextlib import contextmanager
from typing import Any, ClassVar
import numpy as np
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm.utils.torch_utils import set_default_torch_dtype
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin, _unwrap, _wrap
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.interface import (
SupportAudioInput,
SupportAudioOutput,
SupportsComponentDiscovery,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import consume_payload
from vllm_omni.diffusion.models.soulx_singer.utils import _patch_torchaudio_load, load_config
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__)
_SOULX_PROFILER_TARGETS = [
"forward",
]
def convert_soulx_audio_output_to_numpy(audio: torch.Tensor | dict[str, Any]) -> np.ndarray | dict[str, Any]:
if isinstance(audio, dict) and isinstance(audio.get("payload"), dict):
payload = dict(audio["payload"])
audio_payload = payload.get("audio")
if isinstance(audio_payload, torch.Tensor):
payload["audio"] = audio_payload.detach().cpu().float().numpy()
return {
"payload": payload,
"metadata": audio.get("metadata") or {},
}
if isinstance(audio, torch.Tensor):
return audio.detach().cpu().float().numpy()
return audio
class FlowMatchingAudioPipeline(
nn.Module,
CFGParallelMixin,
SupportAudioInput,
SupportAudioOutput,
SupportsComponentDiscovery,
DiffusionPipelineProfilerMixin,
):
"""SoulX flow-matching pipeline with CFG / CFG-parallel support."""
support_audio_input: ClassVar[bool] = True
support_audio_output: ClassVar[bool] = True
audio_sample_rate: ClassVar[int] = 24000
_DEFAULT_RESCALE_CFG: ClassVar[float] = 0.75
_dit_modules: ClassVar[list[str]] = ["cfm_decoder.model.diff_estimator"]
_encoder_modules: ClassVar[list[str]] = ["mel", "f0_encoder"]
_vae_modules: ClassVar[list[str]] = ["vocoder"]
_resident_modules: ClassVar[list[str]] = ["cfm_decoder.model.cond_emb"]
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__()
self.od_config = od_config
self.device = get_local_device()
# 1. parse model path and yaml config
model_dir = od_config.model
assert model_dir is not None, "model_dir is not provided"
if not os.path.exists(model_dir):
raise FileNotFoundError(
f"""Model directory {model_dir} does not exist. User should refer to
`examples/offline_inference/text_to_speech/README.md` for usage details."""
)
# Empty: skip DiffusersPipelineLoader.get_all_weights() → no _prepare_weights / file glob /
# safetensors or pt iterator from the framework. Load checkpoints in __init__ instead.
self.weights_sources = ()
self.model_path = model_dir
config_path = os.path.join(model_dir, "config.yaml")
hf_config = load_config(config_path)
self.audio_config = hf_config.audio
self.encoder_config = hf_config.model.encoder
self.flow_matching_config = hf_config.model.flow_matching
self.mel_dim = self.audio_config.num_mels
self.f0_bin = self.encoder_config.f0_bin
self.f0_dim = self.encoder_config.f0_dim
self.text_dim = self.encoder_config.text_dim
self.vocab_size = self.encoder_config.vocab_size
self.pitch_dim = self.encoder_config.pitch_dim
self.type_dim = self.encoder_config.type_dim
_patch_torchaudio_load()
def _mel_to_audio(self, generated_mel: torch.Tensor, *, squeeze: bool = False) -> torch.Tensor:
audio = self.vocoder(generated_mel.transpose(1, 2)[0:1, ...].float()).float()
return audio.squeeze() if squeeze else audio
def _mel_from_wav(self, wav: torch.Tensor) -> torch.Tensor:
if wav.dtype != torch.float32:
wav = wav.float()
return self.mel(wav)
def _load_soulx_checkpoint(self, checkpoint_name: str) -> None:
weight_path = os.path.join(self.model_path, checkpoint_name)
if not os.path.isfile(weight_path):
raise FileNotFoundError(
f"Model weights not found at {weight_path}. Please check the pretrained model path."
)
state = torch.load(weight_path, map_location=self.device)
self.mel.float()
self.load_state_dict(state["state_dict"], strict=True)
self._finalize_loaded_dtypes()
logger.info("Loaded model weights from %s", weight_path)
def _setup_soulx_profiler(self, *, extra_targets: list[str] | None = None) -> None:
"""Enable stage timing when ``od_config.enable_diffusion_pipeline_profiler`` is set."""
targets = list(_SOULX_PROFILER_TARGETS)
if extra_targets:
targets.extend(extra_targets)
self.setup_diffusion_pipeline_profiler(
profiler_targets=targets,
enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler,
)
@contextmanager
def _stage_timer(self, name: str):
if not getattr(self, "enable_diffusion_pipeline_profiler", False):
yield
return
if current_omni_platform.is_available():
current_omni_platform.synchronize()
start = time.perf_counter()
try:
yield
finally:
if current_omni_platform.is_available():
current_omni_platform.synchronize()
duration = time.perf_counter() - start
with self._profiler_lock:
self._stage_durations[name] = self._stage_durations.get(name, 0.0) + duration
def _profiler_stage_durations(self) -> dict[str, float] | None:
if getattr(self, "enable_diffusion_pipeline_profiler", False):
return self.stage_durations
return None
@staticmethod
def _build_fp32_audio_modules(audio_config) -> tuple[nn.Module, nn.Module]:
"""Mel/vocoder must stay FP32 even when ``od_config.dtype`` is FP16/BF16."""
from vllm_omni.diffusion.models.soulx_singer.modules import MelSpectrogramEncoder, Vocoder
with set_default_torch_dtype(torch.float32):
mel = MelSpectrogramEncoder(audio_config)
vocoder = Vocoder()
return mel, vocoder
def _finalize_loaded_dtypes(self) -> None:
"""Keep mel/vocoder in FP32; cast DiT trunk to ``od_config.dtype`` after load."""
self.mel.float()
self.vocoder.float()
trunk_dtype = self.od_config.dtype
if trunk_dtype not in (torch.float16, torch.bfloat16):
return
trunk_modules = (
"cfm_decoder",
"f0_encoder",
"preflow",
"note_text_encoder",
"note_pitch_encoder",
"note_type_encoder",
)
for name in trunk_modules:
module = getattr(self, name, None)
if module is not None:
module.to(dtype=trunk_dtype)
whisper = getattr(self, "whisper_encoder", None)
if whisper is not None:
whisper.float()
@property
def transformer(self) -> nn.Module:
"""Alias expected by ``CFGParallelMixin`` default hooks."""
return self.cfm_decoder.model.diff_estimator
@property
def diffusion_trunk_dtype(self) -> torch.dtype:
"""DiT trunk dtype from ``diff_estimator`` (not ``cond_emb``, which is registered first)."""
return next(self.cfm_decoder.model.diff_estimator.parameters()).dtype
def _to_trunk_dtype(self, *tensors: torch.Tensor) -> tuple[torch.Tensor, ...]:
"""Cast tensors to ``diffusion_trunk_dtype`` at the CFM/DiT boundary."""
trunk_dtype = self.diffusion_trunk_dtype
return tuple(t if t.dtype == trunk_dtype else t.to(dtype=trunk_dtype) for t in tensors)
def _resolve_diffusion_generator(self, sampling_params: Any) -> torch.Generator | None:
"""Build a device-local RNG for CFM noise from ``generator`` or ``seed``."""
generator = getattr(sampling_params, "generator", None)
if generator is not None:
return generator
seed = getattr(sampling_params, "seed", None)
if seed is None:
return None
return torch.Generator(device=self.device).manual_seed(int(seed))
def _prepare_cfm_loop_state(
self,
prompt: torch.Tensor,
cond: torch.Tensor,
n_timesteps: int,
*,
generator: torch.Generator | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, int]:
"""Cast FP32 islands to trunk dtype and build CFM loop tensors/masks."""
trunk_dtype = self.diffusion_trunk_dtype
prompt, cond = self._to_trunk_dtype(prompt, cond)
cond_emb = self.cfm_decoder.model.cond_emb(cond)
if cond_emb.dtype != trunk_dtype:
cond_emb = self._to_trunk_dtype(cond_emb)[0]
device = cond_emb.device
h = 1.0 / n_timesteps
h_tensor = torch.tensor(h, device=device, dtype=trunk_dtype)
prompt_len = prompt.shape[1]
target_len = cond_emb.shape[1] - prompt_len
x_mask = torch.ones(cond_emb.shape[0], target_len, device=device, dtype=trunk_dtype)
prompt_mask = torch.ones(cond_emb.shape[0], prompt_len, device=device, dtype=trunk_dtype)
xt_mask = torch.cat([prompt_mask, x_mask], dim=1)
xt = torch.randn(
cond_emb.shape[0],
target_len,
self.mel_dim,
dtype=trunk_dtype,
device=device,
generator=generator,
)
return cond_emb, prompt, xt, x_mask, xt_mask, h_tensor, prompt_len
def predict_noise(
self,
*,
x: torch.Tensor,
diffusion_step: torch.Tensor,
cond: torch.Tensor,
x_mask: torch.Tensor,
prompt_len: int | None = None,
**_: Any,
) -> torch.Tensor:
"""Forward through ``diff_estimator``, optionally slice off prompt frames."""
x, diffusion_step, cond, x_mask = self._to_trunk_dtype(x, diffusion_step, cond, x_mask)
flow_pred = self.cfm_decoder.model.diff_estimator(x, diffusion_step, cond, x_mask)
if prompt_len is not None:
flow_pred = flow_pred[:, prompt_len:, :]
return flow_pred
def combine_cfg_noise(
self,
positive_noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
negative_noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
true_cfg_scale: float,
cfg_normalize: bool = False,
rescale_cfg: float | None = None,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""SoulX CFG: pos + scale * (pos - neg) with std rescaling."""
del cfg_normalize # SoulX uses rescale_cfg instead of cfg_normalize_function.
if rescale_cfg is None:
rescale_cfg = getattr(self, "rescale_cfg", self._DEFAULT_RESCALE_CFG)
pos = _unwrap(_wrap(positive_noise_pred))
neg = _unwrap(_wrap(negative_noise_pred))
pos_flow_pred_std = pos.std()
flow_pred_cfg = pos + true_cfg_scale * (pos - neg)
rescale_flow_pred = flow_pred_cfg * pos_flow_pred_std / flow_pred_cfg.std()
combined = rescale_cfg * rescale_flow_pred + (1 - rescale_cfg) * flow_pred_cfg
return combined.to(dtype=pos.dtype)
def _run_flow_matching_loop(
self,
prompt: torch.Tensor,
cond: torch.Tensor,
n_timesteps: int,
cfg: float,
*,
generator: torch.Generator | None = None,
) -> torch.Tensor:
"""Euler flow-matching loop aligned with ``FlowMatchingTransformer.reverse_diffusion``."""
cond_emb, prompt, xt, x_mask, xt_mask, h_tensor, prompt_len = self._prepare_cfm_loop_state(
prompt, cond, n_timesteps, generator=generator
)
trunk_dtype = self.diffusion_trunk_dtype
step_scale = 1.0 / n_timesteps
for i in range(n_timesteps):
xt_input = torch.cat([prompt, xt], dim=1)
diffusion_step = torch.full(
(xt.shape[0],),
fill_value=(i + 0.5) * step_scale,
device=xt.device,
dtype=trunk_dtype,
)
flow_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=(cfg > 0),
true_cfg_scale=cfg,
positive_kwargs={
"x": xt_input,
"diffusion_step": diffusion_step,
"cond": cond_emb,
"x_mask": xt_mask,
"prompt_len": prompt_len,
},
negative_kwargs={
"x": xt,
"diffusion_step": diffusion_step,
# CFG null cond: zero out target frames only (prompt cond stays in pos branch).
"cond": torch.zeros_like(cond_emb)[:, : xt.shape[1], :],
"x_mask": x_mask,
},
cfg_normalize=False,
)
xt = xt + flow_pred * h_tensor
return xt
def _forward_batch_from_request(
self,
req: OmniDiffusionRequest,
*,
kind: str,
metadata_key: str,
infer_batch_fn: Callable[..., tuple[torch.Tensor, int]],
prepare_extra_args: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None,
) -> DiffusionOutput:
sampling_params = req.sampling_params
extra_args = dict(getattr(sampling_params, "extra_args", None) or {})
if prepare_extra_args is not None:
extra_args = prepare_extra_args(extra_args, sampling_params)
sampling_params.extra_args = extra_args
num_inference_steps = sampling_params.num_inference_steps or 32
guidance_scale = sampling_params.guidance_scale if sampling_params.guidance_scale_provided else 3.0
generator = self._resolve_diffusion_generator(sampling_params)
with self._stage_timer("consume_payload"):
payload = consume_payload(req, kind, self.device)
with self._stage_timer("infer_segments"):
audio, pitch_shift = infer_batch_fn(
payload,
extra_args=extra_args,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
)
return DiffusionOutput(
output={
"payload": {"audio": audio},
"metadata": {"audio": {metadata_key: pitch_shift}},
},
stage_durations=self._profiler_stage_durations() or {},
)
@@ -1,411 +0,0 @@
"""SoulX-Singer SVC (voice conversion) pipeline implementation."""
import os
from collections.abc import Iterable
from typing import Any, ClassVar
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm.logger import init_logger
from vllm.utils.torch_utils import set_default_torch_dtype
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.soulx_singer.modules import (
CFMDecoder,
WhisperEncoder,
)
from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_base import (
FlowMatchingAudioPipeline,
convert_soulx_audio_output_to_numpy,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import (
SOULX_PREPROCESSED_KEY,
get_soulx_preprocessed_payload,
has_precomputed,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.pre_process import (
attach_preprocess_for_diffusion_request,
is_warmup_request,
resolve_preprocess_audio,
)
from vllm_omni.diffusion.models.soulx_singer.utils import (
f0_to_coarse,
load_config,
resolve_pitch_shift,
validate_soulx_extra_args,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
logger = init_logger(__name__)
def get_soulxsinger_svc_pre_process_func(od_config: OmniDiffusionConfig):
hf_config = load_config(os.path.join(od_config.model, "config.yaml"))
sample_rate = hf_config.audio.sample_rate
device = get_local_device()
_pipeline = None
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
nonlocal _pipeline
extra_args = validate_soulx_extra_args(
"svc",
dict(getattr(request.sampling_params, "extra_args", None) or {}),
)
# Inline build: when no warmup/no precomputed paths/no IPC payload,
# build the preprocess payload directly from audio file paths.
if not (is_warmup_request(request) or has_precomputed(extra_args, "svc")):
prompt = request.prompt
if not isinstance(prompt, str) and not get_soulx_preprocessed_payload(prompt): # type: ignore[arg-type]
prompt_audio, target_audio = resolve_preprocess_audio(prompt, extra_args) # type: ignore[arg-type]
if prompt_audio is not None and target_audio is not None:
if _pipeline is None:
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.pipeline import (
SoulXPreprocessPipeline,
)
_pipeline = SoulXPreprocessPipeline(
od_config=od_config,
vocal_sep=bool(extra_args.get("vocal_sep", False)),
verbose=bool(extra_args.get("preprocess_verbose", False)),
extra_args=extra_args,
)
payload = _pipeline.build_svc_payload_from_audio(
prompt_audio=prompt_audio,
target_audio=target_audio,
sample_rate=sample_rate,
device=device,
vocal_sep=extra_args.get("vocal_sep"),
)
payload.setdefault("kind", "svc")
prompt.setdefault("additional_information", {})[SOULX_PREPROCESSED_KEY] = payload # type: ignore[union-attr, assignment]
return attach_preprocess_for_diffusion_request(
request,
kind="svc",
sample_rate=sample_rate,
device=device,
)
return pre_process_func
def get_soulxsinger_post_process_func(od_config: OmniDiffusionConfig):
def post_process_func(audio: torch.Tensor):
return convert_soulx_audio_output_to_numpy(audio)
return post_process_func
class PipelineSoulXSingerSVC(FlowMatchingAudioPipeline):
"""SVC pipeline for the SoulX-Singer model."""
_encoder_modules: ClassVar[list[str]] = [
"whisper_encoder",
"mel",
"f0_encoder",
]
EXTRA_BODY_PARAMS: ClassVar[frozenset[str]] = frozenset(
{
"prompt_wav_path",
"target_wav_path",
"prompt_f0_path",
"target_f0_path",
"prompt_audio",
"target_audio",
"vocal_sep",
"preprocess_weights_dir",
"preprocess_verbose",
"auto_shift",
"pitch_shift",
}
)
EXTRA_OUTPUT_PARAMS: ClassVar[frozenset[str]] = frozenset({"pitch_shift"})
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
self.f0_encoder = nn.Embedding(self.f0_bin, self.f0_dim)
self.cfm_decoder = CFMDecoder(self.flow_matching_config)
self.mel, self.vocoder = self._build_fp32_audio_modules(self.audio_config)
with set_default_torch_dtype(torch.float32):
self.whisper_encoder = WhisperEncoder(device=self.device)
self._setup_soulx_profiler()
def _encode_condition(self, *, whisper_features: torch.Tensor, f0_coarse: torch.Tensor) -> torch.Tensor:
cond = whisper_features + self.f0_encoder(f0_coarse)
return self._to_trunk_dtype(cond)[0]
def _encode_prompt_whisper_feature(self, prompt_wav: torch.Tensor) -> torch.Tensor:
trunk_dtype = self.f0_encoder.weight.dtype
with self._stage_timer("whisper"):
return self.whisper_encoder.encode(
prompt_wav,
sr=self.audio_config.sample_rate,
output_dtype=trunk_dtype,
)
_encode_target_whisper_feature = _encode_prompt_whisper_feature
def _infer_segment(
self,
*,
prompt_mel: torch.Tensor,
prompt_wav: torch.Tensor,
target_wav: torch.Tensor,
prompt_f0: torch.Tensor,
target_f0: torch.Tensor,
pitch_shift: int,
num_inference_steps: int,
guidance_scale: float,
prompt_feature: torch.Tensor | None = None,
generator: torch.Generator | None = None,
) -> torch.Tensor:
"""Single-segment SVC inference aligned with ``SoulXSingerSVC.infer_segment``."""
len_prompt_mel = prompt_mel.shape[1]
prompt_f0 = F.pad(prompt_f0, (0, 0, 0, max(0, len_prompt_mel - prompt_f0.shape[1])))[:, :len_prompt_mel]
f0_coarse_prompt = f0_to_coarse(prompt_f0)
f0_coarse_target = f0_to_coarse(target_f0, f0_shift=int(pitch_shift * 5))
f0_coarse = torch.cat([f0_coarse_prompt, f0_coarse_target], dim=1)
trunk_dtype = self.f0_encoder.weight.dtype
if prompt_feature is None:
prompt_feature = self._encode_prompt_whisper_feature(prompt_wav)
with self._stage_timer("whisper"):
target_feature = self.whisper_encoder.encode(
target_wav,
sr=self.audio_config.sample_rate,
output_dtype=trunk_dtype,
)
prompt_feature = F.pad(
prompt_feature,
(0, 0, 0, max(0, f0_coarse_prompt.shape[1] - prompt_feature.shape[1])),
)[:, : f0_coarse_prompt.shape[1], :]
target_feature = F.pad(
target_feature,
(0, 0, 0, max(0, f0_coarse_target.shape[1] - target_feature.shape[1])),
)[:, : f0_coarse_target.shape[1], :]
whisper_features = torch.cat([prompt_feature, target_feature], dim=1)
with self._stage_timer("cond_encode"):
cond = self._encode_condition(whisper_features=whisper_features, f0_coarse=f0_coarse)
with self._stage_timer("cfm"):
generated_mel = self._run_flow_matching_loop(
prompt=prompt_mel,
cond=cond,
n_timesteps=num_inference_steps,
cfg=guidance_scale,
generator=generator,
)
with self._stage_timer("vocoder"):
generated_audio = self._mel_to_audio(generated_mel, squeeze=True)
target_len = target_wav.shape[-1]
if generated_audio.shape[-1] > target_len:
generated_audio = generated_audio[:target_len]
elif generated_audio.shape[-1] < target_len:
generated_audio = F.pad(generated_audio, (0, target_len - generated_audio.shape[-1]))
return generated_audio
def _build_vocal_segments(
self,
f0: torch.Tensor,
*,
hop_size: int,
sample_rate: int,
uv_frames_th: int = 10,
min_duration_sec: float = 15.0,
max_duration_sec: float = 30.0,
ignore_silent: bool = True,
) -> tuple[list[tuple[float, float]], list[tuple[float, float]]]:
"""Build vocal segments based on F0 contour.
Mirrors upstream ``SoulXSingerSVC.build_vocal_segments``.
Returns (overlap_segments, segments) tuples of (start_sec, end_sec).
"""
f0_np = (
f0.detach().float().cpu().numpy().squeeze() if isinstance(f0, torch.Tensor) else np.asarray(f0).squeeze()
)
total_frames = int(f0_np.shape[0])
if total_frames == 0:
return [], []
f0_rate = sample_rate // hop_size
min_frames = max(1, int(round(min_duration_sec * f0_rate)))
max_frames = max(1, int(round(max_duration_sec * f0_rate)))
split_points = [0]
def append_split_point(point: int) -> None:
point = int(max(0, min(point, total_frames)))
while point - split_points[-1] > max_frames:
split_points.append(split_points[-1] + max_frames)
if point > split_points[-1]:
split_points.append(point)
idx = 0
while idx < total_frames:
if f0_np[idx] == 0:
run_start = idx
while idx < total_frames and f0_np[idx] == 0:
idx += 1
run_end = idx
if (run_end - run_start) >= uv_frames_th:
split_point = max(run_end - 5, (run_start + run_end) // 2)
append_split_point(split_point)
else:
idx += 1
append_split_point(total_frames)
segments: list[tuple[int, int]] = []
overlap_segments: list[tuple[int, int]] = []
num_overlaps = 1
def append_segment(start_idx: int, end_idx: int, overlaps: int = num_overlaps) -> None:
segments.append((split_points[start_idx], split_points[end_idx]))
overlap_start_idx = start_idx
if start_idx > 0 and (split_points[end_idx] - split_points[start_idx - overlaps]) <= max_frames:
overlap_start_idx = start_idx - overlaps
overlap_segments.append((split_points[overlap_start_idx], split_points[end_idx]))
seg_start, seg_end = 0, 1
while seg_start < len(split_points) - 1:
while seg_end < len(split_points) and (split_points[seg_end] - split_points[seg_start]) < min_frames:
seg_end += 1
if seg_end >= len(split_points):
append_segment(seg_start, len(split_points) - 1)
break
append_segment(seg_start, seg_end)
seg_start = seg_end
seg_end = seg_start + 1
if ignore_silent:
filtered_idx = []
for i, (ov_start, ov_end) in enumerate(overlap_segments):
voice_ratio = np.sum(f0_np[ov_start:ov_end] > 0) / max(1, ov_end - ov_start)
voiced_frames = np.sum(f0_np[ov_start:ov_end] > 0)
if voice_ratio > 0.05 and voiced_frames >= 10:
filtered_idx.append(i)
overlap_segments = [overlap_segments[i] for i in filtered_idx]
segments = [segments[i] for i in filtered_idx]
# convert indices to seconds
overlap_sec = [(s / f0_rate, e / f0_rate) for s, e in overlap_segments]
seg_sec = [(s / f0_rate, e / f0_rate) for s, e in segments]
return overlap_sec, seg_sec
def infer_svc_batch(
self,
payload: dict[str, Any],
*,
extra_args: dict[str, Any],
num_inference_steps: int,
guidance_scale: float,
generator: torch.Generator | None = None,
) -> tuple[torch.Tensor, int]:
"""Batch SVC with chunked inference for long audio (matches upstream)."""
prompt_wav = payload["prompt_wav"]
target_wav = payload["target_wav"]
prompt_f0 = payload["prompt_f0"]
target_f0 = payload["target_f0"]
pitch_shift = resolve_pitch_shift(
auto_shift=bool(extra_args.get("auto_shift", True)),
manual_shift=int(extra_args.get("pitch_shift", 0)),
prompt_f0=prompt_f0,
target_f0=target_f0,
)
prompt_mel = self._mel_from_wav(prompt_wav)
# Long audio: chunk by vocal segments (same as upstream)
max_sec = 30.0 * self.audio_config.sample_rate
if target_wav.shape[-1] < max_sec:
# Short audio: one-shot
generated_audio = self._infer_segment(
prompt_mel=prompt_mel,
prompt_wav=prompt_wav,
target_wav=target_wav,
prompt_f0=prompt_f0,
target_f0=target_f0,
pitch_shift=pitch_shift,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
)
return generated_audio.unsqueeze(0), pitch_shift
# Build vocal segments
overlap_segments, segments = self._build_vocal_segments(
target_f0,
hop_size=self.audio_config.hop_size,
sample_rate=self.audio_config.sample_rate,
)
if len(segments) == 0:
segments = [(0.0, target_wav.shape[-1] / self.audio_config.sample_rate)]
overlap_segments = [(0.0, target_wav.shape[-1] / self.audio_config.sample_rate)]
f0_rate = self.audio_config.sample_rate // self.audio_config.hop_size
generated_audio = torch.zeros_like(target_wav)
for idx in range(len(segments)):
overlap_start_sec, overlap_end_sec = overlap_segments[idx]
seg_start_sec, seg_end_sec = segments[idx]
wav_start = int(round(overlap_start_sec * self.audio_config.sample_rate))
wav_end = int(round(overlap_end_sec * self.audio_config.sample_rate))
f0_start = int(round(overlap_start_sec * f0_rate))
f0_end = int(round(overlap_end_sec * f0_rate))
wav_start = max(0, min(wav_start, target_wav.shape[-1]))
wav_end = max(wav_start, min(wav_end, target_wav.shape[-1]))
f0_start = max(0, min(f0_start, target_f0.shape[-1]))
f0_end = max(f0_start, min(f0_end, target_f0.shape[-1]))
segment_gt_wav = target_wav[:, wav_start:wav_end]
segment_gt_f0 = target_f0[:, f0_start:f0_end]
segment_generated = self._infer_segment(
prompt_mel=prompt_mel,
prompt_wav=prompt_wav,
target_wav=segment_gt_wav,
prompt_f0=prompt_f0,
target_f0=segment_gt_f0,
pitch_shift=pitch_shift,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
)
seg_start = int(round(seg_start_sec * self.audio_config.sample_rate))
seg_end = int(round(seg_end_sec * self.audio_config.sample_rate))
segment_generated = segment_generated[seg_start - wav_start : seg_end - wav_start]
generated_audio[:, seg_start:seg_end] = segment_generated
return generated_audio.unsqueeze(0), pitch_shift
@torch.inference_mode()
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
return self._forward_batch_from_request(
req,
kind="svc",
metadata_key="pitch_shift",
infer_batch_fn=self.infer_svc_batch,
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
del weights
self._load_soulx_checkpoint("model-svc.pt")
return {name for name, _ in self.named_parameters()}
@@ -1,355 +0,0 @@
"""SoulX-Singer SVS (score-driven) pipeline implementation."""
from collections.abc import Iterable
from typing import Any, ClassVar
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.models.soulx_singer.modules import (
CFMDecoder,
ConvNeXtV2Block,
)
from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_base import (
FlowMatchingAudioPipeline,
convert_soulx_audio_output_to_numpy,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import (
SOULX_PREPROCESSED_KEY,
get_soulx_preprocessed_payload,
has_precomputed,
)
from vllm_omni.diffusion.models.soulx_singer.preprocess.pre_process import (
attach_preprocess_for_diffusion_request,
build_metadata_processor,
is_warmup_request,
normalize_svs_control_extra_args,
resolve_preprocess_audio,
)
from vllm_omni.diffusion.models.soulx_singer.utils import (
f0_to_coarse,
resolve_pitch_shift,
validate_soulx_extra_args,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
logger = init_logger(__name__)
def get_soulxsinger_pre_process_func(od_config: OmniDiffusionConfig):
"""Validate/load SVS preprocess payload for single-stage or stage-1 DiT."""
metadata_processor = build_metadata_processor(od_config)
_pipeline = None
def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest:
nonlocal _pipeline
extra_args = validate_soulx_extra_args(
"svs",
dict(getattr(request.sampling_params, "extra_args", None) or {}),
)
# Inline build: when no warmup/no precomputed paths/no IPC payload,
# build the preprocess payload directly from audio file paths.
if not (is_warmup_request(request) or has_precomputed(extra_args, "svs")):
prompt = request.prompt
if not isinstance(prompt, str) and not get_soulx_preprocessed_payload(prompt): # type: ignore[arg-type]
prompt_audio, target_audio = resolve_preprocess_audio(prompt, extra_args) # type: ignore[arg-type]
if prompt_audio is not None and target_audio is not None:
if _pipeline is None:
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.pipeline import (
SoulXPreprocessPipeline,
)
_pipeline = SoulXPreprocessPipeline(
od_config=od_config,
vocal_sep=bool(extra_args.get("vocal_sep", False)),
verbose=bool(extra_args.get("preprocess_verbose", False)),
extra_args=extra_args,
)
payload = _pipeline.build_svs_payload_from_audio(
prompt_audio=prompt_audio,
target_audio=target_audio,
metadata_processor=metadata_processor,
language=str(extra_args.get("language", "Mandarin")),
vocal_sep=extra_args.get("vocal_sep"),
prompt_vocal_sep=extra_args.get("prompt_vocal_sep"),
target_vocal_sep=extra_args.get("target_vocal_sep"),
prompt_max_merge_duration_ms=extra_args.get("prompt_max_merge_duration"),
target_max_merge_duration_ms=extra_args.get("target_max_merge_duration"),
)
payload.setdefault("kind", "svs")
prompt.setdefault("additional_information", {})[SOULX_PREPROCESSED_KEY] = payload # type: ignore[union-attr, assignment]
return attach_preprocess_for_diffusion_request(
request,
kind="svs",
metadata_processor=metadata_processor,
)
return pre_process_func
def get_soulxsinger_post_process_func(od_config: OmniDiffusionConfig):
"""Convert pipeline audio tensor output for offline consumers."""
def post_process_func(audio: torch.Tensor):
return convert_soulx_audio_output_to_numpy(audio)
return post_process_func
def _expand_states(h: torch.Tensor, mel2token: torch.Tensor) -> torch.Tensor:
if mel2token.max() > h.size(1) - 1:
logger.warning(
"mel2token.max() (%s) is greater than h.size(1) - 1 (%s); clamping.",
mel2token.max(),
h.size(1) - 1,
)
mel2token = torch.clamp(mel2token, 0, h.size(1) - 1)
mel2token_ = mel2token[..., None].repeat([1, 1, h.shape[-1]])
return torch.gather(h, 1, mel2token_)
class PipelineSoulXSingerSVS(FlowMatchingAudioPipeline):
"""Pipeline for the SoulX-Singer model (SoulX-Singer)."""
_encoder_modules: ClassVar[list[str]] = [
"mel",
"f0_encoder",
"preflow",
"note_text_encoder",
"note_pitch_encoder",
"note_type_encoder",
]
EXTRA_BODY_PARAMS: ClassVar[frozenset[str]] = frozenset(
{
"prompt_metadata_path",
"target_metadata_path",
"audio_path",
"prompt_audio",
"target_audio",
"language",
"vocal_sep",
"max_merge_duration",
"preprocess_weights_dir",
"preprocess_verbose",
"control",
"auto_shift",
"pitch_shift",
}
)
EXTRA_OUTPUT_PARAMS: ClassVar[frozenset[str]] = frozenset({"f0_shift"})
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
self.metadata_processor = build_metadata_processor(od_config)
self.f0_encoder = nn.Embedding(self.f0_bin, self.f0_dim)
self.preflow = nn.Sequential(
*[
ConvNeXtV2Block(
self.text_dim,
self.text_dim * 2,
)
for _ in range(self.encoder_config.num_layers)
]
)
self.cfm_decoder = CFMDecoder(self.flow_matching_config)
self.mel, self.vocoder = self._build_fp32_audio_modules(self.audio_config)
self.note_text_encoder = nn.Embedding(self.vocab_size, self.text_dim)
self.note_pitch_encoder = nn.Embedding(256, self.pitch_dim)
self.note_type_encoder = nn.Embedding(256, self.type_dim)
self._setup_soulx_profiler()
def _encode_condition(
self,
*,
note_pitch: torch.Tensor,
note_type: torch.Tensor,
note_text: torch.Tensor,
mel2note: torch.Tensor,
f0_coarse: torch.Tensor,
) -> torch.Tensor:
features = (
self.note_pitch_encoder(note_pitch) + self.note_type_encoder(note_type) + self.note_text_encoder(note_text)
)
features = self.preflow(features)
features = _expand_states(features, mel2note)
features = features + self.f0_encoder(f0_coarse)
return self._to_trunk_dtype(features)[0]
def _infer_svs_segment(
self,
prompt_meta: dict,
target_meta: dict,
*,
pitch_shift: int,
num_inference_steps: int,
guidance_scale: float,
prompt_mel: torch.Tensor | None = None,
generator: torch.Generator | None = None,
) -> torch.Tensor:
prompt_note_text = prompt_meta["phoneme"]
prompt_mel2note = prompt_meta["mel2note"]
prompt_note_type = prompt_meta["note_type"]
prompt_note_pitch = prompt_meta["note_pitch"]
prompt_f0 = prompt_meta["f0"]
target_note_text = target_meta["phoneme"]
target_mel2note = target_meta["mel2note"]
target_note_type = target_meta["note_type"]
target_note_pitch = target_meta["note_pitch"]
target_f0 = target_meta["f0"]
if target_f0 is None or prompt_f0 is None:
target_f0 = torch.zeros_like(target_mel2note).float()
prompt_f0 = torch.zeros_like(prompt_mel2note).float()
if target_note_pitch is None or prompt_note_pitch is None:
target_note_pitch = torch.zeros_like(target_note_type).int()
prompt_note_pitch = torch.zeros_like(prompt_note_type).int()
len_prompt_note = prompt_note_pitch.shape[1]
len_prompt_mel = prompt_f0.shape[1] if prompt_f0 is not None else prompt_mel2note.shape[1]
note_pitch = torch.cat([prompt_note_pitch, target_note_pitch], dim=1)
note_text = torch.cat([prompt_note_text, target_note_text], dim=1)
note_type = torch.cat([prompt_note_type, target_note_type], dim=1)
# Target note indices follow prompt notes in the concatenated score.
mel2note = torch.cat([prompt_mel2note, target_mel2note + len_prompt_note], dim=1)
# pitch_shift is semitones; each coarse F0 bin is 20 cents (×5 bins per semitone).
f0_coarse_prompt = f0_to_coarse(prompt_f0)
f0_coarse_target = f0_to_coarse(target_f0, f0_shift=pitch_shift * 5)
f0_coarse = torch.cat([f0_coarse_prompt, f0_coarse_target], dim=1)
note_pitch = note_pitch.clone()
note_pitch[note_pitch > 0] = note_pitch[note_pitch > 0] + pitch_shift
note_pitch = torch.clamp(note_pitch, 0, 255)
if prompt_mel is None:
prompt_wav = prompt_meta["wav"]
with self._stage_timer("mel"):
prompt_mel = self._mel_from_wav(prompt_wav)
if prompt_mel.shape[1] > len_prompt_mel:
prompt_mel = prompt_mel[:, :len_prompt_mel, :]
elif prompt_mel.shape[1] < len_prompt_mel:
logger.warning(
"prompt_mel length %s is shorter than metadata frames %s; padding mel.",
prompt_mel.shape[1],
len_prompt_mel,
)
prompt_mel = F.pad(prompt_mel, (0, 0, 0, len_prompt_mel - prompt_mel.shape[1]))
with self._stage_timer("cond_encode"):
cond = self._encode_condition(
note_pitch=note_pitch,
note_type=note_type,
note_text=note_text,
mel2note=mel2note,
f0_coarse=f0_coarse,
)
with self._stage_timer("cfm"):
generated_mel = self._run_flow_matching_loop(
prompt_mel,
cond,
num_inference_steps,
guidance_scale,
generator=generator,
)
with self._stage_timer("vocoder"):
return self._mel_to_audio(generated_mel)
def infer_svs_batch(
self,
payload: dict[str, Any],
*,
extra_args: dict[str, Any],
num_inference_steps: int,
guidance_scale: float,
generator: torch.Generator | None = None,
) -> tuple[torch.Tensor, int]:
"""Pure batch (non-streaming): run diffusion for each target segment in order and concat."""
prompt_meta_raw = payload.get("prompt_meta")
target_meta_list = payload["target_meta_list"]
control = str(extra_args.get("control", "score"))
prompt_meta = None
prompt_mel = None
if prompt_meta_raw:
prompt_meta = self._ensure_processed_meta(prompt_meta_raw)
prompt_meta = self._apply_control_mode(prompt_meta, control)
if prompt_meta and prompt_meta.get("wav") is not None:
prompt_mel = self._mel_from_wav(prompt_meta["wav"])
pieces: list[torch.Tensor] = []
pitch_shift = 0
for idx, target_raw in enumerate(target_meta_list):
target_meta = self._ensure_processed_meta(target_raw)
target_meta = self._apply_control_mode(target_meta, control)
if idx == 0:
pitch_shift = resolve_pitch_shift(
auto_shift=bool(extra_args.get("auto_shift", True)),
manual_shift=int(extra_args.get("pitch_shift", 0)),
prompt_f0=prompt_meta["f0"] if prompt_meta else None,
target_f0=target_meta["f0"],
prompt_note_pitch=prompt_meta["note_pitch"] if prompt_meta else None,
target_note_pitch=target_meta["note_pitch"],
)
seg = self._infer_svs_segment(
prompt_meta or {},
target_meta,
pitch_shift=pitch_shift,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
prompt_mel=prompt_mel,
generator=generator,
)
pieces.append(seg.squeeze())
if not pieces:
return torch.zeros(1, 0, device=self.device, dtype=torch.float32), pitch_shift
full = torch.cat(pieces, dim=-1)
return full.unsqueeze(0), pitch_shift
@staticmethod
def _apply_control_mode(meta: dict, control: str) -> dict:
meta["note_pitch"] = meta["note_pitch"] if control == "score" else None
meta["f0"] = meta["f0"] if control == "melody" else None
return meta
def _ensure_processed_meta(self, meta: dict) -> dict:
if isinstance(meta.get("phoneme"), torch.Tensor):
return meta
return self.metadata_processor.process(meta, None)
@torch.inference_mode()
def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput:
return self._forward_batch_from_request(
req,
kind="svs",
metadata_key="f0_shift",
infer_batch_fn=self.infer_svs_batch,
prepare_extra_args=lambda extra_args, _sampling: normalize_svs_control_extra_args(extra_args),
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
del weights
self._load_soulx_checkpoint("model.pt")
return {name for name, _ in self.named_parameters()}
@@ -1 +0,0 @@
"""Native SoulX-Singer preprocess helpers."""
@@ -1,86 +0,0 @@
import re
_EN_WORD_RE = re.compile(r"^[A-Za-z]+(?:'[A-Za-z]+)*$")
_ZH_WORD_RE = re.compile(r"[\u4e00-\u9fff]")
EN_FLAG = "en_"
YUE_FLAG = "yue_"
ZH_FLAG = "zh_"
_g2p_zh = None
_g2p_en = None
def _ensure_g2p_backends() -> None:
global _g2p_zh, _g2p_en
if _g2p_zh is None:
from g2pM import G2pM
_g2p_zh = G2pM()
if _g2p_en is None:
from g2p_en import G2p as G2pE
_g2p_en = G2pE()
def is_chinese_char(word: str) -> bool:
if len(word) != 1:
return False
return bool(_ZH_WORD_RE.fullmatch(word))
def is_english_word(word: str) -> bool:
if not word:
return False
return bool(_EN_WORD_RE.fullmatch(word))
def g2p_cantonese(sent):
import ToJyutping
return ToJyutping.get_jyutping_list(sent)
def g2p_mandarin(sent):
_ensure_g2p_backends()
return _g2p_zh(sent, tone=True, char_split=False)
def g2p_english(word):
_ensure_g2p_backends()
return _g2p_en(word)
def g2p_transform(words, lang):
zh_words = []
transformed_words = [0] * len(words)
for idx, w in enumerate(words):
if w == "<SP>":
transformed_words[idx] = w
continue
w = w.replace("?", "").replace(".", "").replace("!", "").replace(",", "")
if is_chinese_char(w):
zh_words.append([idx, w])
else:
if is_english_word(w):
w = EN_FLAG + "-".join(g2p_english(w.lower()))
else:
w = "<SP>"
transformed_words[idx] = w
sent = "".join([k[1] for k in zh_words])
if len(sent) > 0:
if lang == "Cantonese":
g2pm_rst = g2p_cantonese(sent)
g2pm_rst = [YUE_FLAG + k[1] for k in g2pm_rst]
else:
g2pm_rst = g2p_mandarin(sent)
g2pm_rst = [ZH_FLAG + k for k in g2pm_rst]
for p, w in zip([k[0] for k in zh_words], g2pm_rst):
transformed_words[p] = w
return transformed_words
@@ -1,137 +0,0 @@
"""Metadata merge helpers for SoulX-Singer preprocess."""
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import soundfile as sf
@dataclass
class SegmentMetadata:
item_name: str
wav_fn: str
language: str
start_time_ms: int
end_time_ms: int
note_text: list[str]
note_dur: list[float]
note_pitch: list[int]
note_type: list[int]
origin_wav_fn: str | None = None
wav: np.ndarray | None = None
def get(self, name: str, default: Any = None) -> Any:
return getattr(self, name, default)
def _merge_group(
audio: np.ndarray,
sample_rate: int,
segments: list[SegmentMetadata | dict[str, Any]],
output_dir: Path | None,
*,
end_extension_ms: int = 0,
) -> SegmentMetadata:
if not segments:
raise ValueError("segments must not be empty")
words: list[str] = []
durs: list[float] = []
pitches: list[int] = []
types: list[int] = []
for i, seg in enumerate(segments):
if i > 0:
prev_seg = segments[i - 1]
gap_ms = seg.get("start_time_ms", 0) - prev_seg.get("end_time_ms", 0)
if gap_ms > 0:
words.append("<SP>")
durs.append(gap_ms / 1000.0)
pitches.append(0)
types.append(1)
words.extend(seg.get("note_text", []))
durs.extend(seg.get("note_dur", []))
pitches.extend(seg.get("note_pitch", []))
types.extend(seg.get("note_type", []))
if end_extension_ms > 0:
words.append("<SP>")
durs.append(end_extension_ms / 1000.0)
pitches.append(0)
types.append(1)
merged_words, merged_durs, merged_pitches, merged_types = [], [], [], []
for word, dur, pitch, note_type in zip(words, durs, pitches, types):
if merged_words and word == "<SP>" and merged_words[-1] == "<SP>":
merged_durs[-1] += dur
else:
merged_words.append(word)
merged_durs.append(dur)
merged_pitches.append(pitch)
merged_types.append(note_type)
languages = [s.get("language", "Mandarin") for s in segments if s.get("language")]
language = max(languages, key=languages.count) if languages else "Mandarin"
start_ms = int(segments[0].get("start_time_ms", 0))
end_ms = int(segments[-1].get("end_time_ms", 0)) + end_extension_ms
start_sample = int(start_ms * sample_rate // 1000)
end_sample = int(end_ms * sample_rate // 1000)
first_item_name = segments[0].get("item_name", "segment")
song_prefix = "_".join(str(first_item_name).split("_")[:-1])
item_name = f"{song_prefix}_{start_ms}_{end_ms}"
wav_fn = ""
segment_audio = audio[start_sample:end_sample]
if output_dir is not None:
output_dir.mkdir(parents=True, exist_ok=True)
wav_path = output_dir / f"{item_name}.wav"
sf.write(wav_path, segment_audio, sample_rate)
wav_fn = str(wav_path)
return SegmentMetadata(
item_name=item_name,
wav_fn=wav_fn,
language=language,
start_time_ms=start_ms,
end_time_ms=end_ms,
note_text=merged_words,
note_dur=merged_durs,
note_pitch=merged_pitches,
note_type=merged_types,
origin_wav_fn=segments[0].get("origin_wav_fn", ""),
wav=segment_audio if output_dir is None else None,
)
def convert_metadata(item: SegmentMetadata, f0: np.ndarray | None = None) -> dict[str, Any]:
"""Convert merged segment metadata into SoulX JSON metadata format.
Matches upstream ``convert_metadata``: F0 always comes from ``*_f0.npy`` written
by ``extract_f0_file`` on the merged segment wav, not from an in-memory array.
"""
from vllm_omni.diffusion.models.soulx_singer.preprocess.g2p import g2p_transform
if item.wav_fn:
f0_path = str(Path(item.wav_fn).with_suffix("")) + "_f0.npy"
f0_arr = np.load(f0_path)
elif f0 is not None:
f0_arr = f0
else:
raise ValueError("convert_metadata requires item.wav_fn with sidecar f0 or an explicit f0 array")
return {
"index": item.item_name,
"language": item.language,
"time": [item.start_time_ms, item.end_time_ms],
"duration": " ".join(f"{d:.2f}" for d in item.note_dur),
"text": " ".join(item.note_text),
"phoneme": " ".join(g2p_transform(item.note_text, item.language)),
"note_pitch": " ".join(map(str, item.note_pitch)),
"note_type": " ".join(map(str, item.note_type)),
"f0": " ".join(f"{x:.1f}" for x in f0_arr),
}
@@ -1,106 +0,0 @@
"""SoulX-Singer payload schema (guidance/precomputed tables, dummy builders)."""
from __future__ import annotations
from typing import Any
import torch
SOULX_PRECOMPUTED_KEYS_BY_KIND: dict[str, tuple[str, ...]] = {
"svs": ("prompt_metadata_path", "target_metadata_path", "audio_path"),
"svc": ("prompt_wav_path", "target_wav_path", "prompt_f0_path", "target_f0_path"),
}
# Key under which the SoulX preprocess payload is stored in
# prompt["additional_information"] or diffusion output metadata.
SOULX_PREPROCESSED_KEY = "soulx_preprocessed"
def get_soulx_preprocessed_payload(prompt: dict[str, Any]) -> dict[str, Any] | None:
"""Extract attached payload from a diffusion request prompt dict."""
additional = prompt.get("additional_information") or {}
payload = additional.get(SOULX_PREPROCESSED_KEY)
return payload if isinstance(payload, dict) else None
def relocate_tensors(payload: dict[str, Any], device: torch.device | str) -> dict[str, Any]:
"""Recursively move all tensors inside the payload to the target device.
Called after decode when the payload is about to be consumed by the
diffusion model (which may live on a different device / rank).
"""
if isinstance(payload, torch.Tensor):
return payload.to(device, non_blocking=True)
if isinstance(payload, dict):
return {key: relocate_tensors(value, device) for key, value in payload.items()}
if isinstance(payload, list):
return [relocate_tensors(item, device) for item in payload]
return payload
def consume_payload(
req: Any,
expected_kind: str,
device: torch.device | str,
) -> dict[str, Any]:
"""Read the payload attached by pre_process_func, validate the kind,
and relocate tensors to the compute device.
"""
prompts = getattr(req, "prompts", None) or []
if not prompts:
raise ValueError(f"SoulX-Singer {expected_kind} request has no prompts.")
prompt = prompts[0]
if isinstance(prompt, str):
raise ValueError(f"SoulX-Singer {expected_kind} forward requires pre_process_func output on the prompt.")
payload = get_soulx_preprocessed_payload(prompt)
if payload is None or payload.get("kind") != expected_kind:
raise ValueError(
f"SoulX-Singer {expected_kind} forward requires additional_information['soulx_preprocessed'] "
"produced by pre_process_func."
)
return relocate_tensors(payload, device)
def has_precomputed(extra_args: dict[str, Any], kind: str) -> bool:
keys = SOULX_PRECOMPUTED_KEYS_BY_KIND[kind]
return all(extra_args.get(key) for key in keys)
def build_dummy_payload(kind: str, device: torch.device) -> dict[str, Any]:
mel_frames = 4
hop_size = 480
n_fft = 1920
wav_samples = max(mel_frames * hop_size, n_fft)
if kind == "svc":
voiced_f0 = torch.full((1, mel_frames), 100.0, device=device, dtype=torch.float64)
return {
"kind": "svc",
"prompt_wav": torch.zeros(1, wav_samples, device=device, dtype=torch.float32),
"target_wav": torch.zeros(1, wav_samples, device=device, dtype=torch.float32),
"prompt_f0": voiced_f0.clone(),
"target_f0": voiced_f0.clone(),
}
return {
"kind": "svs",
"prompt_meta": {
"phoneme": torch.zeros(1, 2, device=device, dtype=torch.long),
"note_pitch": torch.zeros(1, mel_frames, device=device, dtype=torch.long),
"note_type": torch.zeros(1, mel_frames, device=device, dtype=torch.long),
"mel2note": torch.zeros(1, mel_frames, device=device, dtype=torch.long),
"f0": torch.zeros(1, mel_frames, device=device, dtype=torch.float32),
"wav": torch.zeros(1, wav_samples, device=device, dtype=torch.float32),
},
"target_meta_list": [
{
"text": "dummy",
"phoneme": " ".join(["<SP>"] * mel_frames),
"f0": " ".join(["100"] * mel_frames),
"language": "Mandarin",
"note_pitch": " ".join(["0"] * mel_frames),
"note_type": " ".join(["1"] * mel_frames),
"duration": " ".join(["1"] * mel_frames),
"time": [0, max(int(wav_samples / 24000 * 1000), 100)],
}
],
}
@@ -1,223 +0,0 @@
"""Shared ``pre_process_func`` helpers for SoulX-Singer pipelines (batch / full-payload only)."""
from pathlib import Path
from typing import Any
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.soulx_singer.modules.preprocess.pipeline import SoulXPreprocessPipeline
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import (
SOULX_PRECOMPUTED_KEYS_BY_KIND,
SOULX_PREPROCESSED_KEY,
build_dummy_payload,
get_soulx_preprocessed_payload,
has_precomputed,
)
from vllm_omni.diffusion.models.soulx_singer.utils import (
MetadataProcessor,
load_config,
resolve_phoneset_path,
validate_soulx_extra_args,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
logger = init_logger(__name__)
def is_warmup_request(request: OmniDiffusionRequest) -> bool:
return request.is_dummy_run()
def resolve_preprocess_audio(prompt: dict[str, Any], extra_args: dict[str, Any]) -> tuple[Any, Any]:
mm = prompt.get("multi_modal_data") or {}
prompt_audio = mm.get("audio") if isinstance(mm, dict) else None
if prompt_audio is None:
prompt_audio = extra_args.get("prompt_audio")
return prompt_audio, extra_args.get("target_audio")
def _preprocess_inputs_missing_error(kind: str) -> ValueError:
return ValueError(
f"SoulX-Singer {kind} requires precomputed paths "
f"{list(SOULX_PRECOMPUTED_KEYS_BY_KIND[kind])}, an attached soulx_preprocessed payload "
f"(multi-stage preprocess stage), or run with pipeline soulxsinger_{kind}."
)
def build_precomputed_payload(
kind: str,
extra_args: dict[str, Any],
*,
metadata_processor,
sample_rate: int | None,
device,
) -> dict[str, Any]:
if kind == "svs":
return SoulXPreprocessPipeline.build_svs_payload_from_paths(
prompt_metadata_path=str(extra_args["prompt_metadata_path"]),
target_metadata_path=str(extra_args["target_metadata_path"]),
audio_path=str(extra_args["audio_path"]),
metadata_processor=metadata_processor,
)
if sample_rate is None or device is None:
raise ValueError("svc precomputed payload requires sample_rate and device")
return SoulXPreprocessPipeline.build_svc_payload_from_paths( # type: ignore
prompt_wav_path=str(extra_args["prompt_wav_path"]),
target_wav_path=str(extra_args["target_wav_path"]),
prompt_f0_path=str(extra_args["prompt_f0_path"]),
target_f0_path=str(extra_args["target_f0_path"]),
sample_rate=int(sample_rate),
device=device,
)
def build_warmup_payload(
kind: str,
*,
metadata_processor,
device,
sample_rate: int | None,
) -> dict[str, Any]:
if kind == "svs":
if metadata_processor is None:
raise ValueError("SVS warmup requires metadata_processor")
payload = build_dummy_payload("svs", torch.device("cpu"))
dummy_prompt = payload["prompt_meta"]
processed = metadata_processor.process(dict(payload["target_meta_list"][0]))
payload["prompt_meta"] = {
key: value.clone() if isinstance(value, torch.Tensor) else value for key, value in processed.items()
}
if isinstance(dummy_prompt.get("wav"), torch.Tensor):
payload["prompt_meta"]["wav"] = dummy_prompt["wav"].clone()
return payload
if device is None or sample_rate is None:
raise ValueError("svc warmup requires device and sample_rate")
return build_dummy_payload("svc", device)
def build_preprocess_payload(
kind: str,
*,
prompt: dict[str, Any],
extra_args: dict[str, Any],
preprocess: nn.Module,
metadata_processor,
device,
sample_rate: int,
) -> dict[str, Any]:
if has_precomputed(extra_args, kind):
return build_precomputed_payload(
kind,
extra_args,
metadata_processor=metadata_processor,
sample_rate=sample_rate,
device=device,
)
prompt_audio, target_audio = resolve_preprocess_audio(prompt, extra_args)
if prompt_audio is None or target_audio is None:
raise _preprocess_inputs_missing_error(kind)
if kind == "svc":
return preprocess.build_svc_payload_from_audio( # type: ignore
prompt_audio=prompt_audio,
target_audio=target_audio,
sample_rate=sample_rate,
device=device,
vocal_sep=extra_args.get("vocal_sep"),
)
return preprocess.build_svs_payload_from_audio( # type: ignore
prompt_audio=prompt_audio,
target_audio=target_audio,
metadata_processor=metadata_processor,
language=str(extra_args.get("language", "Mandarin")),
vocal_sep=extra_args.get("vocal_sep"),
prompt_vocal_sep=extra_args.get("prompt_vocal_sep"),
target_vocal_sep=extra_args.get("target_vocal_sep"),
prompt_max_merge_duration_ms=extra_args.get("prompt_max_merge_duration"),
target_max_merge_duration_ms=extra_args.get("target_max_merge_duration"),
)
def attach_preprocess_for_diffusion_request(
request: OmniDiffusionRequest,
*,
kind: str,
metadata_processor=None,
sample_rate: int | None = None,
device=None,
) -> OmniDiffusionRequest:
"""Resolve preprocess payload for stage-1 diffusion (warmup / IPC / precomputed paths)."""
extra_args = validate_soulx_extra_args(
kind,
dict(getattr(request.sampling_params, "extra_args", None) or {}),
)
prompt = request.prompt
prompt = OmniTextPrompt(prompt=prompt) if isinstance(prompt, str) else prompt
if is_warmup_request(request):
request.sampling_params.num_inference_steps = 1
payload = build_warmup_payload(
kind,
metadata_processor=metadata_processor,
device=device,
sample_rate=sample_rate,
)
elif get_soulx_preprocessed_payload(prompt):
request.prompt = prompt
if kind == "svs":
extra_args = normalize_svs_control_extra_args(extra_args)
request.sampling_params.extra_args = extra_args
return request
elif has_precomputed(extra_args, kind):
payload = build_precomputed_payload(
kind,
extra_args,
metadata_processor=metadata_processor,
sample_rate=sample_rate,
device=device,
)
else:
raise _preprocess_inputs_missing_error(kind)
if payload.get("kind") != kind:
raise ValueError(f"Invalid {kind} preprocess payload kind: {payload.get('kind')}")
prompt.setdefault("additional_information", {})[SOULX_PREPROCESSED_KEY] = payload
request.prompt = prompt
if kind == "svs":
extra_args = normalize_svs_control_extra_args(extra_args)
request.sampling_params.extra_args = extra_args
return request
def normalize_svs_control_extra_args(extra_args: dict[str, Any]) -> dict[str, Any]:
control = extra_args.get("control")
if control is None:
control = "score"
logger.warning("control is not provided, using 'score' as default")
elif control not in ("score", "melody"):
raise ValueError(f"Invalid control: {control}. Must be one of: ['score', 'melody']")
extra_args["control"] = control
return extra_args
def build_metadata_processor(od_config: OmniDiffusionConfig):
model_dir = od_config.model
config_path = Path(model_dir) / "config.yaml"
hf_config = load_config(str(config_path))
audio_config = hf_config.audio
return MetadataProcessor(
hop_size=audio_config.hop_size,
sample_rate=audio_config.sample_rate,
phoneset_path=resolve_phoneset_path(model_dir),
device=str(get_local_device()),
)
@@ -1,565 +0,0 @@
import json
from pathlib import Path
from typing import Any, Literal
import numpy as np
import torch
import torchaudio
from huggingface_hub import snapshot_download
from omegaconf import DictConfig, OmegaConf
from vllm.logger import init_logger
from vllm_omni.diffusion.data import OmniDiffusionConfig
logger = init_logger(__name__)
_MODEL_WEIGHTS_REPO = "Soul-AILab/SoulX-Singer"
_PREPROCESS_WEIGHTS_REPO = "Soul-AILab/SoulX-Singer-Preprocess"
def _patch_torchaudio_load() -> None:
"""Borrowed from MOSS-TTS-Nano. Patch torchaudio.load to use soundfile
if torchcodec is unavailable.
"""
try:
import torchaudio
torchaudio # noqa
import torchcodec # noqa: F401
return
except Exception:
pass
import soundfile as sf
def _soundfile_load(path, frame_offset=0, num_frames=-1, normalize=True, channels_first=True, format=None):
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
if frame_offset > 0:
data = data[frame_offset:]
if num_frames > 0:
data = data[:num_frames]
waveform = torch.from_numpy(data)
if channels_first:
waveform = waveform.T
return waveform, sr
def _soundfile_save(path, src, sample_rate, channels_first=True, **kwargs):
wav = src.detach().cpu().float().numpy()
if channels_first and wav.ndim == 2:
wav = wav.T
sf.write(str(path), wav, sample_rate)
try:
import torchaudio
torchaudio.load = _soundfile_load
torchaudio.save = _soundfile_save
logger.info("Patched torchaudio.load/save to use soundfile (torchcodec unavailable)")
except Exception as e:
logger.warning("Could not patch torchaudio: %s", e)
def load_config(config_path: str | Path) -> DictConfig:
"""
Load a configuration file and optionally merges it with a base configuration.
Args:
config_path (Path): Path to the configuration file.
"""
# Load the initial configuration from the given path
config = OmegaConf.load(str(config_path))
# Check if there is a base configuration specified and merge if necessary
if config.get("base_config", None) is not None:
base_config = OmegaConf.load(str(config.get("base_config")))
config = OmegaConf.merge(base_config, config)
return config
SoulXKind = Literal["svs", "svc"]
_SVS_ARCHITECTURE = "SoulXSingerPipeline"
_SVC_ARCHITECTURE = "SoulXSingerSVCPipeline"
_ARCHITECTURE_TO_KIND: dict[str, SoulXKind] = {
_SVS_ARCHITECTURE: "svs",
_SVC_ARCHITECTURE: "svc",
}
def load_model_config_json(model_dir: str | Path) -> dict[str, Any]:
"""Load ``config.json`` from a SoulX-Singer model view directory."""
cfg_path = Path(model_dir) / "config.json"
if not cfg_path.is_file():
raise FileNotFoundError(
f"SoulX-Singer config.json not found at {cfg_path}. "
"Create one with architectures ['SoulXSingerPipeline'] (SVS) or "
"['SoulXSingerSVCPipeline'] (SVC); see examples/offline_inference/text_to_speech/README.md."
)
with cfg_path.open(encoding="utf-8") as f:
return json.load(f)
def resolve_soulx_kind(model_dir: str | Path) -> SoulXKind:
"""Resolve SVS vs SVC from ``config.json`` ``architectures[0]``."""
cfg = load_model_config_json(model_dir)
archs = cfg.get("architectures") or []
if not archs:
raise ValueError(
f"SoulX-Singer config.json at {Path(model_dir) / 'config.json'} is missing 'architectures'. "
"Set architectures to ['SoulXSingerPipeline'] for SVS or ['SoulXSingerSVCPipeline'] for SVC."
)
arch = archs[0]
kind = _ARCHITECTURE_TO_KIND.get(arch)
if kind is None:
raise ValueError(
f"Unknown SoulX-Singer architecture {arch!r} in config.json. Expected one of {list(_ARCHITECTURE_TO_KIND)}."
)
return kind
def validate_soulx_extra_args(kind: SoulXKind, extra_args: dict[str, Any] | None) -> dict[str, Any]:
"""Validate request ``extra_args`` against the model mode from config.json."""
from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import (
SOULX_PRECOMPUTED_KEYS_BY_KIND,
has_precomputed,
)
extra_args = dict(extra_args or {})
declared = extra_args.pop("kind", None)
if declared is not None and declared != kind:
logger.warning(
"Ignoring extra_args kind=%r; model config.json declares %r.",
declared,
kind,
)
other_kind: SoulXKind = "svc" if kind == "svs" else "svs"
if has_precomputed(extra_args, other_kind):
raise ValueError(
f"SoulX-Singer config.json declares {kind!r}, but request supplies "
f"{other_kind} precomputed paths {list(SOULX_PRECOMPUTED_KEYS_BY_KIND[other_kind])}."
)
if kind == "svc":
for key in ("language", "control"):
if extra_args.get(key) is not None:
logger.warning(
"Ignoring SVS-only extra_arg %r=%r on an SVC model (config.json).",
key,
extra_args.pop(key),
)
for key in SOULX_PRECOMPUTED_KEYS_BY_KIND["svs"]:
if extra_args.get(key):
raise ValueError(f"SoulX-Singer SVC model (config.json) does not accept SVS precomputed key {key!r}.")
else:
for key in SOULX_PRECOMPUTED_KEYS_BY_KIND["svc"]:
if extra_args.get(key):
raise ValueError(f"SoulX-Singer SVS model (config.json) does not accept SVC precomputed key {key!r}.")
return extra_args
# ---------------- utils for model weights ----------------
def resolve_preprocess_weights_root(od_config: OmniDiffusionConfig) -> Path:
"""Locate preprocess weights on disk or download from Hugging Face."""
# default local dir
if Path(od_config.model).parent.is_dir():
local_dir = Path(od_config.model).parent / "SoulX-Singer-Preprocess"
else:
local_dir = Path(__file__).parents[5] / "pretrained" / "SoulX-Singer-Preprocess"
local_dir = local_dir.expanduser()
if local_dir.is_dir():
logger.info("Using SoulX preprocess weights from %s", local_dir)
return local_dir
logger.info(
"SoulX preprocess weights not found locally; downloading %s",
_PREPROCESS_WEIGHTS_REPO,
)
downloaded = snapshot_download(
_PREPROCESS_WEIGHTS_REPO,
allow_patterns=["*"],
local_dir=local_dir,
)
return Path(downloaded)
def preprocess_weight_paths(weights_root: Path) -> dict[str, str]:
"""Map upstream relative paths to absolute paths under ``weights_root``."""
root = weights_root.resolve()
return {
"rmvpe": str(root / "rmvpe/rmvpe.pt"),
"sep_ckpt": str(root / "mel-band-roformer-karaoke/mel_band_roformer_karaoke_becruily.ckpt"),
"sep_config": str(root / "mel-band-roformer-karaoke/config_karaoke_becruily.yaml"),
"asr_zh": str(root / "speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch"),
"asr_en": str(root / "parakeet-tdt-0.6b-v2/parakeet-tdt-0.6b-v2.nemo"),
"rosvot": str(root / "rosvot/rosvot/model.pt"),
}
def resolve_phoneset_path(model_dir: str) -> str:
"""Resolve phoneme vocabulary; must be present in model directory."""
candidates = (
Path(model_dir) / "phoneme" / "phone_set.json",
Path(model_dir) / "phone_set.json",
)
for path in candidates:
if path.is_file():
return str(path)
raise FileNotFoundError(
"SoulX-Singer phoneset not found. Expected one of: "
f"{[str(p) for p in candidates]}. "
"Copy phone_set.json into the model directory. "
"See `examples/offline_inference/text_to_speech/README.md` for instructions."
)
# ---------------- utils for data processing ----------------
def load_wav(wav_path: str, sample_rate: int) -> torch.Tensor:
"""Load wav file and resample to target sample rate.
Args:
wav_path (str): Path to wav file.
sample_rate (int): Target sample rate.
Returns:
torch.Tensor: Waveform tensor with shape (1, T).
"""
_patch_torchaudio_load()
waveform, sr = torchaudio.load(wav_path)
if sr != sample_rate:
waveform = torchaudio.functional.resample(waveform, sr, sample_rate)
if len(waveform.shape) > 1 and waveform.shape[0] > 1:
waveform = torch.mean(waveform, dim=0, keepdim=True)
return waveform
def f0_to_coarse(f0, f0_bin=361, f0_min=32.7031956625, f0_shift=0):
"""
Convert continuous F0 values to discrete F0 bins (SIL and C1 - B6, 361 bins).
args:
f0: continuous F0 values
f0_bin: number of F0 bins
f0_min: minimum F0 value
f0_shift: shift value for F0 bins
returns:
f0_coarse: discrete F0 bins
"""
is_torch = isinstance(f0, torch.Tensor)
uv_mask = f0 <= 0
if is_torch:
f0_min_tensor = f0.new_tensor(f0_min)
f0_safe = torch.maximum(f0, f0_min_tensor)
f0_cents = 1200 * torch.log2(f0_safe / f0_min_tensor)
else:
f0_safe = np.maximum(f0, f0_min)
f0_cents = 1200 * np.log2(f0_safe / f0_min)
f0_coarse = (f0_cents / 20) + 1
if is_torch:
f0_coarse = torch.round(f0_coarse).long()
f0_coarse = torch.clamp(f0_coarse, min=1, max=f0_bin - 1)
else:
f0_coarse = np.rint(f0_coarse).astype(int)
f0_coarse = np.clip(f0_coarse, 1, f0_bin - 1)
f0_coarse[uv_mask] = 0
if f0_shift != 0:
if is_torch:
voiced = f0_coarse > 0
if voiced.any():
shifted = f0_coarse[voiced] + f0_shift
f0_coarse[voiced] = torch.clamp(shifted, 1, f0_bin - 1)
else:
voiced = f0_coarse > 0
if np.any(voiced):
shifted = f0_coarse[voiced] + f0_shift
f0_coarse[voiced] = np.clip(shifted, 1, f0_bin - 1)
return f0_coarse
def resolve_pitch_shift(
*,
auto_shift: bool,
manual_shift: int,
prompt_f0: torch.Tensor | None = None,
target_f0: torch.Tensor | None = None,
prompt_note_pitch: torch.Tensor | None = None,
target_note_pitch: torch.Tensor | None = None,
) -> int:
"""Resolve semitone shift for auto_shift; ``f0_to_coarse(..., f0_shift=shift * 5)`` consumes it."""
if not auto_shift or manual_shift != 0:
return int(manual_shift)
if prompt_note_pitch is not None and target_note_pitch is not None:
target_pitched = target_note_pitch[target_note_pitch >= 1]
prompt_pitched = prompt_note_pitch[prompt_note_pitch >= 1]
if target_pitched.numel() > 0 and prompt_pitched.numel() > 0:
shift = torch.round(prompt_pitched.median() - target_pitched.median())
if torch.isfinite(shift):
return int(shift.item())
if prompt_f0 is not None and target_f0 is not None:
target_voiced = target_f0[target_f0 > 0]
prompt_voiced = prompt_f0[prompt_f0 > 0]
if target_voiced.numel() > 0 and prompt_voiced.numel() > 0:
shift = torch.round(torch.log2(prompt_voiced.median() / target_voiced.median()) * 1200 / 100)
if torch.isfinite(shift):
return int(shift.item())
return 0
class MetadataProcessor:
"""Data processor for SoulX-Singer"""
def __init__(
self,
hop_size: int,
sample_rate: int,
phoneset_path: str,
device: str = "cuda",
):
"""Initialize data processor.
Args:
hop_size (int): Hop size in samples.
sample_rate (int): Sample rate in Hz.
phoneset_path (str): Path to phoneme set JSON file.
device (str): Device to use for tensor operations.
"""
self.hop_size = hop_size
self.sample_rate = sample_rate
self.device = device
self.load_phoneme_id_map(phoneset_path)
def load_phoneme_id_map(self, phoneset_path: str):
with open(phoneset_path, encoding="utf-8") as f:
phoneset = json.load(f)
self.phone2idx = {ph: idx for idx, ph in enumerate(phoneset)}
def merge_phoneme(self, meta):
merged_items = []
duration = [float(x) for x in meta["duration"].split()]
phoneme = [str(x).replace("<AP>", "<SP>") for x in meta["phoneme"].split()]
note_pitch = [int(x) for x in meta["note_pitch"].split()]
note_type = [int(x) if phoneme[i] != "<SP>" else 1 for i, x in enumerate(meta["note_type"].split())]
for i in range(len(phoneme)):
if (
i > 0
and phoneme[i] == phoneme[i - 1] == "<SP>"
and note_type[i] == note_type[i - 1]
and note_pitch[i] == note_pitch[i - 1]
):
merged_items[-1][1] += duration[i]
else:
merged_items.append([phoneme[i], duration[i], note_pitch[i], note_type[i]])
meta["phoneme"] = [x[0] for x in merged_items]
meta["duration"] = [x[1] for x in merged_items]
meta["note_pitch"] = [x[2] for x in merged_items]
meta["note_type"] = [x[3] for x in merged_items]
return meta
def preprocess(
self,
note_duration: list[float],
phonemes: list[str],
note_pitch: list[int],
note_type: list[int],
):
"""
Insert <BOW> and <EOW> for each note.
Get aligned indices for each frame.
Args:
note_duration: Duration of each note in seconds
phonemes: Phoneme sequence for each note
note_pitch: Pitch value for each note
note_type: Type value for each note
"""
sample_rate = self.sample_rate
hop_size = self.hop_size
duration = sum(note_duration) * sample_rate / hop_size
mel2note = torch.zeros(int(duration), dtype=torch.long, device=self.device)
ph_locations = [] # idx at mel scale and length
new_phonemes = []
dur_sum = 0
note2origin = []
for ph_idx in range(len(phonemes)):
dur = int(np.round(dur_sum * sample_rate / hop_size))
dur = min(dur, len(mel2note) - 1)
new_phonemes.append("<BOW>")
note2origin.append(ph_idx)
if phonemes[ph_idx][:3] == "en_":
en_phs = ["en_" + x for x in phonemes[ph_idx][3:].split("-")] + [
"<SEP>"
] # <sep> between en words in one note
ph_locations.append([dur, max(1, len(en_phs))])
new_phonemes.extend(en_phs)
note2origin.extend([ph_idx] * len(en_phs))
else:
ph_locations.append([dur, 1])
new_phonemes.append(phonemes[ph_idx])
note2origin.append(ph_idx)
new_phonemes.append("<EOW>")
note2origin.append(ph_idx)
dur_sum += note_duration[ph_idx]
ph_idx = 1
for idx, (i, j) in enumerate(ph_locations):
next_phoneme_start = ph_locations[idx + 1][0] if idx < len(ph_locations) - 1 else len(mel2note)
if i >= len(mel2note) or i + j > len(mel2note):
break
if i < len(mel2note) and mel2note[i] > 0:
logger.warning(f"warning: overlap of {idx}: {mel2note[i]}")
while i < len(mel2note) and mel2note[i] > 0:
i += 1
mel2note[i] = ph_idx
k = i + 1
while k + j < next_phoneme_start:
mel2note[k : k + j] = torch.arange(ph_idx, ph_idx + j, device=self.device) + 1
k += j
mel2note[next_phoneme_start - 1] = ph_idx + j + 1
ph_idx += j + 2 # <BOW> + ph repeats + <EOW>
new_phonemes = ["<PAD>"] + new_phonemes
new_note_pitch = [0] + [note_pitch[k] for k in note2origin]
new_note_type = [1] + [note_type[k] for k in note2origin]
return {
"phoneme": torch.tensor([self.phone2idx[x] for x in new_phonemes], device=self.device).unsqueeze(0),
"note_pitch": torch.tensor(new_note_pitch, device=self.device).unsqueeze(0),
"note_type": torch.tensor(new_note_type, device=self.device).unsqueeze(0),
"mel2note": mel2note.unsqueeze(0),
}
def process(self, meta: dict, wav_path: str | None = None) -> dict[str, torch.Tensor | None]:
meta = self.merge_phoneme(meta)
item = self.preprocess(
meta["duration"],
meta["phoneme"],
meta["note_pitch"],
meta["note_type"],
)
f0 = [float(x) for x in meta.get("f0", "").split()]
min_frame = min(item["mel2note"].shape[1], len(f0)) if len(f0) > 0 else item["mel2note"].shape[1]
item["f0"] = torch.tensor(f0, device=self.device)[:min_frame].unsqueeze(0).float() if len(f0) > 0 else None
item["mel2note"] = item["mel2note"][:, :min_frame]
if wav_path is not None:
waveform = load_wav(wav_path, self.sample_rate)
item["wav"] = waveform.to(self.device)[:, : min_frame * self.hop_size]
return item
def build_vocal_segments(
f0,
*,
f0_rate: int = 50,
ignore_silent_frames_thresh: int = 5,
min_duration_sec_per_segment: float = 5.0,
max_duration_sec_per_segment: float = 30.0,
num_overlaps: int = 1,
ignore_silent_frames: bool = True,
) -> tuple[list[tuple[float, float]], list[tuple[float, float]]]:
"""Build vocal segments from an F0 contour for chunked SVC inference."""
if isinstance(f0, torch.Tensor):
f0_np = f0.detach().float().cpu().numpy()
else:
f0_np = np.asarray(f0, dtype=np.float32)
f0_np = np.squeeze(f0_np)
total_frames = int(f0_np.shape[0])
if total_frames == 0:
return [], []
min_frames = max(1, int(round(min_duration_sec_per_segment * f0_rate)))
max_frames = max(1, int(round(max_duration_sec_per_segment * f0_rate)))
split_points = [0]
def append_split_point(point: int):
point = int(max(0, min(point, total_frames)))
while point - split_points[-1] > max_frames:
split_points.append(split_points[-1] + max_frames)
if point > split_points[-1]:
split_points.append(point)
idx = 0
while idx < total_frames:
if f0_np[idx] == 0:
run_start = idx
while idx < total_frames and f0_np[idx] == 0:
idx += 1
run_end = idx
if (run_end - run_start) >= ignore_silent_frames_thresh:
split_point = max(run_end - 5, (run_start + run_end) // 2)
append_split_point(split_point)
else:
idx += 1
append_split_point(total_frames)
segments: list[tuple[float, float]] = []
overlap_segments: list[tuple[float, float]] = []
def append_segment(start_idx: int, end_idx: int, overlaps: int = num_overlaps):
segments.append((split_points[start_idx] / f0_rate, split_points[end_idx] / f0_rate))
overlap_start_idx = start_idx
if start_idx > 0 and (split_points[end_idx] - split_points[start_idx - overlaps]) <= max_frames:
overlap_start_idx = start_idx - overlaps
overlap_segments.append((split_points[overlap_start_idx] / f0_rate, split_points[end_idx] / f0_rate))
segment_start, segment_end = 0, 1
while segment_start < len(split_points) - 1:
while (
segment_end < len(split_points) and (split_points[segment_end] - split_points[segment_start]) < min_frames
):
segment_end += 1
if segment_end >= len(split_points):
append_segment(segment_start, len(split_points) - 1, overlaps=num_overlaps)
break
append_segment(segment_start, segment_end, overlaps=num_overlaps)
segment_start = segment_end
segment_end = segment_start + 1
if ignore_silent_frames:
filtered_idx = []
for i, seg in enumerate(overlap_segments):
start_frame = int(seg[0] * f0_rate)
end_frame = int(seg[1] * f0_rate)
seg_frames = end_frame - start_frame
voice_frames = np.sum(f0_np[start_frame:end_frame] > 0)
if voice_frames / seg_frames > 0.05 and voice_frames >= 10:
filtered_idx.append(i)
overlap_segments = [overlap_segments[i] for i in filtered_idx]
segments = [segments[i] for i in filtered_idx]
return overlap_segments, segments
-14
View File
@@ -316,16 +316,6 @@ _DIFFUSION_MODELS = {
"pipeline_cosmos3",
"Cosmos3OmniDiffusersPipeline",
),
"SoulXSingerPipeline": (
"soulx_singer",
"pipeline_soulx_singer_svs",
"PipelineSoulXSingerSVS",
),
"SoulXSingerSVCPipeline": (
"soulx_singer",
"pipeline_soulx_singer_svc",
"PipelineSoulXSingerSVC",
),
"DiffusersAdapterPipeline": (
"diffusers_adapter",
"pipeline_diffusers_adapter",
@@ -566,8 +556,6 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
"MiniMaxH3Pipeline": "get_minimax_h3_post_process_func",
"MiniMaxH3ModularPipeline": "get_minimax_h3_post_process_func",
"StableAudioPipeline": "get_stable_audio_post_process_func",
"SoulXSingerPipeline": "get_soulxsinger_post_process_func",
"SoulXSingerSVCPipeline": "get_soulxsinger_post_process_func",
"AudioXPipeline": "get_audiox_post_process_func",
"WanImageToVideoPipeline": "get_wan22_i2v_post_process_func",
"WanS2VPipeline": "get_wan22_s2v_post_process_func",
@@ -647,8 +635,6 @@ _DIFFUSION_PRE_PROCESS_FUNCS = {
"SanaWmPipeline": "get_sana_wm_pre_process_func",
"Cosmos3OmniDiffusersPipeline": "get_cosmos3_pre_process_func",
"Cosmos3OmniPipeline": "get_cosmos3_pre_process_func",
"SoulXSingerPipeline": "get_soulxsinger_pre_process_func",
"SoulXSingerSVCPipeline": "get_soulxsinger_svc_pre_process_func",
}
@@ -1,5 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Keep this package lightweight. The pipeline registry imports pipeline.py
# directly, while diffusion model classes are loaded lazily from their registry.
@@ -1,45 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""SoulX-Singer pipeline topologies."""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
SOULXSINGER_SVS_PIPELINE = PipelineConfig(
model_type="soulxsinger_svs",
default_deploy_config_name="soulxsinger_svs.yaml",
model_arch="SoulXSingerPipeline",
hf_architectures=("SoulXSingerPipeline",),
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="dit",
execution_type=StageExecutionType.DIFFUSION,
input_sources=(),
final_output=True,
final_output_type="audio",
model_arch="SoulXSingerPipeline",
),
),
)
SOULXSINGER_SVC_PIPELINE = PipelineConfig(
model_type="soulxsinger_svc",
default_deploy_config_name="soulxsinger_svc.yaml",
model_arch="SoulXSingerSVCPipeline",
hf_architectures=("SoulXSingerSVCPipeline",),
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="dit",
execution_type=StageExecutionType.DIFFUSION,
input_sources=(),
final_output=True,
final_output_type="audio",
model_arch="SoulXSingerSVCPipeline",
),
),
)