Files
2025-05-03 22:56:25 +08:00

588 lines
24 KiB
Python

import abc
import json
import pathlib
from dataclasses import dataclass
from typing import Literal
import dask
import librosa
import numpy
import torch
import tqdm
from lib import logging
from lib.config.schema import DataConfig, BinarizerConfig
from lib.config.schema import DataSourceConfig
from lib.feature import get_energy, get_tension, SinusoidalSmoothingConv1d
from lib.feature.decomposition import (
world_analyze, world_synthesize_harmonics, world_synthesize_aperiodic,
get_kth_harmonic
)
from lib.feature.mel import StretchableMelSpectrogram
from lib.feature.pitch import get_pitch_parselmouth, get_pitch_harvest
from lib.functional import resample_align_curve
from lib.indexed_dataset import IndexedDatasetBuilder
from lib.multiprocess import chunked_multiprocess_run
from lib.plot import distribution_to_figure
from modules.commons.tts_modules import LengthRegulator
@dataclass
class MetadataItem(abc.ABC):
item_name: str
estimated_duration: float
spk_name: str
spk_id: int
ph_text: str
lang_seq: list[int]
ph_seq: list[int]
ph_dur: list[float]
wav_fn: pathlib.Path
@dataclass
class DataSample:
name: str
spk_name: str
spk_id: int
ph_text: str
length: int
augmented: bool
data: dict[str, int | float | numpy.ndarray]
error: str = None
class BaseBinarizer(abc.ABC):
__data_attrs__: list[str] = None
__augmentation__: bool = False
def __init__(
self, data_config: DataConfig, binarizer_config: BinarizerConfig,
coverage_check_option: Literal["strict", "bypass", "compat"] = "strict"
):
self.phoneme_dictionary = data_config.phoneme_dictionary
self.spk_map = data_config.spk_map
self.lang_map = data_config.lang_map
self.sources = data_config.sources
self.config = binarizer_config
self.binary_data_dir: pathlib.Path = binarizer_config.binary_data_dir_resolved
self.timestep = binarizer_config.features.hop_size / binarizer_config.features.audio_sample_rate
self.coverage_check_option = coverage_check_option
self.missing_phonemes = set()
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.lr = LengthRegulator()
# Lazy-initialized modules
self.mel_spec = None
self.rmvpe = None
self.hn_sep_model = None
self.smooth_fns = {}
self.smooth_widths = {
"energy": self.config.features.energy.smooth_width,
"breathiness": self.config.features.breathiness.smooth_width,
"voicing": self.config.features.voicing.smooth_width,
"tension": self.config.features.tension.smooth_width
}
self.valid_items: list[MetadataItem] = []
self.train_items: list[MetadataItem] = []
@abc.abstractmethod
def load_metadata(self, data_source_config: DataSourceConfig) -> dict[str, MetadataItem]:
pass
@abc.abstractmethod
def process_item(self, item: MetadataItem, augmentation=False) -> list[DataSample]:
pass
def try_load_external_labels_if_allowed(self, raw_data_dir, item_name, transcription) -> tuple[bool, dict]:
"""
Try to load external labels from the waveform file if allowed (prefer_ds=true).
:return: (loaded, overridden transcription)
"""
if self.config.prefer_ds:
ds_fn = raw_data_dir / "wavs" / f"{item_name}.ds"
if ds_fn.exists() and ds_fn.is_file():
with open(ds_fn, "r", encoding="utf8") as ds_f:
ds_obj = ds_f.read()
if isinstance(ds_obj, list):
if len(ds_obj) == 0:
raise ValueError(f"Empty ds content encountered in '{ds_fn}'.")
elif len(ds_obj) > 1:
raise ValueError(
f"Unsupported multiple segments in '{ds_fn}' (found {len(ds_obj)} segments).")
ds_obj = ds_obj[0]
transcription = {
**transcription,
**{k: v for k, v in ds_obj if v}
}
return True, transcription
return False, transcription
def parse_language_phoneme_sequences(self, transcription: dict, language: str) -> tuple[bool, tuple | str]:
"""
Parse the language and phoneme sequences from transcriptions
:param transcription: dict
:param language: language context
:return: (succeeded, results).
if succeeded, results = (ph_text, lang_seq, ph_seq, ph_dur);
if failed, results = error message template with args (raw_data_dir, item_name)
"""
ph_text = transcription["ph_seq"].split()
lang_seq = []
for ph in ph_text:
if "/" in ph:
lang_name = ph.split("/")[0]
if lang_name not in self.lang_map:
return False, (
"Invalid language tag found in raw dataset '{}':\n"
f"item '{{}}', phoneme '{ph}'"
)
full_name = ph
else:
lang_name = language
full_name = f"{language}/{ph}"
if self.phoneme_dictionary.is_cross_lingual(full_name):
lang_id = self.lang_map[lang_name]
else:
lang_id = 0
lang_seq.append(lang_id)
ph_seq = self.phoneme_dictionary.encode(ph_text, lang=language)
unrecognized_phs = set()
for ph, token in zip(ph_text, ph_seq):
if token is None:
unrecognized_phs.add(ph)
if unrecognized_phs:
return False, (
"Unrecognized phonemes found in raw dataset '{}':\n"
f"item '{{}}', phonemes {sorted(unrecognized_phs)}"
)
ph_dur = []
for dur in transcription["ph_dur"].split():
dur_float = float(dur)
if dur_float < 0:
return False, (
"Negative phoneme duration found in raw dataset '{}':\n"
f"item '{{}}', duration '{dur}'"
)
ph_dur.append(dur_float)
if len(ph_seq) == 0:
return False, (
"Empty phoneme sequence found in raw dataset '{}':\n"
f"item '{{}}'"
)
if len(ph_seq) != len(ph_dur):
raise ValueError(
"Unaligned ph_seq and ph_dur found in raw dataset '{}':\n"
f"item '{{}}', ph_seq length {len(ph_seq)}, ph_dur length {len(ph_dur)}"
)
ph_text = " ".join(ph_text)
return True, (ph_text, lang_seq, ph_seq, ph_dur)
def split_train_and_valid_set(self, metadata_dict: dict[str, MetadataItem], prefixes: list[str]):
for prefix in prefixes:
if prefix in metadata_dict:
self.valid_items.append(metadata_dict.pop(prefix))
else:
hit = False
for key in list(metadata_dict.keys()):
if key.startswith(prefix):
self.valid_items.append(metadata_dict.pop(key))
hit = True
if not hit:
logging.warning(f"Test prefix '{prefix}' does not hit any item.")
for item in metadata_dict.values():
self.train_items.append(item)
def check_coverage(self):
# Group by phoneme IDs in the dictionary.
ph_idx_required = set(range(1, len(self.phoneme_dictionary)))
ph_idx_occurred = set()
ph_idx_count_map = {
idx: 0
for idx in ph_idx_required
}
# Load and count those phones that appear in the actual data
for item in self.train_items:
ph_idx_occurred.update(item.ph_seq)
for idx in item.ph_seq:
ph_idx_count_map[idx] += 1
ph_count_map = {
self.phoneme_dictionary.decode_one(idx, scalar=False): count
for idx, count in ph_idx_count_map.items()
}
def display_phoneme(phoneme):
if isinstance(phoneme, tuple):
return f"({', '.join(phoneme)})"
return phoneme
print("===== Phoneme Distribution Summary =====")
keys = sorted(ph_count_map.keys(), key=lambda v: v[0] if isinstance(v, tuple) else v)
width = 10
start = 0
while start < len(keys):
end = min(start + width, len(keys))
disp = ", ".join(
f"{display_phoneme(k)}: {ph_count_map[k]}"
for k in keys[start:end]
)
if end < len(keys):
disp += ","
print(disp)
start = end
# Draw graph.
xs = [display_phoneme(k) for k in keys]
ys = [ph_count_map[k] for k in keys]
plt = distribution_to_figure(
title="Phoneme Distribution Summary",
x_label="Phoneme", y_label="Number of occurrences",
items=xs, values=ys, rotate=True
)
filename = self.binary_data_dir / "phoneme_distribution.jpg"
plt.savefig(fname=filename, bbox_inches="tight", pad_inches=0.25)
logging.info(f"Phoneme distribution summary saved to '{filename}'.")
missing_phonemes = set()
missing_phonemes_display = []
for phone_id in ph_idx_required.difference(ph_idx_occurred):
phone_repr = self.phoneme_dictionary.decode_one(phone_id, scalar=False)
missing_phonemes_display.append(phone_repr)
if not isinstance(phone_repr, tuple):
phone_repr = (phone_repr,)
for ph in phone_repr:
missing_phonemes.add(ph)
if self.coverage_check_option == "bypass":
# bypass: ignore missing phonemes
return
if self.coverage_check_option == "compat":
# compat: record missing phonemes and exclude them from the dictionary
self.missing_phonemes = missing_phonemes
return
# strict: raise error on missing phonemes
if ph_idx_occurred != ph_idx_required:
missing_phonemes_display.sort(key=lambda v: v[0] if isinstance(v, tuple) else v)
missing_phones = sorted({
self.phoneme_dictionary.decode_one(idx, scalar=False)
for idx in ph_idx_required.difference(ph_idx_occurred)
}, key=lambda v: v[0] if isinstance(v, tuple) else v)
raise RuntimeError(
f"The following phonemes are not covered in transcriptions: {missing_phones}\n"
"If you are fine-tuning from a pre-trained model or you don't want to support these phonemes, "
"consider using --coverage-check-option bypass or --coverage-check-option compat.\n"
)
def free_lazy_modules(self):
"""
The lazy-initialized PyTorch modules should be freed before multiprocessing,
because of CUDA IPC (shared memory) issues on Windows platforms.
Reference:
- https://github.com/pytorch/pytorch/issues/100358
- https://github.com/Xiao-Chenguang/FedMind/issues/61
"""
self.mel_spec = None
self.rmvpe = None
self.hn_sep_model = None
self.smooth_fns.clear()
def process_items(self, items: list[MetadataItem], prefix: str, augmentation=False, multiprocessing=True):
builder = IndexedDatasetBuilder(
path=self.binary_data_dir, prefix=prefix, allowed_attr=self.__data_attrs__
)
if multiprocessing and self.config.num_workers > 0:
logging.debug(f"Processing {prefix} items with {self.config.num_workers} worker(s).")
self.free_lazy_modules()
iterable = chunked_multiprocess_run(
self.process_item, [(item, augmentation) for item in items], num_workers=self.config.num_workers
)
else:
logging.debug(f"Processing {prefix} items in main process.")
iterable = (self.process_item(item, augmentation) for item in items)
item_names = []
ph_texts = []
spk_ids = []
spk_names = []
lengths = []
attr_lengths = {}
total_duration_before_aug = {k: 0 for k in self.spk_map}
total_duration = {k: 0 for k in self.spk_map}
with tqdm.tqdm(iterable, total=len(items), desc=f"Processing {prefix} items") as progress:
for samples in progress:
for sample in samples:
sample: DataSample
if sample.error:
logging.warning(
f"Error encountered in sample '{sample.name}': {sample.error}",
callback=progress.write
)
continue
if not augmentation and sample.augmented:
raise RuntimeError(
f"Augmented samples are not allowed when `augmentation` is set to False."
)
builder.add_item(sample.data)
item_names.append(sample.name)
ph_texts.append(sample.ph_text)
spk_ids.append(sample.spk_id)
spk_names.append(sample.spk_name)
lengths.append(sample.length)
for k, v in sample.data.items():
if isinstance(v, numpy.ndarray) and v.ndim > 0:
if k not in attr_lengths:
attr_lengths[k] = []
attr_lengths[k].append(v.shape[0])
duration = sample.length * self.timestep
if not sample.augmented:
total_duration_before_aug[sample.spk_name] += duration
total_duration[sample.spk_name] += duration
builder.finalize()
metadata = {
"item_names": item_names,
"ph_texts": ph_texts,
"spk_ids": spk_ids,
"spk_names": spk_names,
"lengths": lengths,
**attr_lengths
}
if prefix == "train":
metadata.pop("item_names")
metadata.pop("ph_texts")
metadata.pop("spk_names")
metadata = {
k: numpy.array(v)
for k, v in metadata.items()
}
with open(self.binary_data_dir / f"{prefix}.info.npz", "wb") as f:
numpy.savez(f, **metadata)
dur_before = sum(total_duration_before_aug.values())
dur_after = sum(total_duration.values())
if augmentation:
logging.info(f"Total duration of {prefix} (original): {dur_before:.2f}s")
logging.info(
f"Respective duration of {prefix} (original): "
+ ", ".join(f"{k}={v:.2f}s" for k, v in total_duration_before_aug.items() if v > 0)
)
logging.info(
f"Total duration of {prefix} (augmented): "
f"{dur_after:.2f}s ({dur_after / dur_before:.2f}x)"
)
logging.info(
f"Respective duration of {prefix} (augmented): "
+ ", ".join(f"{k}={v:.2f}s" for k, v in total_duration.items())
)
else:
logging.info(f"Total duration of {prefix}: {dur_before:.2f}s")
logging.info(
f"Respective duration of {prefix}: "
+ ", ".join(f"{k}={v:.2f}s" for k, v in total_duration_before_aug.items() if v > 0)
)
logging.debug(f"Processing {prefix} items done.")
def process(self):
self.binary_data_dir.mkdir(parents=True, exist_ok=True)
for source in self.sources:
metadata_dict = self.load_metadata(source)
self.split_train_and_valid_set(metadata_dict, source.test_prefixes)
logging.debug(f"Loaded {len(metadata_dict)} metadata items from '{source.raw_data_dir_resolved}'")
logging.info(f"Training set total size: {len(self.train_items)}.")
logging.info(f"Validation set total size: {len(self.valid_items)}.")
if not self.train_items:
raise RuntimeError("Training set is empty.")
if not self.valid_items:
raise RuntimeError("Validation set is empty.")
self.check_coverage()
with open(self.binary_data_dir / "spk_map.json", "w", encoding="utf8") as f:
json.dump(self.spk_map, f, ensure_ascii=False)
with open(self.binary_data_dir / "lang_map.json", "w", encoding="utf8") as f:
json.dump(self.lang_map, f, ensure_ascii=False)
self.phoneme_dictionary.dump(self.binary_data_dir / "ph_map.json", excludes=self.missing_phonemes)
self.train_items.sort(key=lambda i: i.estimated_duration, reverse=True)
self.process_items(self.valid_items, prefix="valid", augmentation=False, multiprocessing=False)
self.process_items(self.train_items, prefix="train", augmentation=self.__augmentation__, multiprocessing=True)
@dask.delayed
def load_waveform(self, wav_fn: pathlib.Path):
waveform, _ = librosa.load(wav_fn, sr=self.config.features.audio_sample_rate, mono=True)
return waveform
@dask.delayed(nout=2)
@torch.no_grad()
def get_mel(self, waveform: numpy.ndarray, shift: float = 0., speed: float = 1.):
if self.mel_spec is None:
self.mel_spec = StretchableMelSpectrogram(
sample_rate=self.config.features.audio_sample_rate,
n_mels=self.config.features.spectrogram.num_bins,
n_fft=self.config.features.fft_size,
win_length=self.config.features.win_size,
hop_length=self.config.features.hop_size,
fmin=self.config.features.spectrogram.fmin,
fmax=self.config.features.spectrogram.fmax
).eval().to(self.device)
mel = self.mel_spec(
torch.from_numpy(waveform).to(self.device).unsqueeze(0),
key_shift=shift, speed=speed
).squeeze(0).T.cpu().numpy()
return mel, mel.shape[0]
@dask.delayed
def sec_dur_to_frame_dur(self, dur_sec: numpy.ndarray, length: int):
dur_cumsum = numpy.round(numpy.cumsum(dur_sec, axis=0) / self.timestep + 0.5).astype(numpy.int64)
dur_cumsum = numpy.clip(dur_cumsum, a_min=0, a_max=length)
dur_cumsum[-1] = length
dur_frame = numpy.diff(dur_cumsum, axis=0, prepend=numpy.array([0]))
return dur_frame
@torch.no_grad()
def smooth_curve(self, curve: numpy.ndarray, smooth_fn_name: str):
if smooth_fn_name not in self.smooth_fns:
self.smooth_fns[smooth_fn_name] = SinusoidalSmoothingConv1d(
round(self.smooth_widths[smooth_fn_name] / self.timestep)
).eval().to(self.device)
return self.smooth_fns[smooth_fn_name](torch.from_numpy(curve)[None].to(self.device))[0].cpu().numpy()
@dask.delayed(nout=2)
def get_f0(self, waveform: numpy.ndarray, length: int):
pe_method = self.config.extractors.pitch_extraction.method
if pe_method == "parselmouth":
f0, uv = get_pitch_parselmouth(
waveform,
samplerate=self.config.features.audio_sample_rate,
length=length,
hop_size=self.config.features.hop_size,
f0_min=self.config.extractors.pitch_extraction.f0_min,
f0_max=self.config.extractors.pitch_extraction.f0_max,
speed=1,
interp_uv=True
)
elif pe_method == "harvest":
f0, uv = get_pitch_harvest(
waveform,
samplerate=self.config.features.audio_sample_rate,
length=length,
hop_size=self.config.features.hop_size,
f0_min=self.config.extractors.pitch_extraction.f0_min,
f0_max=self.config.extractors.pitch_extraction.f0_max,
speed=1,
interp_uv=True
)
elif pe_method == "rmvpe":
if self.rmvpe is None:
from modules.rmvpe.inference import RMVPE
self.rmvpe = RMVPE(self.config.extractors.pitch_extraction.model_path)
f0, uv = self.rmvpe.get_pitch(
waveform,
samplerate=self.config.features.audio_sample_rate,
length=length,
hop_size=self.config.features.hop_size,
interp_uv=True
)
else:
raise ValueError(f"Unknown pitch extraction method: {pe_method}")
return f0, uv
@dask.delayed
def get_energy(self, waveform: numpy.ndarray, length: int, smooth_fn_name: str = None):
energy = get_energy(
waveform, length,
hop_size=self.config.features.hop_size,
win_size=self.config.features.win_size
)
if smooth_fn_name is not None:
energy = self.smooth_curve(energy, smooth_fn_name=smooth_fn_name)
return energy
@dask.delayed(nout=2)
def world_analyze(self, waveform: numpy.ndarray, f0: numpy.ndarray):
sp, ap = world_analyze(
waveform, f0,
samplerate=self.config.features.audio_sample_rate,
hop_size=self.config.features.hop_size,
fft_size=self.config.features.fft_size
)
return sp, ap
@dask.delayed
def world_synthesize_aperiodic(self, f0: numpy.ndarray, sp: numpy.ndarray, ap: numpy.ndarray):
noise = world_synthesize_aperiodic(
f0, sp, ap,
samplerate=self.config.features.audio_sample_rate,
time_step=self.timestep
)
return noise
@dask.delayed
def world_synthesize_harmonics(self, f0: numpy.ndarray, sp: numpy.ndarray, ap: numpy.ndarray):
harmonic = world_synthesize_harmonics(
f0, sp, ap,
samplerate=self.config.features.audio_sample_rate,
time_step=self.timestep
)
return harmonic
@dask.delayed(nout=2)
@torch.no_grad()
def run_vr_separation(self, waveform: numpy.ndarray):
if self.hn_sep_model is None:
from modules.vr import load_sep_model
self.hn_sep_model = load_sep_model(
model_path=self.config.extractors.harmonic_noise_separation.model_path,
device=self.device
)
x = torch.from_numpy(waveform).to(self.device).reshape(1, 1, -1)
if not self.hn_sep_model.is_mono:
x = x.repeat(1, 2, 1)
x = self.hn_sep_model.predict_from_audio(x)
x = torch.mean(x, dim=1)
harmonic = x.squeeze().cpu().numpy()
noise = waveform - harmonic
return harmonic, noise
def harmonic_noise_separation(self, waveform: numpy.ndarray, f0: numpy.ndarray):
hn_sep_method = self.config.extractors.harmonic_noise_separation.method
if hn_sep_method == "world":
sp, ap = self.world_analyze(waveform, f0)
noise = self.world_synthesize_aperiodic(f0, sp, ap)
harmonic = self.world_synthesize_harmonics(f0, sp, ap)
elif hn_sep_method == "vr":
harmonic, noise = self.run_vr_separation(waveform)
else:
raise ValueError(f"Unknown harmonic-noise separation method: {hn_sep_method}")
return harmonic, noise
@dask.delayed
def get_kth_harmonic(self, harmonic: numpy.ndarray, f0: numpy.ndarray, k: int):
kth_harmonic = get_kth_harmonic(
harmonic, f0, k=k,
samplerate=self.config.features.audio_sample_rate,
hop_size=self.config.features.hop_size,
win_size=self.config.features.win_size,
device=self.device
)
return kth_harmonic
@dask.delayed
def get_tension(self, harmonic: numpy.ndarray, base_harmonic: numpy.ndarray, length: numpy.ndarray):
tension = get_tension(
harmonic, base_harmonic, length,
hop_size=self.config.features.hop_size,
win_size=self.config.features.win_size
)
tension = self.smooth_curve(tension, smooth_fn_name="tension")
return tension
def try_load_curve_from_label_if_allowed(self, label: dict, curve_key: str, timestep_key: str, length: int):
if not self.config.prefer_ds:
return None
curve_text = label.get(curve_key)
if curve_text is None:
return None
curve = dask.delayed(resample_align_curve)(
numpy.array(curve_text.split(), numpy.float32),
original_timestep=float(label[timestep_key]),
target_timestep=self.timestep,
align_length=length
)
return curve