File size: 17,278 Bytes
a3003cb
76df492
 
 
 
 
a3003cb
 
76df492
 
a3003cb
76df492
 
 
 
 
a3003cb
 
 
 
 
 
 
 
 
 
76df492
 
 
 
a3003cb
4ca16ac
 
76df492
 
a3003cb
 
76df492
 
4ca16ac
 
 
 
 
76df492
a3003cb
76df492
 
 
 
 
 
 
 
a3003cb
76df492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3003cb
76df492
 
 
 
 
 
a3003cb
76df492
2434d88
4ca16ac
 
 
 
 
 
 
 
 
 
 
76df492
4ca16ac
76df492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ca16ac
 
76df492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3003cb
 
76df492
 
 
a3003cb
76df492
 
a3003cb
76df492
 
a3003cb
 
76df492
4ca16ac
 
 
 
 
76df492
 
 
 
a3003cb
4ca16ac
 
76df492
 
 
 
4ca16ac
76df492
 
 
a3003cb
76df492
 
a3003cb
76df492
 
 
a3003cb
 
 
 
76df492
 
a3003cb
 
 
76df492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3003cb
76df492
 
 
a3003cb
 
76df492
 
a3003cb
76df492
 
 
 
 
a3003cb
76df492
a3003cb
76df492
 
 
 
 
 
 
 
a3003cb
 
 
 
 
 
 
76df492
a3003cb
 
 
 
 
76df492
a3003cb
76df492
 
 
 
 
a3003cb
 
 
76df492
 
 
 
 
a3003cb
76df492
 
 
a3003cb
 
76df492
 
 
 
 
 
 
 
 
 
 
 
 
 
a3003cb
 
76df492
 
a3003cb
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
"""
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()