はじめに
前回の報告
で、自己回帰型ですが transformer を使ったメルスペクトログラムの合成についてご報告させていただきました。こののち、非自己回帰型のテストをするのは、自然な流れと考えられるます。わたくしも、transformer の考え方を使って、非自己回帰型の音声合成の実験をしました。一応、韻律付き音素(Python で学ぶ音声合成の JSUT のデータ)からメルスペクトログラムを推論し、HiFiGAN の助けを借りて、聞き取れる音声を合成することができました。大幅に Fastspeech
と Fastspeech2
espnet2 の Fastspeech2
を参考にさせていただきました。心より感謝します。
合成した音声です。
JSUT の wav と label を使って 「python で学ぶ音声合成」の tacotron2 の学習に準拠して、学習させました。train 4700 発話、dev 200 発話、test 100発話、batch_size = 32。
200エポック学習
BASIC4999
https://drive.google.com/file/d/1Ci47nOi6mbzSSFwFYtg3-2qTsm6yBXgT/view?usp=sharing
BASIC5000
https://drive.google.com/file/d/18Nl52pTW7pvQgJhyr4vhXND6lptCTv_z/view?usp=sharing
500エポック学習
BASIC4999
https://drive.google.com/file/d/106uE7rqciR9muGEADaRkYW_ogN6j_t--/view?usp=sharing
BASIC5000
https://drive.google.com/file/d/1qk2rt6AcuM4MpN-61DOIQLgZrAXxN_3Y/view?usp=sharing
600エポック学習
BASIC4999
https://drive.google.com/file/d/1d6ytKNnxsqLiOQSczBPUrBoaJG6KsGuZ/view?usp=sharing
BASIC5000
https://drive.google.com/file/d/1IgS5KEe-pdHNnOzOuPr7BN9lb5WzTPbi/view?usp=sharing
推論したメルスペクトログラム
600 エポック学習時に推論したメルスペクトログラムです。上二つが BASIC4999 です。 1番目が wav ファイルから生成したメルスペクトログラムで、2番目が韻律付き音素から推論したメルスペクトログラム。下二つが BASIC5000 です。3番目が wav ファイルから生成したメルスペクトログラムで、4番目が韻律付き音素から推論したメルスペクトログラムです。
学習曲線
600エポック学習までの学習曲線(エポックと損失loss )です。上側が訓練データの loss で、下側が開発データの loss です。
一番の問題点は、duration でした。
上記の前回の報告のソースプログラムを改修して、エンコーダーの出力をメルスペクトログラムのフレームシフトの数(hop の数)分にアップサンプリングしてデコーダーの cross attention の q 入力としました。また、cross attention のもう一方の入力 k, v は、エンコーダー出力としました。
一番の問題点は、アップサンプリングする時の、音声の長さがうまく決められないことでした。学習中は、教師データの音声の長さに合わせていれば良いのですが、推論時は、エンコーダー出力から音声1発話の長さを予測するようなモジュールを入れて学習させても、正確に音声の長さを予測することができませんでした。これにより、音声の途中で、同じ言葉を繰り返したり、音声が中抜けしたりしました。
単にエンコーダーの出力を非自己回帰でデコーダーに入れただけでは、ガサガサいってダメだった。
一方、サンプリングレート 16000Hz のままでは、合成した音声がガサついて、音声を合成したとは言えないものでした。このガサ付きを少なくしたのが、サンプリングレートを 22050Hz にして、自己回帰型の wavnet から 非自己回帰型の HiFiGAN
で音声出力を生成することでした。これで、かなり音質が良くなりました。この過程で、メルスペクトログラムの仕様を、「python で学ぶ音声合成」で用いられていたメルスペクトログラムから、HiFiGAN の inference.py の get_mel 関数に変更しました。こうしないと、HiFiGAN で音声生成した時に、ピーというノイズが入りました。HiFiGAN のメルスペクトログラムにしたらうまくいきました。また、「python で学ぶ音声合成」では、mu-law 量子化をしていましたが、HiFiGAN では、wav 波形自体を生成します。このため、JSUT のデータを用いて、HiFiGAN 用のメルスペクトログラムと 22050Hz の wav データを作り、HiFiGAN に学習せました。
Fastspeech の DurationPredictor と LengthRegulatorを真似ました。
Fastspeech の DurationPredictor と LengthRegulator を参考にして、エンコーダー出力の時間的に 1フレームをどの程度の duration にアップサンプリングするかを学習させました。duration の学習のためのデータは、後で詳しく述べますが、音素継続長モデルの duration を用いました。推論時に duration の予測を用いて、エンコーダーの出力をデコーダーの入力にアップサンプリングするようにしました。アップサンプリングしたデータをデコーダーの cross attention の q 入力としました。k, v 入力はエンコーダー出力です。これにより、ようやく、HiFiGAN で音声生成して聞いて意味を理解できる音声が生成できました。
duration の学習について。
音声合成の推論に duration を用いるためには、duration データを作り、学習させる必要があります。duration と in_feats は、「python で学ぶ音声合成」の preprocess.py を修正して、音素継続長モデルを用いて new_preprocess.py として次のプログラムを使用しました。
注意事項 苦肉の策
音素継続長モデルでは、通常の音素の duration のみを考慮しています。韻律 prosody の duration は考慮されていません。継続長モデルの duration を用いて、DurationPredictor の学習を行うために、韻律がある場合、韻律の直前の音素と韻律を結合させ一つの新しい音素とし、韻律直前の通常の音素の継続長モデルの duration を、新しい音素とした「通常の音素+韻律」の duration としました。このため、語彙数が 52 から 59 に増えました。
実は、韻律を考慮しない通常の音素だけで、TTS を学習させてみたのですが、推論してみるとアクセントがおかしいような印象を受けたため、苦肉の策で韻律を取り入れたら、それなりに音声合成できているようです。
import argparse
import sys
import os
sys.path.append( "./hifi-gan-master/" )
from meldataset import mel_spectrogram, MAX_WAV_VALUE, load_wav
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import librosa
import numpy as np
import torch
from nnmnkwii.io import hts
from nnmnkwii.frontend import merlin as fe
from nnmnkwii.preprocessing import mulaw_quantize
from scipy.io import wavfile
from scipy.io.wavfile import read, write
from tqdm import tqdm
from ttslearn.dsp import logmelspectrogram
from ttslearn.tacotron.frontend.openjtalk import pp_symbols, text_to_sequence
from ttslearn.util import pad_1d
def get_parser():
parser = argparse.ArgumentParser(
description="Preprocess for Tacotron",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("utt_list", type=str, help="utternace list")
parser.add_argument("wav_root", type=str, help="wav root")
parser.add_argument("lab_root", type=str, help="lab_root")
parser.add_argument("out_dir", type=str, help="out directory")
parser.add_argument("--n_jobs", type=int, default=1, help="Number of jobs")
parser.add_argument("--sample_rate", type=int, default=22050, help="Sample rate")
parser.add_argument("--mu", type=int, default=255, help="mu")
parser.add_argument("--n_fft", type=int, default=1024, help="n_fft")
parser.add_argument("--n_mels", type=int, default=80, help="n_mels" )
parser.add_argument("--hop_length", type=int, default=256, help="hop_length" )
parser.add_argument("--win_length", type=int, default=1024, help="win_length" )
parser.add_argument("--f_min", type=int, default=0, help="f_min" )
parser.add_argument("--f_max", type=int, default=8000, help="f_max" )
return parser
def get_mel(x, n_fft, n_mels, sample_rate, hop_length, win_length, f_min, f_max):
return mel_spectrogram(x, n_fft, n_mels, sample_rate, hop_length, win_length, f_min, f_max)
def preprocess(
wav_file,
lab_file,
sr,
mu,
n_fft,
n_mels,
win_length,
hop_length,
f_min,
f_max,
in_dir,
out_dir,
wave_dir,
):
phone_to_id = {'~': 0, '^': 1, 'm': 2, 'i[': 3, 'z': 4, 'u': 5, 'o#': 6, 'a[': 7, 'r': 8, 'e]': 9,\
'e': 10, 'sh': 11, 'i': 12, 'a': 13, 'k': 14, 'a#': 15, 'w': 16, 'n': 17, 'a]': 18, 't': 19,\
'o': 20, 'd': 21, 's': 22, '$': 23, 'o[': 24, 'y': 25, 'o]': 26, 'b': 27, '_': 28, 'e[': 29,\
'N': 30, 'u[': 31, 'ry': 32, 'j': 33, 'g': 34, 'i]': 35, 'h': 36, 'ts': 37, 'cl': 38, 'u]': 39,\
'ny': 40, 'i#': 41, 'p': 42, 'e#': 43, 'f': 44, 'gy': 45, 'ky': 46, 'ch': 47, 'N#': 48, 'u#': 49,\
'?': 50, 'hy': 51, 'my': 52, 'N]': 53, 'by': 54, 'py': 55, 'cl[': 56, 'v': 57, 'dy': 58}
id_to_phone = {0: '~', 1: '^', 2: 'm', 3: 'i[', 4: 'z', 5: 'u', 6: 'o#', 7: 'a[', 8: 'r', 9: 'e]',\
10: 'e', 11: 'sh', 12: 'i', 13: 'a', 14: 'k', 15: 'a#', 16: 'w', 17: 'n', 18: 'a]', 19: 't',\
20: 'o', 21: 'd', 22: 's', 23: '$', 24: 'o[', 25: 'y', 26: 'o]', 27: 'b', 28: '_', 29: 'e[',\
30: 'N', 31: 'u[', 32: 'ry', 33: 'j', 34: 'g', 35: 'i]', 36: 'h', 37: 'ts', 38: 'cl', 39: 'u]',\
40: 'ny', 41: 'i#', 42: 'p', 43: 'e#', 44: 'f', 45: 'gy', 46: 'ky', 47: 'ch', 48: 'N#', 49: 'u#',\
50: '?', 51: 'hy', 52: 'my', 53: 'N]', 54: 'by', 55: 'py', 56: 'cl[', 57: 'v', 58: 'dy'}
assert wav_file.stem == lab_file.stem
labels = hts.load(lab_file)
# 韻律記号付き音素列の抽出
PP = pp_symbols(labels.contexts)
#in_feats = text_to_sequence( PP )
# 継続長モデルから duration を読み込み
durations_orig = fe.duration_features( labels )
# メルスペクトログラムの計算
_sr, x = wavfile.read(wav_file)
# x は、melspectrogram を計算するために。xx は、wav ファイルに書き込むために sr=22050Hz の PCM16。
xx = x
xx = librosa.resample( xx.astype(np.float32), orig_sr=_sr, target_sr=sr).astype(np.int16)
if x.dtype in [np.int16, np.int32]:
x = (x / np.iinfo(x.dtype).max).astype(np.float64)
x = librosa.resample(x, orig_sr=_sr, target_sr=sr)
x = x[ np.newaxis, :]
x = torch.from_numpy(x).float()
out_feats = get_mel( x, n_fft, n_mels, sr, hop_length, win_length, f_min, f_max )
out_feats = torch.squeeze( out_feats, dim = 0 ).cpu().detach().numpy()
# 冒頭と末尾の非音声区間の長さを調整
assert "sil" in labels.contexts[0] and "sil" in labels.contexts[-1]
start_frame = int(labels.start_times[1] / 116100)
end_frame = int(labels.end_times[-2] / 116100)
# 冒頭: 50 ミリ秒、末尾: 100 ミリ秒 22050Hz の hop_length (256frame) は、0.0116100 秒。
start_frame = max(0, start_frame - int(0.050 / 0.01161))
end_frame = min(out_feats.shape[1], end_frame + int(0.100 / 0.01161))
out_feats = out_feats[:,start_frame:end_frame] # HiFiGan 用 melspectrogram
out_feats2 = np.transpose( out_feats, ( 1, 0 )) # Transtron 用 melspectrogram
# duration の計算。
# 基本音素 + "^" BOS + "$" EOS。 duration_orig の継続長に対応した音素と記号。韻律は考えない。
onso = ["m","i","z","u","o","a","r","e","sh","k","w","n","U","t","d","s","y","b","_","N","ry",\
"I","j","g","h","ts","cl","ny","p","f","gy","ky","ch","hy","my","by","py","v","dy", "$", "^", "?"]
n = 0
#duration_PP = []
Together_Prosody = []
for PPP in PP:
#print( "n:{}, PPP:{}".format( n, PPP ) )
if PPP in onso:
#duration_PP.append( float( durations_orig[n] ) )
Together_Prosody.append( PPP )
#print( "n:{}, Together_Prosody[n]:{}".format( n, Together_Prosody[n] ))
#last_PPP = PPP
n += 1
else:
Together_Prosody[n-1] = Together_Prosody[n-1] + PPP
#Together_Prosody[n-1] = last_PPP + PPP
#print( "n-1:{}, Together_Prosody[n]:{}".format( n-1, Together_Prosody[n-1] ))
#duration_PP.append( 0 )
#durations_PP_np = np.array( duration_PP )
durations_np = np.array( durations_orig )
durations_np = np.squeeze( durations_np, -1 )
#print( "n:{}".format( n ))
#print( "shape of durations_np:{}".format( durations_np.shape ))
#print( "shape of Together_Porosody:{}".format( np.array( Together_Prosody ).shape ))
in_feats = []
for Together in Together_Prosody:
in_feats.append( phone_to_id[ Together ] )
in_feats_np = np.array( in_feats )
#print( "shape of in_feats:{}".format( in_feats_np.shape ))
durations_np[0] = 10 # BOS ^ については、0.05秒 0.3秒が 60 なので、
durations_np[-1] = 20 # EOS $ については、0.10秒
out_len = out_feats.shape[1]
# 継続長モデル(16000Hz)の duration の sum を、全体で、out_feats.shape[1] になるように規格化
durations = durations_np * out_len / np.sum( durations_np )
#durations = np.round( durations.astype(np.float) ).astype(np.long)
# 時間領域で音声の長さを調整
xx = xx[int(start_frame * 256) :]
length = 256 * out_feats.shape[1]
xx = pad_1d(xx, length) if len(xx) < length else xx[:length]
# 特徴量のアップサンプリングを行う都合上、音声波形の長さはフレームシフト(hop_length)で割り切れる必要があります
assert len(xx) % 256 == 0
# save to files
#in_feats_np = np.array( in_feats )
#print( "shape of in_feats:{}".format( in_feats_np.shape))
#print( "shape of durations:{}".format( durations.shape ))
#print( "shape of out_feats:{}".format( out_feats.shape ))
#print( "shape of out_feats2:{}".format( out_feats2.shape ))
#print( "shape of xx:{}".format( xx.shape ))
#if np.array( in_feats ).shape[0] == 96 and durations.shape[0] == 97:
# print( "basename:{}".format( lab_file.stem ) )
# print( "in_feats.shape[0]:{}".format( np.array( in_feats ).shape[0] ))
# print( "durations.shape[0]:{}".format( durations.shape[0] ))
if np.array( in_feats).shape[0] != durations.shape[0]:
print( "basename:{}".format( lab_file.stem ) )
print( "in_feats.shape[0]:{}".format( np.array( in_feats ).shape[0] ))
print( "durations.shape[0]:{}".format( durations.shape[0] ))
print( "Together_Prosody:{}".format( Together_Prosody ))
utt_id = lab_file.stem
np.save(in_dir / f"{utt_id}-feats.npy", in_feats, allow_pickle=False)
in_dir2 = str(in_dir).replace( "org", "norm" )
np.save(in_dir2 + "/" + f"{utt_id}-feats.npy", in_feats, allow_pickle=False)
np.save(
out_dir / f"{utt_id}-feats.npy",
out_feats2.astype(np.float32),
allow_pickle=False,
)
out_dir2 = str(out_dir).replace( "org", "norm" )
np.save(
out_dir2 + "/" + f"{utt_id}-feats.npy",
out_feats2.astype(np.float32),
allow_pickle=False,
)
out_dir_dur = str( out_dir2 ).replace( "out_tacotron", "out_duration" )
np.save(
out_dir_dur + "/" + f"{utt_id}-feats.npy",
#durations.astype(np.long),
durations.astype(np.float32),
allow_pickle=False,
)
np.save(
f"./hifi-gan-master/JSUT/mels/{utt_id}.npy",
out_feats.astype(np.float32),
allow_pickle=False,
)
writefilename = f"./hifi-gan-master/JSUT/wavs/{utt_id}.wav"
write(writefilename, rate=22050, data = xx)
if __name__ == "__main__":
args = get_parser().parse_args(sys.argv[1:])
with open(args.utt_list) as f:
utt_ids = [utt_id.strip() for utt_id in f]
wav_files = [Path(args.wav_root) / f"{utt_id}.wav" for utt_id in utt_ids]
lab_files = [Path(args.lab_root) / f"{utt_id}.lab" for utt_id in utt_ids]
in_dir = Path(args.out_dir) / "in_tacotron"
out_dir = Path(args.out_dir) / "out_tacotron"
wave_dir = Path(args.out_dir) / "out_wavenet"
in_dir.mkdir(parents=True, exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True)
wave_dir.mkdir(parents=True, exist_ok=True)
with ProcessPoolExecutor(args.n_jobs) as executor:
futures = [
executor.submit(
preprocess,
wav_file,
lab_file,
args.sample_rate,
args.mu,
args.n_fft,
args.n_mels,
args.win_length,
args.hop_length,
args.f_min,
args.f_max,
in_dir,
out_dir,
wave_dir,
)
for wav_file, lab_file in zip(wav_files, lab_files)
]
for future in tqdm(futures):
future.result()
#print( future )
このプログラムでは、preprocess.py のPP にあたる Together_Prosody には、通常の音素と新しい音素以外に、BOS と EOS が含まれます。BOS と EOS は、0.05秒と 0.10 秒に対応した10と20 としました。16000Hz と 22050 Hzの違いがあるので、durations の合計が、メルスペクトログラムの時間フレーム数になるように規格化しました。このため、Fastspeech などでは、long 型になっている duration を、通常では float で扱い、LengthRegulator の torch.repeat_interleave の第二引数とするために、torch.round( duration_float ).long() としました。
学習は、MSELoss で行い、loss を
duration_loss = nn.MSELoss( predicted_duration, duration_feats )
loss = decoder_out_loss + postnet_out_loss + duration_loss
としました。
Attention Block を、Fastspeech2 のモジュールを真似て改修しました。
Attention Block の MultiHeadAttention と PositionwiseFeedForward は、Fastspeech2 を真似ました。ただし、Decoder での MultiHeadAttention は、cross attention を計算できるように改修しました。
プログラム
参考のため、学習と評価に使ったプログラムを github に置いておきます。
https://github.com/toshiouchi/transtron_non_ar
評価
それなりに音声合成できていると評価しています。