0%

FastSpeech2

摘要

FastSpeech2:基于Transformer的非自回归TTS

Paper

https://arxiv.org/abs/2006.04558

代码详解

https://github.com/ming024/FastSpeech2该代码使用 pytorch框架

1. Dataset 与 DataLoader

Dataset 是 PyTorch 中用来表示数据集的一个抽象类,我们的数据集可以用这个类来表示,至少复写下面两个方法即可:

1
2
__len__: 数据集大小
__getitem__: 可以通过下标的方式来获取第 i 个数据

FastSpeech2代码中通过 preprocess_configtrain_config 以及之前处理的train.txt文件构建数据集

  • train.txt 构造如下(以标贝数据为例):数据以 | 分割,包含了“文件名”|“说话人”|“音素”|“拼音”,对应代码中变量 basename, speaker, text, raw_text
1
008894|BZNSYP|{sh e3 d e2 sh e3 d e2 sp y iou2 sh e3 c ai2 y iou3 d e2 sp sh e3 d e5 sp sh e3 de2 sp g ai1 sh e3 d e5 y iao4 sh e3 sp g ai1 d e2 d e5 y iao4 d e2}|she3 de2 she3 de2 you2 she3 cai2 you3 de2 she3 de5 she3 de2 gai1 she3 de5 yao4 she3 gai1 de2 de5 yao4 de2

speaker_map 保存在 speakers.json 中,保存了 speaker 对应的 id:

1
{"BZNSYP": 0}

首先,定义 __len__,这里根据text这个列表的 len()计算即可;

其次,定义__getitem__

1
2
3
4
5
6
7
8
"id": basename,                 # 008565
"speaker": speaker_id, # 0
"text": phone, # [12, 31, 213, 34, 41]
"raw_text": raw_text, # she3 de2 she3 de2 you2 she3 cai2
"mel": mel, # np.array (466, 80) 466不固定
"pitch": pitch, # np.array (55, ) 55不固定
"energy": energy, # np.array (55, )
"duration": duration, # np.array (55, )

注意:mel、pitch、energy与duration是预处理保存的npy文件,对应的维数与 音频处理 中的帧、窗口等相关。后续详细介绍 preprocess.py文件的时候再解释。

