본문 바로가기

서비스 제작

[RVC 코드 분석] 노래에서 가수 목소리 음원과 MR 음원으로 분리

화자의 목소리를 담은 노래 커버를 제작하는 ai 서비스를 개발중에 있다. 이를 위해 RVC(Retrieval-based Voice Conversion)라는 AI 음성 합성 기술을 사용한다. RVC에서 많이 사용하는 깃헙 레포지토리는 https://github.com/RVC-Project/Retrieval-based-Voice-Conversion-WebUI/tree/main 이다. 하지만 레포지토리 이름에서 보이는 대로 이는 그라디오로 만들어진 웹 상에서 작동하도록 설계된 레포지토리이다. 서비스 제작을 위해서는 코드를 분석하여 api로 만들 수 있도록 코드를 수정해야한다. 이 포스트에서는 서비스를 위한 AI 기능 구현을 위한 코드 분석 과정을 담는다.


서비스 상에서 구현되어야하는 AI 기능은 아래와 같다.


  • 노래에서 가수 목소리 음원과 MR 음원으로 분리
  • 화자의 목소리를 받아서 화자 목소리 모델을 생성
  • 화자 목소리 모델과 가수 목소리 음원을 합성하여 화자 목소리로 부른 음원을 생성

RVC 깃헙 코드상에서 이 기능들이 모두 구현되어 있다. 포스트 당 하나씩 살펴보자. 이번 포스팅에는 “노래에서 가수 목소리 음원과 MR 음원으로 분리”에 대해 알아본다.


노래에서 가수 목소리 음원과 MR 음원으로 분리

이 기능을 수행하는 RVC 웹의 스크린샷은 아래와 같다.



각 빨간색 네모 영역은 다음을 의미한다.


스크린샷 번호 의미
1, 2 음원 파일의 경로를 입력하거나 음원 파일을 업로드
3 음원 분리를 위한 AI 모델 설정
4 분리된 가수 목소리 음원을 저장할 폴더 경로
5 분리된 MR 음원을 저장할 폴더 경로
6 분리된 음원의 저장 파일 형식
7 변환 버튼

이제 이를 코드에서 찾아보자. 위와 같이 WEB을 표현하는 gradio가 담겨있는 파일은 infer-web.py이다. 음원을 분리하는 부분의 코드는 아래와 같다. 가장 하단의 버튼 클릭하는 부분에 대해 설명해보면 but2 버튼을 클릭하면 uvr 함수에 model_choose와 같은 파라미터들이 들어가서 실행된다. 그 결과물이 vc_output4 변수명 안에 담기게된다.


with gr.TabItem(i18n("伴奏人声分离&去混响&去回声")):
      with gr.Group():
          gr.Markdown(
              value=i18n(
                  "人声伴奏分离批量处理, 使用UVR5模型。 <br>合格的文件夹路径格式举例: E:\\codes\\py39\\vits_vc_gpu\\白鹭霜华测试样例(去文件管理器地址栏拷就行了)。 <br>模型分为三类: <br>1、保留人声:不带和声的音频选这个,对主人声保留比HP5更好。内置HP2和HP3两个模型,HP3可能轻微漏伴奏但对主人声保留比HP2稍微好一丁点; <br>2、仅保留主人声:带和声的音频选这个,对主人声可能有削弱。内置HP5一个模型; <br> 3、去混响、去延迟模型(by FoxJoy):<br>  (1)MDX-Net(onnx_dereverb):对于双通道混响是最好的选择,不能去除单通道混响;<br>&emsp;(234)DeEcho:去除延迟效果。Aggressive比Normal去除得更彻底,DeReverb额外去除混响,可去除单声道混响,但是对高频重的板式混响去不干净。<br>去混响/去延迟,附:<br>1、DeEcho-DeReverb模型的耗时是另外2个DeEcho模型的接近2倍;<br>2、MDX-Net-Dereverb模型挺慢的;<br>3、个人推荐的最干净的配置是先MDX-Net再DeEcho-Aggressive。"
              )
          )
          with gr.Row():
              with gr.Column():
                  dir_wav_input = gr.Textbox(
                      label=i18n("输入待处理音频文件夹路径"),
                      placeholder="C:\\Users\\Desktop\\todo-songs",
                  )
                  wav_inputs = gr.File(
                      file_count="multiple",
                      label=i18n("也可批量输入音频文件, 二选一, 优先读文件夹"),
                  )
              with gr.Column():
                  model_choose = gr.Dropdown(
                      label=i18n("模型"), choices=uvr5_names
                  )
                  agg = gr.Slider(
                      minimum=0,
                      maximum=20,
                      step=1,
                      label="人声提取激进程度",
                      value=10,
                      interactive=True,
                      visible=False,  # 先不开放调整
                  )
                  opt_vocal_root = gr.Textbox(
                      label=i18n("指定输出主人声文件夹"), value="opt"
                  )
                  opt_ins_root = gr.Textbox(
                      label=i18n("指定输出非主人声文件夹"), value="opt"
                  )
                  format0 = gr.Radio(
                      label=i18n("导出文件格式"),
                      choices=["wav", "flac", "mp3", "m4a"],
                      value="flac",
                      interactive=True,
                  )
              but2 = gr.Button(i18n("转换"), variant="primary")
              vc_output4 = gr.Textbox(label=i18n("输出信息"))
              but2.click(
                  uvr,
                  [
                      model_choose,
                      dir_wav_input,
                      opt_vocal_root,
                      wav_inputs,
                      opt_ins_root,
                      agg,
                      format0,
                  ],
                  [vc_output4],
                  api_name="uvr_convert",
              )

