#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Copyright 2019 Tomoki Hayashi # MIT License (https://opensource.org/licenses/MIT) """Perform preprocessing and raw feature extraction.""" import argparse import logging import os import librosa import numpy as np import soundfile as sf import torch import yaml from scipy.interpolate import interp1d from tqdm import tqdm from parallel_wavegan.datasets import AudioDataset, AudioSCPDataset from parallel_wavegan.utils import write_hdf5 def logmelfilterbank( audio, sampling_rate, fft_size=1024, hop_size=256, win_length=None, window="hann", num_mels=80, fmin=None, fmax=None, eps=1e-10, log_base=10.0, ): """Compute log-Mel filterbank feature. Args: audio (ndarray): Audio signal (T,). sampling_rate (int): Sampling rate. fft_size (int): FFT size. hop_size (int): Hop size. win_length (int): Window length. If set to None, it will be the same as fft_size. window (str): Window function type. num_mels (int): Number of mel basis. fmin (int): Minimum frequency in mel basis calculation. fmax (int): Maximum frequency in mel basis calculation. eps (float): Epsilon value to avoid inf in log calculation. log_base (float): Log base. If set to None, use np.log. Returns: ndarray: Log Mel filterbank feature (#frames, num_mels). """ # get amplitude spectrogram x_stft = librosa.stft( audio, n_fft=fft_size, hop_length=hop_size, win_length=win_length, window=window, pad_mode="reflect", ) spc = np.abs(x_stft).T # (#frames, #bins) # get mel basis fmin = 0 if fmin is None else fmin fmax = sampling_rate / 2 if fmax is None else fmax mel_basis = librosa.filters.mel( sr=sampling_rate, n_fft=fft_size, n_mels=num_mels, fmin=fmin, fmax=fmax, ) mel = np.maximum(eps, np.dot(spc, mel_basis.T)) if log_base is None: return np.log(mel) elif log_base == 10.0: return np.log10(mel) elif log_base == 2.0: return np.log2(mel) else: raise ValueError(f"{log_base} is not supported.") def f0_torchyin( audio, sampling_rate, hop_size=256, frame_length=None, pitch_min=40, pitch_max=10000, ): """Compute F0 with Yin. Args: audio (ndarray): Audio signal (T,). sampling_rate (int): Sampling rate. hop_size (int): Hop size. pitch_min (int): Minimum pitch in pitch extraction. pitch_max (int): Maximum pitch in pitch extraction. Returns: ndarray: f0 feature (#frames, ). Note: Unvoiced frame has value = 0. """ torch_wav = torch.from_numpy(audio).float() if frame_length is not None: pitch_min = sampling_rate / (frame_length / 2) import torchyin pitch = torchyin.estimate( torch_wav, sample_rate=sampling_rate, pitch_min=pitch_min, pitch_max=pitch_max, frame_stride=hop_size / sampling_rate, ) f0 = pitch.cpu().numpy() nonzeros_idxs = np.where(f0 != 0)[0] f0[nonzeros_idxs] = np.log(f0[nonzeros_idxs]) return f0 def logf0_and_vuv_pyreaper(audio, fs, hop_size=64, f0min=40.0, f0max=500.0): """Extract continuous log f0 and uv sequences. Args: audio (ndarray): Audio sequence in float (-1, 1). fs (ndarray): Sampling rate. hop_size (int): Hop size in point. f0min (float): Minimum f0 value. f0max (float): Maximum f0 value. Returns: ndarray: Continuous log f0 sequence (#frames, 1). ndarray: Voiced (=1) / unvoiced (=0) sequence (#frames, 1). """ # delayed import import pyreaper # convert to 16 bit interger and extract f0 audio = np.array([round(x * np.iinfo(np.int16).max) for x in audio], dtype=np.int16) _, _, f0_times, f0, _ = pyreaper.reaper(audio, fs, frame_period=hop_size / fs) # get vuv vuv = np.float32(f0 != -1) if vuv.sum() == 0: logging.warn("All of the frames are unvoiced.") return # get start and end of f0 start_f0 = f0[f0 != -1][0] end_f0 = f0[f0 != -1][-1] # padding start and end of f0 sequence start_idx = np.where(f0 == start_f0)[0][0] end_idx = np.where(f0 == end_f0)[0][-1] f0[:start_idx] = start_f0 f0[end_idx:] = end_f0 # get non-zero frame index voiced_frame_idxs = np.where(f0 != -1)[0] # perform linear interpolation f = interp1d(f0_times[voiced_frame_idxs], f0[voiced_frame_idxs]) f0 = f(f0_times) # convert to log domain lf0 = np.log(f0) return lf0.reshape(-1, 1), vuv.reshape(-1, 1) def main(): """Run preprocessing process.""" parser = argparse.ArgumentParser( description=( "Preprocess audio and then extract features (See detail in" " parallel_wavegan/bin/preprocess.py)." ) ) parser.add_argument( "--wav-scp", "--scp", default=None, type=str, help="kaldi-style wav.scp file. you need to specify either scp or rootdir.", ) parser.add_argument( "--segments", default=None, type=str, help=( "kaldi-style segments file. if use, you must to specify both scp and" " segments." ), ) parser.add_argument( "--rootdir", default=None, type=str, help=( "directory including wav files. you need to specify either scp or rootdir." ), ) parser.add_argument( "--dumpdir", type=str, required=True, help="directory to dump feature files.", ) parser.add_argument( "--config", type=str, required=True, help="yaml format configuration file.", ) parser.add_argument( "--utt2spk", default=None, type=str, help=( "kaldi-style utt2spk file. If you want to add global conditionning with " "speaker id, you need to specify this argument." ), ) parser.add_argument( "--spk2idx", default=None, type=str, help=( "kaldi-style spk2idx file. If you want to add global conditionning with " "speaker id, you need to specify this argument." ), ) parser.add_argument( "--skip-mel-ext", default=False, action="store_true", help="whether to skip the extraction of mel features.", ) parser.add_argument( "--extract-f0", default=False, action="store_true", help="whether to extract f0 sequence.", ) parser.add_argument( "--allow-different-sampling-rate", default=False, action="store_true", help="whether to allow different sampling rate in config.", ) parser.add_argument( "--verbose", type=int, default=1, help="logging level. higher is more logging. (default=1)", ) args = parser.parse_args() # set logger if args.verbose > 1: logging.basicConfig( level=logging.DEBUG, format="%(asctime)s (%(module)s:%(lineno)d) %(levelname)s: %(message)s", ) elif args.verbose > 0: logging.basicConfig( level=logging.INFO, format="%(asctime)s (%(module)s:%(lineno)d) %(levelname)s: %(message)s", ) else: logging.basicConfig( level=logging.WARN, format="%(asctime)s (%(module)s:%(lineno)d) %(levelname)s: %(message)s", ) logging.warning("Skip DEBUG/INFO messages") # load config with open(args.config) as f: config = yaml.load(f, Loader=yaml.Loader) config.update(vars(args)) # check model architecture generator_type = config.get("generator_type", "ParallelWaveGANGenerator") use_f0_and_excitation = generator_type == "UHiFiGANGenerator" # check arguments if (args.wav_scp is not None and args.rootdir is not None) or ( args.wav_scp is None and args.rootdir is None ): raise ValueError("Please specify either --rootdir or --wav-scp.") # get dataset if args.rootdir is not None: dataset = AudioDataset( args.rootdir, "*.wav", audio_load_fn=sf.read, return_utt_id=True, ) else: dataset = AudioSCPDataset( args.wav_scp, segments=args.segments, return_utt_id=True, return_sampling_rate=True, ) # check directly existence if not os.path.exists(args.dumpdir): os.makedirs(args.dumpdir, exist_ok=True) if "sampling_rate_for_feats" not in config: sampling_rate = config["sampling_rate"] else: sampling_rate = config["sampling_rate_for_feats"] if use_f0_and_excitation: from parallel_wavegan.layers import SineGen ExcitationExtractor = SineGen(samp_rate=sampling_rate) # load spk2utt file if args.utt2spk is not None: with open(args.utt2spk) as f: lines = [line.replace("\n", "") for line in f.readlines()] utt2spk = {line.split()[0]: line.split()[1] for line in lines} with open(args.spk2idx) as f: lines = [line.replace("\n", "") for line in f.readlines()] spk2idx = {line.split()[0]: int(line.split()[1]) for line in lines} # process each data for utt_id, (audio, fs) in tqdm(dataset): # check assert len(audio.shape) == 1, f"{utt_id} seems to be multi-channel signal." assert ( np.abs(audio).max() <= 1.0 ), f"{utt_id} seems to be different from 16 bit PCM." assert ( fs == config["sampling_rate"] ), f"{utt_id} seems to have a different sampling rate." # trim silence if config["trim_silence"]: audio, _ = librosa.effects.trim( audio, top_db=config["trim_threshold_in_db"], frame_length=config["trim_frame_size"], hop_length=config["trim_hop_size"], ) if not args.skip_mel_ext: if "sampling_rate_for_feats" not in config: x = audio sampling_rate = config["sampling_rate"] hop_size = config["hop_size"] else: # NOTE(kan-bayashi): this procedure enables to train the model with different # sampling rate for feature and audio, e.g., training with mel extracted # using 16 kHz audio and 24 kHz audio as a target waveform x = librosa.resample( audio, orig_sr=fs, target_sr=config["sampling_rate_for_feats"] ) sampling_rate = config["sampling_rate_for_feats"] assert ( config["hop_size"] * config["sampling_rate_for_feats"] % fs == 0 ), ( "hop_size must be int value. please check sampling_rate_for_feats" " is correct." ) hop_size = config["hop_size"] * config["sampling_rate_for_feats"] // fs # extract feature mel = logmelfilterbank( x, sampling_rate=sampling_rate, hop_size=hop_size, fft_size=config["fft_size"], win_length=config["win_length"], window=config["window"], num_mels=config["num_mels"], fmin=config["fmin"], fmax=config["fmax"], ) # make sure the audio length and feature length are matched audio = np.pad(audio, (0, config["fft_size"]), mode="edge") audio = audio[: len(mel) * config["hop_size"]] assert len(mel) * config["hop_size"] == len(audio) # extract f0 sequence if args.extract_f0: l_ = logf0_and_vuv_pyreaper(audio, fs, config["hop_size"]) if l_ is None: continue l_ = np.concatenate(l_, axis=-1) if len(audio) > len(l_) * config["hop_size"]: audio = audio[: len(l_) * config["hop_size"]] if len(audio) < len(l_) * config["hop_size"]: audio = np.pad( audio, (0, len(l_) * config["hop_size"] - len(audio)), mode="edge" ) if use_f0_and_excitation: f0 = f0_torchyin( audio, sampling_rate=sampling_rate, hop_size=hop_size, frame_length=config["win_length"], ).reshape(-1, 1) if len(f0) > len(mel): f0 = f0[: len(mel)] else: f0 = np.pad(f0, (0, len(mel) - len(f0)), mode="edge") extended_f0 = ( torch.from_numpy(f0) .reshape(1, 1, -1) .repeat(1, config["hop_size"], 1) .reshape(1, -1, 1) ) sine_waves, _, _ = ExcitationExtractor(extended_f0) excitation = sine_waves.squeeze(0).squeeze(-1).cpu().numpy() excitation = excitation[: len(mel) * config["hop_size"]] excitation = excitation.reshape(-1, config["hop_size"]) f0 = np.squeeze(f0) # (#frames,) excitation = np.squeeze(excitation) # (#frames, hop_size) # apply global gain if config["global_gain_scale"] > 0.0: audio *= config["global_gain_scale"] if np.abs(audio).max() >= 1.0: logging.warn( f"{utt_id} causes clipping. " "it is better to re-consider global gain scale." ) continue # save if config["format"] == "hdf5": write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "wave", audio.astype(np.float32), ) if not args.skip_mel_ext: write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "feats", mel.astype(np.float32), ) if use_f0_and_excitation: write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "f0", f0.astype(np.float32), ) write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "excitation", excitation.astype(np.float32), ) if args.extract_f0: write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "local", l_.astype(np.float32), ) elif config["format"] == "npy": np.save( os.path.join(args.dumpdir, f"{utt_id}-wave.npy"), audio.astype(np.float32), allow_pickle=False, ) if not args.skip_mel_ext: np.save( os.path.join(args.dumpdir, f"{utt_id}-feats.npy"), mel.astype(np.float32), allow_pickle=False, ) if use_f0_and_excitation: np.save( os.path.join(args.dumpdir, f"{utt_id}-f0.npy"), f0.astype(np.float32), allow_pickle=False, ) np.save( os.path.join(args.dumpdir, f"{utt_id}-excitation.npy"), excitation.astype(np.float32), ) if args.extract_f0: np.save( os.path.join(args.dumpdir, f"{utt_id}-local.npy"), l_.astype(np.float32), allow_pickle=False, ) else: raise ValueError("support only hdf5 or npy format.") # save global embedding if config.get("use_global_condition", False): spk = utt2spk[utt_id] idx = spk2idx[spk] if config["format"] == "hdf5": write_hdf5( os.path.join(args.dumpdir, f"{utt_id}.h5"), "global", int(idx) ) elif config["format"] == "npy": np.save( os.path.join(args.dumpdir, f"{utt_id}-global.npy"), int(idx), allow_pickle=False, ) if __name__ == "__main__": main()