omnivoice_gpu / app.py
Pushkar02-n's picture
Fix BigVGAN load against current huggingface_hub, plus audio-loading drift
4ca16ac
Raw
History Blame Contribute Delete
17.3 kB
"""
Nepali Song Voice Swap — Seed-VC on ZeroGPU — Hugging Face Space
==================================================================
Pipeline: song -> Demucs (separate vocals/instrumental) -> Seed-VC SVC
(convert vocals to your voice, F0-conditioned) -> remix over instrumental.
ZeroGPU notes:
- All models must be moved to CUDA at module level, not inside the
decorated function. Lazy loading into @spaces.GPU is much slower.
- Quota is charged against the *visitor's* account by declared duration,
so duration is computed per request from source-audio length.
Code from https://github.com/Plachtaa/seed-vc (GPL-3.0) is vendored under
./modules and ./hf_utils.py, unmodified, so upstream fixes can be dropped
in by re-copying those files.
"""
import os
import time
import tempfile
import spaces # must be imported before torch
import gradio as gr
import numpy as np
import torch
import torchaudio
import librosa
import soundfile as sf
import yaml
from huggingface_hub import hf_hub_download
from modules.commons import build_model, load_checkpoint, recursive_munch
from hf_utils import load_custom_model_from_hf
torch.set_grad_enabled(False)
device = torch.device("cuda")
# ZeroGPU kills the function when the declared duration elapses, so a song
# long enough to overrun the declared estimate is worse than a rejected
# upload. Free-tier daily quota is ~300s of GPU total, which a 2-minute
# song at 25 steps already approaches. Raise via env var if you have quota.
MAX_SONG_SECONDS = float(os.environ.get("MAX_SONG_SECONDS", "120"))
MAX_REF_SECONDS = 25.0 # Seed-VC hard-clips reference audio beyond this
# --------------------------------------------------------------------------
# Load Seed-VC (singing, 44.1kHz, F0-conditioned) at module level.
# --------------------------------------------------------------------------
dit_checkpoint_path, dit_config_path = load_custom_model_from_hf(
"Plachta/Seed-VC",
"DiT_seed_v2_uvit_whisper_base_f0_44k_bigvgan_pruned_ft_ema_v2.pth",
"config_dit_mel_seed_uvit_whisper_base_f0_44k.yml",
)
_config = yaml.safe_load(open(dit_config_path, "r"))
_model_params = recursive_munch(_config["model_params"])
_model_params.dit_type = "DiT"
svc_model = build_model(_model_params, stage="DiT")
sr = _config["preprocess_params"]["sr"]
hop_length = _config["preprocess_params"]["spect_params"]["hop_length"]
svc_model, _, _, _ = load_checkpoint(
svc_model, None, dit_checkpoint_path,
load_only_params=True, ignore_modules=[], is_distributed=False,
)
for key in svc_model:
svc_model[key].eval().to(device)
svc_model.cfm.estimator.setup_caches(max_batch_size=1, max_seq_length=8192)
from modules.campplus.DTDNN import CAMPPlus
campplus_ckpt_path = load_custom_model_from_hf(
"funasr/campplus", "campplus_cn_common.bin", config_filename=None
)
campplus_model = CAMPPlus(feat_dim=80, embedding_size=192)
campplus_model.load_state_dict(torch.load(campplus_ckpt_path, map_location="cpu"))
campplus_model.eval().to(device)
from modules.bigvgan import bigvgan
# BigVGAN.from_pretrained() is not usable with current huggingface_hub:
# the vendored PyTorchModelHubMixin subclass declares `proxies` and
# `resume_download` as *required* keyword-only args of _from_pretrained,
# but hub_mixin stopped passing either one. Downloading the two files and
# constructing the model directly is what from_pretrained does anyway, and
# it keeps the vendored file untouched.
_bigvgan_name = _model_params.vocoder.name
_bigvgan_config = hf_hub_download(repo_id=_bigvgan_name, filename="config.json")
_bigvgan_ckpt = hf_hub_download(repo_id=_bigvgan_name, filename="bigvgan_generator.pt")
bigvgan_model = bigvgan.BigVGAN(
bigvgan.load_hparams_from_json(_bigvgan_config), use_cuda_kernel=False
)
bigvgan_model.load_state_dict(torch.load(_bigvgan_ckpt, map_location="cpu")["generator"])
bigvgan_model.remove_weight_norm()
bigvgan_model = bigvgan_model.eval().to(device)
from transformers import AutoFeatureExtractor, WhisperModel
_whisper_name = _model_params.speech_tokenizer.name
whisper_model = WhisperModel.from_pretrained(
_whisper_name, torch_dtype=torch.float16
).to(device)
del whisper_model.decoder
whisper_feature_extractor = AutoFeatureExtractor.from_pretrained(_whisper_name)
from modules.audio import mel_spectrogram
_mel_fn_args = {
"n_fft": _config["preprocess_params"]["spect_params"]["n_fft"],
"win_size": _config["preprocess_params"]["spect_params"]["win_length"],
"hop_size": hop_length,
"num_mels": _config["preprocess_params"]["spect_params"]["n_mels"],
"sampling_rate": sr,
"fmin": _config["preprocess_params"]["spect_params"].get("fmin", 0),
# config stores fmax as the string "None"; upstream maps that to None
"fmax": None if _config["preprocess_params"]["spect_params"].get("fmax", "None") == "None" else 8000,
"center": False,
}
to_mel = lambda x: mel_spectrogram(x, **_mel_fn_args)
from modules.rmvpe import RMVPE
rmvpe_path = load_custom_model_from_hf("lj1995/VoiceConversionWebUI", "rmvpe.pt", None)
rmvpe = RMVPE(rmvpe_path, is_half=False, device=device)
max_context_window = sr // hop_length * 30
overlap_frame_len = 16
overlap_wave_len = overlap_frame_len * hop_length
def semantic_fn(waves_16k):
inputs = whisper_feature_extractor(
[waves_16k.squeeze(0).cpu().numpy()],
return_tensors="pt", return_attention_mask=True,
)
input_features = whisper_model._mask_input_features(
inputs.input_features, attention_mask=inputs.attention_mask
).to(device)
outputs = whisper_model.encoder(
input_features.to(whisper_model.encoder.dtype),
head_mask=None, output_attentions=False,
output_hidden_states=False, return_dict=True,
)
hidden = outputs.last_hidden_state.to(torch.float32)
return hidden[:, : waves_16k.size(-1) // 320 + 1]
# --------------------------------------------------------------------------
# Load Demucs (vocal / instrumental separation) at module level.
# --------------------------------------------------------------------------
from demucs.pretrained import get_model as demucs_get_model
from demucs.apply import apply_model as demucs_apply_model
demucs_model = demucs_get_model("htdemucs")
demucs_model.eval().to(device)
def separate_vocals(song_path):
# librosa (soundfile/audioread) rather than torchaudio.load, which is
# deprecated in torchaudio 2.8 and whose mp3 support depends on which
# backend the image happens to dispatch to.
wav, _ = librosa.load(song_path, sr=demucs_model.samplerate, mono=False)
wav = torch.from_numpy(np.atleast_2d(wav)).float()
if wav.shape[0] == 1:
wav = wav.repeat(2, 1)
elif wav.shape[0] > 2:
wav = wav[:2]
# Mirrors demucs.api.Separator.separate_tensor: the mix stays on CPU and
# apply_model moves each chunk to `device` itself.
ref = wav.mean(0)
mean, std = ref.mean(), ref.std() + 1e-8
with torch.no_grad():
sources = demucs_apply_model(
demucs_model, ((wav - mean) / std)[None],
shifts=0, split=True, overlap=0.25, device=device,
)[0]
sources = sources * std + mean
vocals = sources[demucs_model.sources.index("vocals")]
instrumental = sources.sum(0) - vocals
return (
vocals.mean(0).cpu().numpy(),
instrumental.mean(0).cpu().numpy(),
demucs_model.samplerate,
)
# --------------------------------------------------------------------------
# Seed-VC singing voice conversion — adapted from seed-vc's app_svc.py,
# collapsed from a streaming generator to a single return value.
# --------------------------------------------------------------------------
def adjust_f0_semitones(f0, n_semitones):
return f0 * (2 ** (n_semitones / 12))
def crossfade(chunk1, chunk2, overlap):
fade_out = np.cos(np.linspace(0, np.pi / 2, overlap)) ** 2
fade_in = np.cos(np.linspace(np.pi / 2, 0, overlap)) ** 2
chunk2[:overlap] = chunk2[:overlap] * fade_in + chunk1[-overlap:] * fade_out
return chunk2
@torch.no_grad()
def convert_vocals(source_audio_np, source_sr, ref_path, diffusion_steps,
pitch_shift, auto_f0_adjust=True, length_adjust=1.0,
inference_cfg_rate=0.7):
source_audio = librosa.resample(source_audio_np, orig_sr=source_sr, target_sr=sr) \
if source_sr != sr else source_audio_np
ref_audio = librosa.load(ref_path, sr=sr)[0]
source_audio = torch.tensor(source_audio).unsqueeze(0).float().to(device)
ref_audio = torch.tensor(ref_audio[: sr * int(MAX_REF_SECONDS)]).unsqueeze(0).float().to(device)
ref_waves_16k = torchaudio.functional.resample(ref_audio, sr, 16000)
converted_waves_16k = torchaudio.functional.resample(source_audio, sr, 16000)
if converted_waves_16k.size(-1) <= 16000 * 30:
S_alt = semantic_fn(converted_waves_16k)
else:
overlapping_time = 5
S_alt_list = []
buffer = None
traversed_time = 0
while traversed_time < converted_waves_16k.size(-1):
if buffer is None:
chunk = converted_waves_16k[:, traversed_time: traversed_time + 16000 * 30]
else:
chunk = torch.cat([
buffer,
converted_waves_16k[:, traversed_time: traversed_time + 16000 * (30 - overlapping_time)],
], dim=-1)
S_chunk = semantic_fn(chunk)
S_alt_list.append(S_chunk if traversed_time == 0 else S_chunk[:, 50 * overlapping_time:])
buffer = chunk[:, -16000 * overlapping_time:]
traversed_time += 30 * 16000 if traversed_time == 0 else chunk.size(-1) - 16000 * overlapping_time
S_alt = torch.cat(S_alt_list, dim=1)
S_ori = semantic_fn(ref_waves_16k)
mel = to_mel(source_audio)
mel2 = to_mel(ref_audio)
target_lengths = torch.LongTensor([int(mel.size(2) * length_adjust)]).to(device)
target2_lengths = torch.LongTensor([mel2.size(2)]).to(device)
feat2 = torchaudio.compliance.kaldi.fbank(
ref_waves_16k, num_mel_bins=80, dither=0, sample_frequency=16000
)
feat2 = feat2 - feat2.mean(dim=0, keepdim=True)
style2 = campplus_model(feat2.unsqueeze(0))
F0_ori = rmvpe.infer_from_audio(ref_waves_16k[0], thred=0.03)
F0_alt = rmvpe.infer_from_audio(converted_waves_16k[0], thred=0.03)
F0_ori = torch.from_numpy(F0_ori).to(device)[None]
F0_alt = torch.from_numpy(F0_alt).to(device)[None]
voiced_F0_ori = F0_ori[F0_ori > 1]
voiced_F0_alt = F0_alt[F0_alt > 1]
log_f0_alt = torch.log(F0_alt + 1e-5)
median_log_f0_ori = torch.median(torch.log(voiced_F0_ori + 1e-5))
median_log_f0_alt = torch.median(torch.log(voiced_F0_alt + 1e-5))
shifted_log_f0_alt = log_f0_alt.clone()
if auto_f0_adjust:
shifted_log_f0_alt[F0_alt > 1] = log_f0_alt[F0_alt > 1] - median_log_f0_alt + median_log_f0_ori
shifted_f0_alt = torch.exp(shifted_log_f0_alt)
if pitch_shift != 0:
shifted_f0_alt[F0_alt > 1] = adjust_f0_semitones(shifted_f0_alt[F0_alt > 1], pitch_shift)
cond, *_ = svc_model.length_regulator(S_alt, ylens=target_lengths, n_quantizers=3, f0=shifted_f0_alt)
prompt_condition, *_ = svc_model.length_regulator(S_ori, ylens=target2_lengths, n_quantizers=3, f0=F0_ori)
max_source_window = max_context_window - mel2.size(2)
processed_frames = 0
chunks = []
previous_chunk = None
while processed_frames < cond.size(1):
chunk_cond = cond[:, processed_frames: processed_frames + max_source_window]
is_last_chunk = processed_frames + max_source_window >= cond.size(1)
cat_condition = torch.cat([prompt_condition, chunk_cond], dim=1)
with torch.autocast(device_type="cuda", dtype=torch.float16):
vc_target = svc_model.cfm.inference(
cat_condition,
torch.LongTensor([cat_condition.size(1)]).to(device),
mel2, style2, None, diffusion_steps,
inference_cfg_rate=inference_cfg_rate,
)
vc_target = vc_target[:, :, mel2.size(-1):]
vc_wave = bigvgan_model(vc_target.float()).squeeze().cpu()
if vc_wave.ndim == 1:
vc_wave = vc_wave.unsqueeze(0)
if previous_chunk is None:
if is_last_chunk:
chunks.append(vc_wave[0].numpy())
break
chunks.append(vc_wave[0, :-overlap_wave_len].numpy())
previous_chunk = vc_wave[0, -overlap_wave_len:]
processed_frames += vc_target.size(2) - overlap_frame_len
elif is_last_chunk:
chunks.append(crossfade(previous_chunk.numpy(), vc_wave[0].numpy(), overlap_wave_len))
break
else:
chunks.append(crossfade(previous_chunk.numpy(), vc_wave[0, :-overlap_wave_len].numpy(), overlap_wave_len))
previous_chunk = vc_wave[0, -overlap_wave_len:]
processed_frames += vc_target.size(2) - overlap_frame_len
return np.concatenate(chunks), sr
def remix(vocals, instrumental, sr_a, sr_b):
if sr_a != sr_b:
instrumental = librosa.resample(instrumental, orig_sr=sr_b, target_sr=sr_a)
n = min(len(vocals), len(instrumental))
mix = vocals[:n] + instrumental[:n]
peak = np.abs(mix).max()
if peak > 1.0:
mix = mix / peak
return mix
def _check_song(path):
if not path:
raise gr.Error("Upload the Nepali reference song first.")
duration = librosa.get_duration(path=path)
if duration > MAX_SONG_SECONDS:
raise gr.Error(f"Song is {duration:.0f}s. Trim it to {MAX_SONG_SECONDS:.0f}s or less.")
return path
def _check_ref(path):
if not path:
raise gr.Error("Upload your own voice clip first (5-15s, clean speech or singing).")
return path
def estimate_duration(song_path, ref_path, diffusion_steps, pitch_shift):
if not song_path:
return 30
seconds = librosa.get_duration(path=song_path)
return min(int(15 + seconds * 1.3 * (int(diffusion_steps) / 25.0)), 300)
@spaces.GPU(duration=estimate_duration)
def swap_voice(song_path, ref_path, diffusion_steps, pitch_shift):
song_path = _check_song(song_path)
ref_path = _check_ref(ref_path)
t0 = time.time()
vocals_np, instrumental_np, demucs_sr = separate_vocals(song_path)
t1 = time.time()
converted, out_sr = convert_vocals(
vocals_np, demucs_sr, ref_path,
diffusion_steps=int(diffusion_steps), pitch_shift=float(pitch_shift),
)
t2 = time.time()
mixed = remix(converted, instrumental_np, out_sr, demucs_sr)
out_path = tempfile.mktemp(suffix=".wav")
sf.write(out_path, mixed, out_sr)
status = (
f"Separated vocals in {t1 - t0:.1f}s, converted voice in {t2 - t1:.1f}s "
f"({diffusion_steps} diffusion steps). Total {time.time() - t0:.1f}s."
)
return out_path, status
# --------------------------------------------------------------------------
# Interface
# --------------------------------------------------------------------------
CSS = """
.gradio-container { max-width: 900px !important; }
#header h1 { margin-bottom: 0.15rem; font-weight: 650; letter-spacing: -0.01em; }
#header p { margin-top: 0; opacity: 0.72; }
footer { visibility: hidden; }
"""
with gr.Blocks(title="Nepali Song Voice Swap", css=CSS, theme=gr.themes.Soft()) as demo:
gr.Markdown(
"# Nepali Song Voice Swap\n"
"Upload a Nepali reference song and a clip of your own voice. "
"The song's vocals are separated, re-sung in your voice, and remixed "
"back over the original instrumental. Runs on ZeroGPU — GPU time is "
"drawn from your own Hugging Face daily quota.",
elem_id="header",
)
with gr.Row():
with gr.Column(scale=3):
song_in = gr.Audio(
label="Nepali reference song (full mix, up to 4 min)",
type="filepath", sources=["upload"],
)
ref_in = gr.Audio(
label="Your voice (5-15s, clean, upload or record)",
type="filepath", sources=["upload", "microphone"],
)
with gr.Row():
steps = gr.Slider(
1, 100, value=25, step=1, label="Diffusion steps",
info="25 is a good default; 50 for best quality on singing.",
)
pitch = gr.Slider(
-24, 24, value=0, step=1, label="Pitch shift (semitones)",
info="Use if your voice's natural range differs a lot from the song.",
)
btn = gr.Button("Swap voice", variant="primary")
with gr.Column(scale=2):
out_audio = gr.Audio(label="Result", type="filepath")
status = gr.Textbox(label="Timing", lines=3, interactive=False)
btn.click(swap_voice, inputs=[song_in, ref_in, steps, pitch], outputs=[out_audio, status])
gr.Markdown(
"Built on [Seed-VC](https://github.com/Plachtaa/seed-vc) (GPL-3.0) and "
"[Demucs](https://github.com/adefossez/demucs) (MIT) for source separation.\n\n"
"Do not clone anyone's voice without their permission."
)
if __name__ == "__main__":
demo.queue(max_size=20).launch()