| """ |
| Pure numpy + onnxruntime reference implementation for the HT-Demucs FT |
| vocals specialist. NO TORCH at inference. |
| |
| Usage: |
| python infer.py input.mp3 out_dir/ |
| # writes out_dir/vocals.wav |
| |
| Or as a library: |
| import infer |
| vocals = infer.separate_vocals("song.mp3") |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import numpy as np |
| import onnxruntime as ort |
| import soundfile as sf |
|
|
| SAMPLE_RATE = 44100 |
| SEGMENT_S = 7.8 |
| N_SAMPLES = int(SEGMENT_S * SAMPLE_RATE) |
| N_CHANNELS = 2 |
| SOURCES = ["drums", "bass", "other", "vocals"] |
| SPECIALIST_STEM = "vocals" |
| DEFAULT_ONNX = Path(__file__).resolve().parent / "htdemucs_ft_vocals.onnx" |
|
|
|
|
| def _make_transition_window(segment: int, overlap_frac: float = 0.25) -> np.ndarray: |
| transition = int(segment * overlap_frac) |
| window = np.ones(segment, dtype=np.float32) |
| fade = np.linspace(0, 1, transition, dtype=np.float32) |
| window[:transition] = fade |
| window[-transition:] = fade[::-1] |
| return window |
|
|
|
|
| def separate(mix: np.ndarray, sample_rate: int, |
| onnx_path: Path = DEFAULT_ONNX, |
| providers: list[str] | None = None, |
| verbose: bool = True) -> np.ndarray: |
| """Run chunked overlap-add separation on a full-length mix. |
| Returns: (n_sources, channels, samples). Only the row at |
| SOURCES.index(SPECIALIST_STEM) is meaningfully predicted. |
| """ |
| if sample_rate != SAMPLE_RATE: |
| raise ValueError(f"Bound to {SAMPLE_RATE} Hz; got {sample_rate}.") |
| if mix.ndim != 2 or mix.shape[0] != N_CHANNELS: |
| raise ValueError(f"Expected (2, samples) input, got {mix.shape}") |
|
|
| if providers is None: |
| providers = ["CPUExecutionProvider"] |
| sess = ort.InferenceSession(str(onnx_path), providers=providers) |
|
|
| total_len = mix.shape[1] |
| overlap = N_SAMPLES // 4 |
| stride = N_SAMPLES - overlap |
| n_chunks = max(1, (total_len + stride - 1) // stride) |
|
|
| if verbose: |
| print(f" input: {total_len:,} samples ({total_len / sample_rate:.1f}s)") |
| print(f" segment: {N_SAMPLES:,} samples ({SEGMENT_S}s)") |
| print(f" chunks: {n_chunks}, provider {sess.get_providers()[0]}") |
|
|
| window = _make_transition_window(N_SAMPLES) |
| out = np.zeros((len(SOURCES), N_CHANNELS, total_len), dtype=np.float32) |
| weight = np.zeros(total_len, dtype=np.float32) |
|
|
| t0 = time.perf_counter() |
| for i in range(n_chunks): |
| start = i * stride |
| end = min(start + N_SAMPLES, total_len) |
| chunk = mix[:, start:end] |
| if chunk.shape[1] < N_SAMPLES: |
| chunk = np.pad(chunk, ((0, 0), (0, N_SAMPLES - chunk.shape[1])), |
| mode="constant") |
| x = chunk[np.newaxis, ...].astype(np.float32) |
| stems = sess.run(["stems"], {"mix": x})[0][0] |
| chunk_len = end - start |
| w = window[:chunk_len] |
| out[:, :, start:end] += stems[:, :, :chunk_len] * w |
| weight[start:end] += w |
| if verbose: |
| print(f" chunk {i+1}/{n_chunks}: " |
| f"{time.perf_counter() - t0:.1f}s elapsed") |
|
|
| weight = np.maximum(weight, 1e-8) |
| out /= weight |
| if verbose: |
| rtf = (time.perf_counter() - t0) / (total_len / sample_rate) |
| print(f" total: {time.perf_counter() - t0:.2f}s (RTF {rtf:.2f})") |
| return out |
|
|
|
|
| def separate_vocals(input_path: str, onnx_path: Path = DEFAULT_ONNX, |
| providers: list[str] | None = None) -> np.ndarray: |
| """Convenience: load audio, separate, return only the vocals stem.""" |
| audio, sr = sf.read(input_path, dtype="float32", always_2d=True) |
| audio = audio.T |
| if audio.shape[0] == 1: |
| audio = np.tile(audio, (2, 1)) |
| elif audio.shape[0] > 2: |
| audio = audio[:2] |
| stems = separate(audio, sr, onnx_path=onnx_path, providers=providers) |
| return stems[SOURCES.index(SPECIALIST_STEM)] |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser(description=__doc__) |
| ap.add_argument("input", type=Path) |
| ap.add_argument("out_dir", type=Path) |
| ap.add_argument("--onnx", type=Path, default=DEFAULT_ONNX) |
| ap.add_argument("--providers", type=str, default="cpu", |
| choices=["cpu", "coreml", "cuda", "dml"]) |
| ap.add_argument("--write-all-stems", action="store_true", |
| help="Also write the (low-quality) by-product stems.") |
| args = ap.parse_args() |
|
|
| providers_map = { |
| "cpu": ["CPUExecutionProvider"], |
| "coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"], |
| "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"], |
| "dml": ["DmlExecutionProvider", "CPUExecutionProvider"], |
| } |
| args.out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| audio, sr = sf.read(str(args.input), dtype="float32", always_2d=True) |
| audio = audio.T |
| if audio.shape[0] == 1: |
| audio = np.tile(audio, (2, 1)) |
| elif audio.shape[0] > 2: |
| audio = audio[:2] |
|
|
| stems = separate(audio, sr, onnx_path=args.onnx, |
| providers=providers_map[args.providers]) |
| if args.write_all_stems: |
| for i, src in enumerate(SOURCES): |
| sf.write(str(args.out_dir / f"{src}.wav"), stems[i].T, sr) |
| else: |
| target = stems[SOURCES.index(SPECIALIST_STEM)] |
| sf.write(str(args.out_dir / f"{SPECIALIST_STEM}.wav"), target.T, sr) |
| print(f" wrote {args.out_dir / f'{SPECIALIST_STEM}.wav'}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|