解释数据细节:

  • phone
    这里表示的是音素对应的序号,有中文音素、英文音素及特殊字符,后续输入网络时,会把这些序号embedding到一个序列。
    音素到序号对应表如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 英文共包含 84 个音素字符,来自cmudict,借鉴自 tacotron(https://github.com/keithito/tacotron)
valid_symbols = [
"AA",
"AA0",
"AA1",
......
"Z",
"ZH",
]
# 中文包含开头 23 个字符,结尾 185 个字符,再包括一个儿化音字符 “rr”,中文共209个字符,来自 pinyin
initials = [
"b",
"c",
......
"z",
"zh",
]
finals = [
"a1",
"a2",
"a3",
......
"vn4",
"vn5",
]
# 特殊的字符如下
_pad = "_"
_punctuation = "!'(),.:;? "
_special = "-"
_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
_silences = ["@sp", "@spn", "@sil"]

# 音素中都是以@开头
_arpabet = ["@" + s for s in cmudict.valid_symbols]
_pinyin = ["@" + s for s in pinyin.valid_symbols]

Dataset 中的 collate_fn 函数的功能,以及本代码中collate_fn

collate_fn 的输入是一批样本,对batch进行处理,在这里我们text的长度,mel、ptich等,每个样本的长度是不一样的,我们需要将每一个 batch 中各样本长度 pad 到一致!这就有了 reprocess 和 collate_fn 这两个方法了。

reprocess这个函数发挥了主要功能:pad,返回的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
ids,                      # list, ['004717', '008813', ...] 一个batchsize的 文件名
raw_texts, # list, 一个batchsize的 拼音/英文 序列
speakers, # array, 说话人对应的标号 [0, 0, ...]
texts, # array,pad 后的 音素对应的标号,按照0进行pad
text_lens, # array, 每个样本texts的长度 [34, 33, ... 12]
max(text_lens), # 最大长度, 34
mels, # np.array, (16, max_len, 80)
mel_lens, # np.array, 每个样本mels 的长度
max(mel_lens), # 最大长度, 372
pitches, # 和 text 的长度是对应的, 短的样本 pad 0
energies, # 同 pitches
durations, # 同 durations

collate_fn这个函数功能:借助reprocess,同时 进行 sort 和 drop_last,返回结果:

1
list, [(reprocess的结果) * group_size], 

DatLoader 本质是一个可迭代对象,使用iter()访问,每次返回一个batchsize的数据,提供了shuffle。

2. Model and Optimizer(模型和优化器)

放一张FastSpeech2论文里的模型框架图吧!
FastSpeech2

主要的结构是:Encoder + Variance Adaptor + Mel-spectrogram Decoder

  • Encoder:变异Transformer
  • Variance Adaptor:
  • Mel-spectrogram Decoder: 变异Transformer

前向传播 forward:

1
2
3
4
5
6
7
8
9
10
11
12
13
speakers,               # 同 reprocess 返回结果
texts,
src_lens,
max_src_len,
mels=None,
mel_lens=None,
max_mel_len=None,
p_targets=None,
e_targets=None,
d_targets=None,
p_control=1.0, # 控制 pitch
e_control=1.0, # 控制 energy
d_control=1.0, # 控制 duration

首先要介绍这个 mask,由于我们之前将 texts 中长度不一样的进行了 pad,那么在以后的计算中是要mask的。

1
2
3
get_mask_from_lengths:
输入:src_lens (一个batch中每一个样本text的长度), max_src_len(一个batch中text的最大长度)
输出:np.array, shape=(batch_size, max_src_len), 对应的元素为是否应该被 mask

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
src_lens = tensor([1, 3, 5, 6])
max_src_len = 6

ids = tensor([[0, 1, 2, 3, 4, 5],
[0, 1, 2, 3, 4, 5],
[0, 1, 2, 3, 4, 5],
[0, 1, 2, 3, 4, 5]])

src_lens_expand = tensor([[1, 1, 1, 1, 1, 1],
[3, 3, 3, 3, 3, 3],
[5, 5, 5, 5, 5, 5],
[6, 6, 6, 6, 6, 6]])
mask = ids >= src_lens_expand = \
tensor([[False, True, True, True, True, True],
[False, False, False, True, True, True],
[False, False, False, False, False, True],
[False, False, False, False, False, False]])
shape 是 (barch_size, max_src_len)
这些Ture都是原来Pad的,后续计算需要mask的!

接下来,看一下Encoder这里详细的过程了,顺便介绍一下Transformer结构,以及看一看Q、K、V在这里到底学到的是什么东东!

  • src_word_emb:将 text 中的音素,对应到一个 256 维的向量,利用pytorch中的nn.Embedding即可。
  • get_sinusoid_encoding_table: Sinusoid position encoding table

这个 Positional Encoding 是 Transformer 特有的,论文中提到由于没有用到RNN和CNN提取特征,所以没有很好的应用位置信息,所以需要在 input Embedding 后加上 Positional Encodeing。公式如下:

PE(pos,2i)=sin(pos/10000(2i/dmodel))PE_{(pos, 2i)} = sin(pos/10000^{(2i/d_{model})})

PE(pos,2i+1)=cos(pos/10000(2i/dmodel))PE_{(pos, 2i+1)} = cos(pos/10000^{(2i/d_{model})})

这个参数是不需要学习的,由于sin与cos的关系,PE_{(pos + k)}与PE_{(pos)}可以很容易的表示成线性关系。

  • FFTBlock:MultiHeadAttention 和 PositionwiseFeedForward 构成

注意力机制:

<后续整理>,相关内容可参考李宏毅ML中的介绍 self attention.

VarianceAdaptor 是 FastSpeech2 的核心,主要分为以下三个部分:

  • LengthRegulator(这个FastSpeech就有了)
  • duration_predictor(这个是比较关键的)
  • pitch_predictor and energy_predictor

下图是FastSpeech的流程图,可以更好的理解 LengthRegulator:
FastSpeech

duation and ptich and energy predictors 都用了一个框架:

  • 两次 {Conv1D + ReLU + LN + Dropout} + Linear Layer
    这里需要注意的是,Encoder解码得到的输出维度是 (16, max_text_len, 256),在进行卷积运算时,需要转换为 (16, 256, max_text_len), 利用 x = x.contiguous().transpose(1, 2) 即可实现,卷积运算完成之后进行后续 ReLU … 等操作!(这个是由于 pytorch 框架导致的),在conv1d中输入是 [batch, channels, w],conv2d中输入是 [batch, channels, H, W]。

接下来,Encoder 的输出分别输入到 duation、pitch和energy的预测器中,输出结果为 [batch, max_text_len],
这里将pitch 和 energy 的预测值加到了 Encoder 的输出上,加的过程用到了pitch_embedding.

由于pitch和energy都是一个值,这里用到了Pytorch中一个不是特别常见的函数torch.bucketize(input, boundaries, *, out_int32=False, right=False, out=None) → Tensor。这是Pytorch中的分桶函数,boundaries确定了各个桶的边界,是一个单调递增向量,用于划分input,并返回input所属桶的索引,桶索引从0开始。然后利用nn.Embedding 将其映射到 256 维。

1
2
3
4
5
6
7
8
9
10
self.pitch_embedding = nn.Embedding(
n_bins, model_config["transformer"]["encoder_hidden"]
)
self.pitch_bins = nn.Parameter(
torch.linspace(pitch_min, pitch_max, n_bins - 1),
requires_grad=False,
)
self.pitch_embedding(
torch.bucketize(prediction, self.pitch_bins)
)

将 Encoder 的输出 加 ptich和energy的值,利用LR对齐到mel_len。

LengthRegulator介绍:

1
2
3
4
5
6
7
8
9
10
11
def expand(self, batch, predicted):
out = list()
# print(batch.shape) 这里batch 代表一个样本 shpe = (max_len_text, 256)
for i, vec in enumerate(batch):
# print(i) # 这里是每个 音素
# print(vec.shape) 256
expand_size = predicted[i].item() # 3
out.append(vec.expand(max(int(expand_size), 0), -1)) # [(3, 256), (4, 256), (2, 256), ...] 代表 每个音素
out = torch.cat(out, 0) # 按照 0 轴进行拼接

return out

Decoder 与 Encoder结构一样,不过变成了 6 层。

mel_linear 将 256 维数 变为 n_mel_channels: 80。

postnet 精细化 mel 谱,这是 Tacotron2 留下来的,5个1维卷积层

至此,FastSpeech2 的网络结构介绍完毕!


  • Optimizer 优化器

这里利用了 Adam 算法,又加入了一些其他功能,详细介绍如下:

超参数:

1
2
3
4
5
6
7
8
9
10
optimizer:
batch_size: 16
betas: [0.9, 0.98]
eps: 0.000000001
weight_decay: 0.0
grad_clip_thresh: 1.0 # Clipping gradients to avoid gradient explosion
grad_acc_step: 1 # Clipping gradients to avoid gradient explosion
warm_up_step: 4000 # 开始降低学习率
anneal_steps: [300000, 400000, 500000]
anneal_rate: 0.3 # 30W 以后,还要降低学习率

定义优化器:

1
2
3
4
5
6
torch.optim.Adam(
model.parameters(),
betas=train_config["optimizer"]["betas"],
eps=train_config["optimizer"]["eps"],
weight_decay=train_config["optimizer"]["weight_decay"],
)

初始化学习率:

1
2
encoder_hidden = 256
self.init_lr = np.power(model_config["transformer"]["encoder_hidden"], -0.5) # 0.0625

更新学习率:

1
按学习步骤进行降低

3. 利用 TensorBoard 可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
train_logger = SummaryWriter(train_log_path)
log(train_logger, step, losses=losses)
log(
train_logger,
fig=fig,
tag="Training/step_{}_{}".format(step, tag),
)
log(
train_logger,
audio=wav_reconstruction,
sampling_rate=sampling_rate,
tag="Training/step_{}_{}_reconstructed".format(step, tag),
)

4. 音频、文本数据处理

  1. 将音频及文本文件处理成 <text, wav>对儿文件,为后续MFA及提取mel、duration、pitch及enengy做准备。
    下面以baker数据为例进行介绍:
    这里主要是对原始音频按照sampling_rate进行了采样,并进行了最大值“归一化”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import os

import librosa
import numpy as np
from scipy.io import wavfile
from tqdm import tqdm


def prepare_align(config):
in_dir = config["path"]["corpus_path"] # 原始音频保存位置
out_dir = config["path"]["raw_path"] # <.lab, .wav>保存位置
sampling_rate = config["preprocessing"]["audio"]["sampling_rate"] # 采样率
max_wav_value = config["preprocessing"]["audio"]["max_wav_value"] # 16bit 32768
speaker = "BZNSYP" # 说话人
i = 1
# 打开保存的 标注文件,获取每一个样本的拼音
with open(os.path.join(in_dir, "ProsodyLabeling", "000001-010000.txt"), encoding="utf-8") as f:
for line in tqdm(f):
if i % 2 == 1:
wav_name = line[:6]
wav_path = os.path.join(in_dir, "Wave", "{}.wav".format(wav_name))
if os.path.exists(wav_path):
os.makedirs(os.path.join(out_dir, speaker), exist_ok=True)
wav, _ = librosa.load(wav_path, sampling_rate) # 按照采样率 22050进行采样
wav = wav / max(abs(wav)) * max_wav_value # 对wav进行归一化
wavfile.write(
os.path.join(out_dir, speaker, "{}.wav".format(wav_name)),
sampling_rate,
wav.astype(np.int16),
) # 保存wav文件
if i % 2 == 0:
text = line.strip("\n").strip("\t")
with open(
os.path.join(out_dir, speaker, "{}.lab".format(wav_name)),
"w",
) as f1:
f1.write(text) # 保存 拼音 文件
i += 1

  1. 利用 MFA 工具 提取音素到mel的对齐信息 TextGrid。具体见本博客中 MFA安装及使用。

  2. 利用 TextGrid 与 <.lab, .wav>获得 mel, duration, pitch, energy 及 stats, speaker 及 train, val 数据集

下面以单一样本 LJS001-0001 为例进行解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 首先利用 `tgt` 包读入 TextGrid
import tgt
textgrid = tgt.io.read_textgrid(tg_path)
tier = textgrid.get_tier_by_name("phones")

# 其次利用得到的tier计算 phones, duration, start_time, end_time
# 这里将 开头及结尾 静音 silence 进行了过滤
def get_alignment(self, tier):
sil_phones = ["sil", "sp", "spn"]

phones = []
durations = []
start_time = 0
end_time = 0
end_idx = 0
for t in tier._objects:
s, e, p = t.start_time, t.end_time, t.text

# Trim leading silences
if phones == []:
if p in sil_phones:
continue
else:
start_time = s

if p not in sil_phones:
# For ordinary phones
phones.append(p)
end_time = e
end_idx = len(phones)
else:
# For silent phones
phones.append(p)

durations.append(
int(
np.round(e * self.sampling_rate / self.hop_length)
- np.round(s * self.sampling_rate / self.hop_length)
)
)

# Trim tailing silences
phones = phones[:end_idx]
durations = durations[:end_idx]

return phones, durations, start_time, end_time

这里 duration 是 每个音素对应的帧数,需要用到sampling_rate 与 hop_length.
pitch 和 energy 是wav文件计算的

1
2
3
4
5
6
7
8
9
10
11
# 计算 pitch
import pyworld as pw

pitch, t = pw.dio(
wav.astype(np.float64),
self.sampling_rate,
frame_period=self.hop_length / self.sampling_rate * 1000,
)
pitch = pw.stonemask(wav.astype(np.float64), pitch, t, self.sampling_rate)

pitch = pitch[: sum(duration)]
1
2
3
4
# 利用 Tacotron 中 STFT  从wav中获得mel与energy
mel_spectrogram, energy = Audio.tools.get_mel_from_wav(wav, self.STFT)
mel_spectrogram = mel_spectrogram[:, : sum(duration)]
energy = energy[: sum(duration)]

这里计算的 pitch 和 energy 都是帧级别的,不是音素级别的,所以要对 原始 pitch 和 energy 在 音素级别取平均:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from scipy.interpolate import interp1d  # 插值函数

# 把那些 pitch 为 0 的进行了线性插值!,然后再按照音素级别取平均

nonzero_ids = np.where(pitch != 0)[0]
interp_fn = interp1d(
nonzero_ids,
pitch[nonzero_ids],
fill_value=(pitch[nonzero_ids[0]], pitch[nonzero_ids[-1]]),
bounds_error=False,
)
pitch = interp_fn(np.arange(0, len(pitch)))

# Phoneme-level average
pos = 0
for i, d in enumerate(duration):
if d > 0:
pitch[i] = np.mean(pitch[pos : pos + d])
else:
pitch[i] = 0
pos += d
pitch = pitch[: len(duration)]

保存 .npy 文件,方便后续训练模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Save files
dur_filename = "{}-duration-{}.npy".format(speaker, basename)
np.save(os.path.join(self.out_dir, "duration", dur_filename), duration)

pitch_filename = "{}-pitch-{}.npy".format(speaker, basename)
np.save(os.path.join(self.out_dir, "pitch", pitch_filename), pitch)

energy_filename = "{}-energy-{}.npy".format(speaker, basename)
np.save(os.path.join(self.out_dir, "energy", energy_filename), energy)

mel_filename = "{}-mel-{}.npy".format(speaker, basename)
np.save(
os.path.join(self.out_dir, "mel", mel_filename),
mel_spectrogram.T,
)