코드의 변수명과 웹 스크린샷의 번호를 연결해보자.


웹 스크린샷 번호 코드 변수명 의미
1 dir_wav_input 음원 파일의 경로
2 wav_inputs 음원 파일을 업로드
3 model_choose 음원 분리를 위한 AI 모델 설정
4 opt_vocal_root 분리된 가수 목소리 음원을 저장할 폴더 경로
5 opt_ins_root 분리된 MR 음원을 저장할 폴더 경로
6 format0 분리된 음원의 저장 파일 형식
7 but2 변환 버튼

“변환” 버튼을 누르면 uvr이라는 함수가 위의 파라미터와 함께 실행이 된다. 즉, 음원 분리를 위해서는 uvr이라는 함수와 적절한 파라미터를 넣어주면 되는 것이다. 그럼 이제 uvr 함수를 확인해보자.

아래 uvr 함수를 확인해보면 다양한 AI 모델 중 하나를 선택하여 음원 분리를 한다. 이 함수를 조금 수정을 해서 voice_separation.py라는 함수를 만들었다.


def uvr(model_name, inp_root, save_root_vocal, paths, save_root_ins, agg, format0):
    infos = []
    try:
        inp_root = inp_root.strip(" ").strip('"').strip("\n").strip('"').strip(" ")
        save_root_vocal = (
            save_root_vocal.strip(" ").strip('"').strip("\n").strip('"').strip(" ")
        )
        save_root_ins = (
            save_root_ins.strip(" ").strip('"').strip("\n").strip('"').strip(" ")
        )
        if model_name == "onnx_dereverb_By_FoxJoy":
            pre_fun = MDXNetDereverb(15, config.device)
        else:
            func = AudioPre if "DeEcho" not in model_name else AudioPreDeEcho
            pre_fun = func(
                agg=int(agg),
                model_path=os.path.join(
                    os.getenv("weight_uvr5_root"), model_name + ".pth"
                ),
                device=config.device,
                is_half=config.is_half,
            )
        is_hp3 = "HP3" in model_name
        if inp_root != "":
            paths = [os.path.join(inp_root, name) for name in os.listdir(inp_root)]
        else:
            paths = [path.name for path in paths]
        for path in paths:
            inp_path = os.path.join(inp_root, path)
            need_reformat = 1
            done = 0
            try:
                info = ffmpeg.probe(inp_path, cmd="ffprobe")
                if (
                    info["streams"][0]["channels"] == 2
                    and info["streams"][0]["sample_rate"] == "44100"
                ):
                    need_reformat = 0
                    pre_fun._path_audio_(
                        inp_path, save_root_ins, save_root_vocal, format0, is_hp3=is_hp3
                    )
                    done = 1
            except:
                need_reformat = 1
                traceback.print_exc()
            if need_reformat == 1:
                tmp_path = "%s/%s.reformatted.wav" % (
                    os.path.join(os.environ["TEMP"]),
                    os.path.basename(inp_path),
                )
                os.system(
                    "ffmpeg -i %s -vn -acodec pcm_s16le -ac 2 -ar 44100 %s -y"
                    % (inp_path, tmp_path)
                )
                inp_path = tmp_path
            try:
                if done == 0:
                    pre_fun._path_audio_(
                        inp_path, save_root_ins, save_root_vocal, format0
                    )
                infos.append("%s->Success" % (os.path.basename(inp_path)))
                yield "\n".join(infos)
            except:
                try:
                    if done == 0:
                        pre_fun._path_audio_(
                            inp_path, save_root_ins, save_root_vocal, format0
                        )
                    infos.append("%s->Success" % (os.path.basename(inp_path)))
                    yield "\n".join(infos)
                except:
                    infos.append(
                        "%s->%s" % (os.path.basename(inp_path), traceback.format_exc())
                    )
                    yield "\n".join(infos)
    except:
        infos.append(traceback.format_exc())
        yield "\n".join(infos)
    finally:
        try:
            if model_name == "onnx_dereverb_By_FoxJoy":
                del pre_fun.pred.model
                del pre_fun.pred.model_
            else:
                del pre_fun.model
                del pre_fun
        except:
            traceback.print_exc()
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
            logger.info("Executed torch.cuda.empty_cache()")
    yield "\n".join(infos)

