mirror of
https://github.com/RVC-Project/Retrieval-based-Voice-Conversion-WebUI.git
synced 2026-08-29 10:09:32 +02:00
更新多说话人训练webui,提供cli命令 / Update the multi-speaker training WebUI and provide CLI commands
This commit is contained in:
299
infer/cli.py
Normal file
299
infer/cli.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"`torch\.nn\.utils\.weight_norm` is deprecated.*",
|
||||
category=FutureWarning,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
os.chdir(PROJECT_ROOT)
|
||||
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
|
||||
os.environ.setdefault("weight_root", str(PROJECT_ROOT / "assets" / "weights"))
|
||||
os.environ.setdefault("index_root", str(PROJECT_ROOT / "logs"))
|
||||
os.environ.setdefault("outside_index_root", str(PROJECT_ROOT / "assets" / "indices"))
|
||||
os.environ.setdefault("rmvpe_root", str(PROJECT_ROOT / "assets" / "rmvpe"))
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
".wav",
|
||||
".flac",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".ogg",
|
||||
".opus",
|
||||
".aac",
|
||||
".wma",
|
||||
".mp4",
|
||||
".mkv",
|
||||
".webm",
|
||||
}
|
||||
OUTPUT_FORMATS = {"wav", "flac", "mp3", "m4a"}
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Offline RVC inference for single-speaker and multi-speaker models."
|
||||
)
|
||||
parser.add_argument("--model", required=True, help="Model filename or .pth path.")
|
||||
parser.add_argument("--input", help="Input audio file or directory.")
|
||||
parser.add_argument("--output", help="Output audio file or directory.")
|
||||
parser.add_argument(
|
||||
"--speaker-id",
|
||||
type=int,
|
||||
help="Speaker ID. Multi-speaker models default to their smallest declared ID.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-speakers",
|
||||
action="store_true",
|
||||
help="List the model's speaker metadata and exit.",
|
||||
)
|
||||
parser.add_argument("--pitch", type=int, default=0, help="Pitch shift in semitones.")
|
||||
parser.add_argument(
|
||||
"--f0-method", choices=["pm", "rmvpe"], default="rmvpe"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
help="Explicit added index path. The matching speaker index is selected automatically when omitted.",
|
||||
)
|
||||
parser.add_argument("--index-rate", type=float, default=0.75)
|
||||
parser.add_argument("--resample-sr", type=int, default=0)
|
||||
parser.add_argument("--rms-mix-rate", type=float, default=1.0)
|
||||
parser.add_argument("--protect", type=float, default=0.33)
|
||||
parser.add_argument(
|
||||
"--format", dest="output_format", choices=sorted(OUTPUT_FORMATS)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--recursive", action="store_true", help="Scan input subdirectories."
|
||||
)
|
||||
parser.add_argument("--overwrite", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def resolve_model(value):
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (PROJECT_ROOT / candidate).resolve()
|
||||
if not candidate.is_file():
|
||||
candidate = (Path(os.environ["weight_root"]) / value).resolve()
|
||||
if not candidate.is_file():
|
||||
raise FileNotFoundError("Model not found: %s" % value)
|
||||
if candidate.suffix.lower() != ".pth":
|
||||
raise ValueError("Model must be a .pth file: %s" % candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def load_model_metadata(model_path):
|
||||
import torch
|
||||
|
||||
checkpoint = torch.load(str(model_path), map_location="cpu")
|
||||
weight = checkpoint.get("weight", {}) if isinstance(checkpoint, dict) else {}
|
||||
embedding = weight.get("emb_g.weight")
|
||||
if embedding is None:
|
||||
raise ValueError("Model does not contain weight/emb_g.weight")
|
||||
speaker_count = int(embedding.shape[0])
|
||||
speakers = []
|
||||
seen = set()
|
||||
for item in checkpoint.get("speaker_info", []):
|
||||
try:
|
||||
speaker_id = int(item["id"])
|
||||
speaker_name = str(item["name"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
0 <= speaker_id < speaker_count
|
||||
and speaker_name
|
||||
and speaker_id not in seen
|
||||
):
|
||||
speakers.append({"id": speaker_id, "name": speaker_name})
|
||||
seen.add(speaker_id)
|
||||
speakers.sort(key=lambda item: item["id"])
|
||||
return speaker_count, speakers
|
||||
|
||||
|
||||
def select_speaker(speaker_count, speakers, requested_id):
|
||||
if speakers:
|
||||
valid_ids = {item["id"] for item in speakers}
|
||||
speaker_id = speakers[0]["id"] if requested_id is None else requested_id
|
||||
if speaker_id not in valid_ids:
|
||||
raise ValueError(
|
||||
"Speaker ID %s is not declared by this multi-speaker model; available IDs: %s"
|
||||
% (speaker_id, ", ".join(str(value) for value in sorted(valid_ids)))
|
||||
)
|
||||
return speaker_id
|
||||
speaker_id = 0 if requested_id is None else requested_id
|
||||
if speaker_id < 0 or speaker_id >= speaker_count:
|
||||
raise ValueError(
|
||||
"Speaker ID must be between 0 and %s for this model" % (speaker_count - 1)
|
||||
)
|
||||
return speaker_id
|
||||
|
||||
|
||||
def resolve_index(value, model_name, speaker_id, index_rate):
|
||||
from infer.vc.utils import get_index_path_from_model
|
||||
|
||||
if index_rate == 0:
|
||||
return ""
|
||||
if value:
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (PROJECT_ROOT / candidate).resolve()
|
||||
if "trained" in candidate.name:
|
||||
candidate = candidate.with_name(candidate.name.replace("trained", "added"))
|
||||
index_path = str(candidate)
|
||||
else:
|
||||
index_path = get_index_path_from_model(model_name, speaker_id)
|
||||
if not index_path or not Path(index_path).is_file():
|
||||
raise FileNotFoundError(
|
||||
"No usable added index for speaker ID %s. Supply --index or use --index-rate 0."
|
||||
% speaker_id
|
||||
)
|
||||
return str(Path(index_path).resolve())
|
||||
|
||||
|
||||
def collect_jobs(input_value, output_value, output_format, recursive):
|
||||
input_path = Path(input_value).expanduser().resolve()
|
||||
output_path = Path(output_value).expanduser().resolve()
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError("Input not found: %s" % input_path)
|
||||
|
||||
if input_path.is_file():
|
||||
suffix_format = output_path.suffix.lower().lstrip(".")
|
||||
if output_path.suffix and suffix_format not in OUTPUT_FORMATS:
|
||||
raise ValueError("Unsupported output suffix: %s" % output_path.suffix)
|
||||
selected_format = output_format or suffix_format or "wav"
|
||||
if output_path.suffix:
|
||||
output_file = output_path.with_suffix(".%s" % selected_format)
|
||||
else:
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_path / (input_path.stem + "." + selected_format)
|
||||
return [(input_path, output_file, selected_format)]
|
||||
|
||||
selected_format = output_format or "wav"
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
iterator = input_path.rglob("*") if recursive else input_path.iterdir()
|
||||
jobs = []
|
||||
for path in sorted(iterator):
|
||||
if not path.is_file() or path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
relative_parent = path.relative_to(input_path).parent if recursive else Path()
|
||||
output_file = output_path / relative_parent / (path.stem + "." + selected_format)
|
||||
jobs.append((path, output_file, selected_format))
|
||||
if not jobs:
|
||||
raise ValueError("No supported audio files found in: %s" % input_path)
|
||||
return jobs
|
||||
|
||||
|
||||
def write_audio(path, audio, sample_rate, output_format):
|
||||
import soundfile as sf
|
||||
from infer.audio import wav2
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if output_format in ("wav", "flac"):
|
||||
sf.write(str(path), audio, sample_rate)
|
||||
return
|
||||
with BytesIO() as wav_file:
|
||||
sf.write(wav_file, audio, sample_rate, format="wav")
|
||||
wav_file.seek(0)
|
||||
with open(path, "wb") as output_file:
|
||||
wav2(wav_file, output_file, output_format)
|
||||
|
||||
|
||||
def create_config():
|
||||
from configs.config import Config
|
||||
|
||||
original_argv = sys.argv[:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
try:
|
||||
return Config()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not args.list_speakers and (not args.input or not args.output):
|
||||
parser.error("--input and --output are required unless --list-speakers is used")
|
||||
if not 0 <= args.index_rate <= 1:
|
||||
parser.error("--index-rate must be between 0 and 1")
|
||||
if not 0 <= args.rms_mix_rate <= 1:
|
||||
parser.error("--rms-mix-rate must be between 0 and 1")
|
||||
if not 0 <= args.protect <= 0.5:
|
||||
parser.error("--protect must be between 0 and 0.5")
|
||||
if args.resample_sr and args.resample_sr < 16000:
|
||||
parser.error("--resample-sr must be 0 or at least 16000")
|
||||
|
||||
model_path = resolve_model(args.model)
|
||||
os.environ["weight_root"] = str(model_path.parent)
|
||||
model_name = model_path.name
|
||||
speaker_count, speakers = load_model_metadata(model_path)
|
||||
if args.list_speakers:
|
||||
if speakers:
|
||||
for item in speakers:
|
||||
print("%s\t%s" % (item["id"], item["name"]))
|
||||
else:
|
||||
print("0-%s" % (speaker_count - 1))
|
||||
return 0
|
||||
|
||||
speaker_id = select_speaker(speaker_count, speakers, args.speaker_id)
|
||||
index_path = resolve_index(
|
||||
args.index, model_name, speaker_id, args.index_rate
|
||||
)
|
||||
jobs = collect_jobs(
|
||||
args.input, args.output, args.output_format, args.recursive
|
||||
)
|
||||
existing = [str(output) for _, output, _ in jobs if output.exists()]
|
||||
if existing and not args.overwrite:
|
||||
raise FileExistsError(
|
||||
"Output already exists; use --overwrite: %s" % existing[0]
|
||||
)
|
||||
|
||||
from i18n.i18n import I18nAuto
|
||||
from infer.vc.modules import VC
|
||||
|
||||
i18n = I18nAuto()
|
||||
config = create_config()
|
||||
print(i18n("当前设备:%s | 推理精度:%s") % (config.device, config.dtype))
|
||||
print("%s: %s" % (i18n("选择模型"), model_name))
|
||||
print("%s: %s" % (i18n("说话人ID(0~109)"), speaker_id))
|
||||
print("%s: %s" % (i18n("选择索引"), index_path or i18n("未使用")))
|
||||
|
||||
vc = VC(config)
|
||||
vc.get_vc(model_name)
|
||||
failed = 0
|
||||
for input_path, output_path, output_format in jobs:
|
||||
status, result = vc.vc_single(
|
||||
speaker_id,
|
||||
str(input_path),
|
||||
args.pitch,
|
||||
args.f0_method,
|
||||
index_path,
|
||||
args.index_rate,
|
||||
args.resample_sr,
|
||||
args.rms_mix_rate,
|
||||
args.protect,
|
||||
)
|
||||
print(status)
|
||||
if not result or result[0] is None or result[1] is None:
|
||||
failed += 1
|
||||
continue
|
||||
write_audio(output_path, result[1], result[0], output_format)
|
||||
print(str(output_path))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
except Exception as error:
|
||||
print("rvc-cli: error: %s" % error, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -305,6 +305,24 @@ class SineGen(torch.nn.Module):
|
||||
uv = uv.float()
|
||||
return uv
|
||||
|
||||
### @yxlllc
|
||||
def _f02sine(self, f0, upp):
|
||||
rad = f0 / self.sampling_rate * torch.arange(
|
||||
1, upp + 1, device=f0.device
|
||||
)
|
||||
rad2 = torch.fmod(rad[..., -1:].float() + 0.5, 1.0) - 0.5
|
||||
rad_acc = rad2.cumsum(dim=1).fmod(1.0).to(f0)
|
||||
rad += F.pad(rad_acc, (0, 0, 1, -1))
|
||||
rad = rad.reshape(f0.shape[0], -1, 1)
|
||||
rad = torch.multiply(
|
||||
rad,
|
||||
torch.arange(1, self.dim + 1, device=f0.device).reshape(1, 1, -1),
|
||||
)
|
||||
rand_ini = torch.rand(1, 1, self.dim, device=f0.device)
|
||||
rand_ini[..., 0] = 0
|
||||
rad += rand_ini
|
||||
return torch.sin(2 * np.pi * rad)
|
||||
|
||||
def forward(self, f0, upp):
|
||||
"""sine_tensor, uv = forward(f0)
|
||||
input F0: tensor(batchsize=1, length, dim=1)
|
||||
@@ -314,44 +332,7 @@ class SineGen(torch.nn.Module):
|
||||
"""
|
||||
with torch.no_grad():
|
||||
f0 = f0[:, None].transpose(1, 2)
|
||||
f0_buf = torch.zeros(f0.shape[0], f0.shape[1], self.dim, device=f0.device)
|
||||
# fundamental component
|
||||
f0_buf[:, :, 0] = f0[:, :, 0]
|
||||
for idx in range(self.harmonic_num):
|
||||
f0_buf[:, :, idx + 1] = f0_buf[:, :, 0] * (
|
||||
idx + 2
|
||||
) # idx + 2: the (idx+1)-th overtone, (idx+2)-th harmonic
|
||||
rad_values = (
|
||||
f0_buf / self.sampling_rate
|
||||
) % 1 ###%1意味着n_har的乘积无法后处理优化
|
||||
rand_ini = torch.rand(
|
||||
f0_buf.shape[0], f0_buf.shape[2], device=f0_buf.device
|
||||
)
|
||||
rand_ini[:, 0] = 0
|
||||
rad_values[:, 0, :] = rad_values[:, 0, :] + rand_ini
|
||||
tmp_over_one = torch.cumsum(
|
||||
rad_values, 1
|
||||
) # % 1 #####%1意味着后面的cumsum无法再优化
|
||||
tmp_over_one *= upp
|
||||
tmp_over_one = F.interpolate(
|
||||
tmp_over_one.transpose(2, 1),
|
||||
scale_factor=float(upp),
|
||||
mode="linear",
|
||||
align_corners=True,
|
||||
).transpose(2, 1)
|
||||
rad_values = F.interpolate(
|
||||
rad_values.transpose(2, 1), scale_factor=float(upp), mode="nearest"
|
||||
).transpose(
|
||||
2, 1
|
||||
) #######
|
||||
tmp_over_one %= 1
|
||||
tmp_over_one_idx = (tmp_over_one[:, 1:, :] - tmp_over_one[:, :-1, :]) < 0
|
||||
cumsum_shift = torch.zeros_like(rad_values)
|
||||
cumsum_shift[:, 1:, :] = tmp_over_one_idx * -1.0
|
||||
sine_waves = torch.sin(
|
||||
torch.cumsum(rad_values + cumsum_shift, dim=1) * 2 * torch.pi
|
||||
)
|
||||
sine_waves = sine_waves * self.sine_amp
|
||||
sine_waves = self._f02sine(f0, upp) * self.sine_amp
|
||||
uv = self._f02uv(f0)
|
||||
uv = F.interpolate(
|
||||
uv.transpose(2, 1), scale_factor=float(upp), mode="nearest"
|
||||
|
||||
@@ -32,6 +32,58 @@ def inference_status(title, state, detail=""):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def normalized_speaker_info(checkpoint, n_spk):
|
||||
speaker_info = []
|
||||
seen = set()
|
||||
for item in checkpoint.get("speaker_info", []):
|
||||
try:
|
||||
speaker_id = int(item["id"])
|
||||
speaker_name = str(item["name"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
speaker_id < 0
|
||||
or speaker_id >= n_spk
|
||||
or not speaker_name
|
||||
or speaker_id in seen
|
||||
):
|
||||
continue
|
||||
seen.add(speaker_id)
|
||||
speaker_info.append({"id": speaker_id, "name": speaker_name})
|
||||
speaker_info.sort(key=lambda item: item["id"])
|
||||
return speaker_info
|
||||
|
||||
|
||||
def speaker_selector_updates(checkpoint, n_spk):
|
||||
speaker_info = normalized_speaker_info(checkpoint, n_spk)
|
||||
if speaker_info:
|
||||
choices = [
|
||||
i18n("说话人:%s(ID:%s)") % (item["name"], item["id"])
|
||||
for item in speaker_info
|
||||
]
|
||||
return (
|
||||
{
|
||||
"visible": False,
|
||||
"value": speaker_info[0]["id"],
|
||||
"__type__": "update",
|
||||
},
|
||||
{
|
||||
"visible": True,
|
||||
"choices": choices,
|
||||
"value": choices[0],
|
||||
"__type__": "update",
|
||||
},
|
||||
)
|
||||
return (
|
||||
{
|
||||
"visible": True,
|
||||
"maximum": max(n_spk - 1, 0),
|
||||
"__type__": "update",
|
||||
},
|
||||
{"visible": False, "value": None, "__type__": "update"},
|
||||
)
|
||||
|
||||
|
||||
class VC:
|
||||
def __init__(self, config):
|
||||
self.n_spk = None
|
||||
@@ -99,6 +151,7 @@ class VC:
|
||||
torch.cuda.empty_cache()
|
||||
return (
|
||||
{"visible": False, "__type__": "update"},
|
||||
{"visible": False, "value": None, "__type__": "update"},
|
||||
{
|
||||
"visible": True,
|
||||
"value": to_return_protect0,
|
||||
@@ -146,19 +199,28 @@ class VC:
|
||||
|
||||
self.pipeline = Pipeline(self.tgt_sr, self.config)
|
||||
n_spk = self.cpt["config"][-3]
|
||||
index = {"value": get_index_path_from_model(sid), "__type__": "update"}
|
||||
speaker_info = normalized_speaker_info(self.cpt, n_spk)
|
||||
speaker_slider_update, speaker_dropdown_update = speaker_selector_updates(
|
||||
self.cpt, n_spk
|
||||
)
|
||||
default_speaker_id = speaker_info[0]["id"] if speaker_info else 0
|
||||
index = {
|
||||
"value": get_index_path_from_model(sid, default_speaker_id),
|
||||
"__type__": "update",
|
||||
}
|
||||
logger.info("%s: %s", i18n("选择索引"), index["value"])
|
||||
|
||||
return (
|
||||
(
|
||||
{"visible": True, "maximum": n_spk, "__type__": "update"},
|
||||
speaker_slider_update,
|
||||
speaker_dropdown_update,
|
||||
to_return_protect0,
|
||||
to_return_protect1,
|
||||
index,
|
||||
index,
|
||||
)
|
||||
if to_return_protect
|
||||
else {"visible": True, "maximum": n_spk, "__type__": "update"}
|
||||
else speaker_slider_update
|
||||
)
|
||||
|
||||
def vc_single(
|
||||
|
||||
@@ -4,12 +4,16 @@ import re
|
||||
from infer.hubert import load_hubert_model
|
||||
|
||||
|
||||
def get_index_path_from_model(sid):
|
||||
def get_index_path_from_model(sid, speaker_id=None):
|
||||
model_stem = os.path.splitext(os.path.basename(str(sid or "")))[0]
|
||||
experiment_name = re.sub(r"_e\d+_s\d+$", "", model_stem, flags=re.IGNORECASE)
|
||||
if not experiment_name:
|
||||
return ""
|
||||
|
||||
try:
|
||||
target_speaker_id = None if speaker_id is None else int(speaker_id)
|
||||
except (TypeError, ValueError):
|
||||
target_speaker_id = None
|
||||
candidates = []
|
||||
roots = [os.getenv("outside_index_root"), os.getenv("index_root")]
|
||||
for index_root in roots:
|
||||
@@ -22,6 +26,18 @@ def get_index_path_from_model(sid):
|
||||
index_stem = os.path.splitext(name)[0]
|
||||
lower_index = index_stem.lower()
|
||||
lower_experiment = experiment_name.lower()
|
||||
speaker_match = re.search(r"_spkid(\d+)$", index_stem, re.IGNORECASE)
|
||||
indexed_speaker_id = (
|
||||
int(speaker_match.group(1)) if speaker_match else None
|
||||
)
|
||||
if target_speaker_id is None and indexed_speaker_id is not None:
|
||||
continue
|
||||
if (
|
||||
target_speaker_id is not None
|
||||
and indexed_speaker_id is not None
|
||||
and indexed_speaker_id != target_speaker_id
|
||||
):
|
||||
continue
|
||||
standard_match = (
|
||||
lower_index.startswith(lower_experiment + "_added_")
|
||||
or ("_" + lower_experiment + "_v1") in lower_index
|
||||
@@ -31,6 +47,7 @@ def get_index_path_from_model(sid):
|
||||
if standard_match or exact_model_match:
|
||||
path = os.path.abspath(os.path.join(root, name))
|
||||
score = (
|
||||
0 if indexed_speaker_id == target_speaker_id else 1,
|
||||
0 if standard_match else 1,
|
||||
0 if os.path.abspath(index_root) == os.path.abspath(roots[0]) else 1,
|
||||
-os.path.getmtime(path),
|
||||
|
||||
Reference in New Issue
Block a user