Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 | |
| 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) | |
| 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() | |