voice_separation.py는 아래와 같다. 수정하는 부분은 가독성을 높이기 위해 uvr 함수를 여러 부분으로 분리를 하고, argparse를 사용하도록 했다. 또한 기존 uvr 함수는 폴더내에 있는 모든 음원을 분리하도록 했다. 하지만 편의를 위한 하나의 음원 경로를 받아서 그 음원만 분리하도록 했다.


# voice_separation.py
import os
import sys
import logging
import traceback
import argparse
import torch
import ffmpeg

# Adding current working directory to path
now_dir = os.getcwd()
sys.path.append(now_dir)

from configs.config import Config
from infer.modules.uvr5.mdxnet import MDXNetDereverb
from infer.modules.uvr5.vr import AudioPre, AudioPreDeEcho

# Set up logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)  # Adjust as necessary

config = Config()

def initialize_pre_fun(model_name, config, agg):
    weight_root = os.getenv("weight_uvr5_root", "/home/choi/desktop/rvc/ai/Retrieval-based-Voice-Conversion-WebUI/assets/uvr5_weights")
    if model_name == "onnx_dereverb_By_FoxJoy":
        return MDXNetDereverb(15, config.device)
    else:
        Cls = AudioPre if "DeEcho" not in model_name else AudioPreDeEcho
        return Cls(
            agg=int(agg),
            model_path=os.path.join(weight_root, model_name + ".pth"),
            device=config.device,
            is_half=config.is_half,
        )

def uvr(model_name, inp_path, save_root_vocal, paths, save_root_ins, agg, format0):
    os.makedirs(save_root_vocal, exist_ok=True)
    os.makedirs(save_root_ins, exist_ok=True)

    infos = []
    pre_fun = None
    try:
        pre_fun = initialize_pre_fun(model_name, config, agg)
        need_reformat = 1
        done = 0
        try:
            info = ffmpeg.probe(inp_path, cmd="ffprobe")
            if info["streams"][0]["channels"] == 2 and info["streams"][0]["sample_rate"] == "44100":
                need_reformat = 0
                pre_fun._path_audio_(inp_path, save_root_ins, save_root_vocal, format0)
                done = 1
        except Exception as e:
            infos.append(f"Error in probing the file: {str(e)}")
            traceback.print_exc()

        if need_reformat == 1:
            tmp_path = f"{os.environ.get('TEMP', '/tmp')}/{os.path.basename(inp_path)}.reformatted.wav"
            os.system(f"ffmpeg -i {inp_path} -vn -acodec pcm_s16le -ac 2 -ar 44100 {tmp_path} -y")
            inp_path = tmp_path

        if done == 0:
            pre_fun._path_audio_(inp_path, save_root_ins, save_root_vocal, format0)
            infos.append(f"{os.path.basename(inp_path)}->Success")
        else:
            infos.append("No reformat needed and processing completed.")

    except Exception as e:
        infos.append(f"Exception during audio processing: {str(e)}")
        traceback.print_exc()
    finally:
        clean_up(pre_fun)
    return "\n".join(infos)

def clean_up(pre_fun):
    try:
        if pre_fun:
            if hasattr(pre_fun, 'model'):
                del pre_fun.model
            if hasattr(pre_fun, 'pred') and hasattr(pre_fun.pred, 'model'):
                del pre_fun.pred.model
    except Exception as e:
        logger.error(f"Failed to clean up resources: {str(e)}")
        traceback.print_exc()
    if torch.cuda.is_available():
        torch.cuda.empty_cache()
        logger.info("Executed torch.cuda.empty_cache()")

def arg_parse():
    parser = argparse.ArgumentParser(description="Audio processing with UVR model")
    parser.add_argument("--model_name", type=str, default="HP5_only_main_vocal", help="Model name for processing")
    parser.add_argument("--inp_path", type=str, default="/home/choi/desktop/rvc/ai/data/user1/input/music/origin_music.mp3", help="Input path of the audio file")
    parser.add_argument("--save_root_vocal", type=str, default="/home/choi/desktop/rvc/ai/data/user1/output/music", help="Output path for vocal")
    parser.add_argument("--save_root_ins", type=str, default="/home/choi/desktop/rvc/ai/data/user1/output/music", help="Output path for instruments")
    parser.add_argument("--agg", type=int, default=10, help="Aggregation parameter")
    parser.add_argument("--format0", type=str, default="wav", help="Output audio format")
    return parser.parse_args()

def main():
    args = arg_parse()
    result = uvr(args.model_name, args.inp_path, args.save_root_vocal, [], args.save_root_ins, args.agg, args.format0)
    print(result)

if __name__ == "__main__":
    main()