通常的TTS模型包含许多模块,例如文本分析, 声学模型, 音频合成等。而构建这些模块需要大量专业相关的知识以及特征工程,这将花费大量的时间和精力,而且各个模块之间组合在一起也会产生很多新的问题。TACOTRON是一个端到端的深度学习TTS模型,它可以说是将这些模块都放在了一个黑箱子里,我们不用花费大量的时间去了解TTS中需要用的的模块或者领域知识,直接用深度学习的方法训练出一个TTS模型,模型训练完成后,给定input,模型就能生成对应的音频。
TACOTRON是一个端到端的TTS模型,模型核心是seq2seq + attention。模型的输入为一系列文本字向量,输出spectrogram frame, 然后在使用Griffin_lim算法生成对应音频。模型结构如下图:
我们知道在训练模型的时候,我们拿到的数据是一条长短不一的(text, audio)的数据,深度学习的核心其实就是大量的矩阵乘法,对于模型而言,文本类型的数据是不被接受的,所以这里我们需要先把文本转化为对应的向量。这里涉及到如下几个操作
因为纯文本数据是没法作为深度学习输入的,所以我们首先得把文本转化为一个个对应的向量,这里我使用字典下标作为字典中每一个字对应的id, 然后每一条文本就可以通过遍历字典转化成其对应的向量了。所以字典主要是应用在将文本转化成其在字典中对应的id, 根据语料库构造,这里我使用的方法是根据语料库中的字频构造字典(我使用的是基于语料库中的字构造字典,有的人可能会先分词,基于词构造。不使用基于词是现在就算是最好的分词都会有一些误分词问题,而且基于字还可以在一定程度上缓解OOV的问题)。下面是构造字典的代码:
def create_vocabulary(vocabulary_path, data_paths, max_vocabulary_size, tokenizer=None):
if not os.path.exists(vocabulary_path):
print("Creating vocabulary %s from data %s" % (vocabulary_path, str(data_paths)))
vocab = defaultdict(int)
for path in data_paths:
with codecs.open(path, mode="r", encoding="utf-8") as fr:
counter = 0
for line in fr:
counter += 1
if counter % 100000 == 0:
print(" processing line %d" % (counter,))
tokens = tokenizer(line)
for w in tokens:
word = re.sub(_DIGIT_RE, " ", w)
# word = w
vocab[word] += 1
vocab_list = sorted(vocab, key=vocab.get, reverse=True)
print("Vocabulary size: %d" % len(vocab_list))
if len(vocab_list) > max_vocabulary_size:
vocab_list = vocab_list[:max_vocabulary_size]
with codecs.open(vocabulary_path, mode="w", encoding="utf-8") as vocab_file:
for w in vocab_list:
vocab_file.write(w + "\n")
然后我们就可以将文本数据转化成对应的向量作为模型的输入。
光有对应的id,没法很好的表征文本信息,这里就涉及到构造词向量,关于词向量不在说明,网上有很多资料,模型中使用词嵌入层,通过训练不断的学习到语料库中的每个字的词向量,代码如下:
def embed(inputs, vocab_size, num_units, zero_pad=True, scope="embedding", reuse=None):
'''Embeds a given tensor.
Args:
inputs: A `Tensor` with type `int32` or `int64` containing the ids
to be looked up in `lookup table`.
vocab_size: An int. Vocabulary size.
num_units: An int. Number of embedding hidden units.
zero_pad: A boolean. If True, all the values of the fist row (id 0)
should be constant zeros.
scope: Optional scope for `variable_scope`.
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns:
A `Tensor` with one more rank than inputs's. The last dimesionality
should be `num_units`.
'''
with tf.variable_scope(scope, reuse=reuse):
lookup_table = tf.get_variable('lookup_table',
dtype=tf.float32,
shape=[vocab_size, num_units],
initializer=tf.truncated_normal_initializer(mean=0.0, stddev=0.01))
if zero_pad:
lookup_table = tf.concat((tf.zeros(shape=[1, num_units]),
lookup_table[1:, :]), 0)
return tf.nn.embedding_lookup(lookup_table, inputs)
值得注意的是,这里是随机初始化词嵌入层,另一种方法是引入预先在语料库训练的词向量(word2vec),可以在一定程度上提升模型的效果。
对于音频,我们主要是提取出它的melspectrogram音频特征。MFCC是一种比较常用的音频特征,对于声音来说,它其实是一个一维的时域信号,直观上很难看出频域的变化规律,我们知道,可以使用傅里叶变化,得到它的频域信息,但是又丢失了时域信息,无法看到频域随时域的变化,这样就没法很好的描述声音, 为了解决这个问题,很多时频分析手段应运而生。短时傅里叶,小波,Wigner分布等都是常用的时频域分析方法。这里我们使用短时傅里叶。
所谓短时傅里叶变换,顾名思义,是对短时的信号做傅里叶变化。那么短时的信号怎么得到的? 是长时的信号分帧得来的。这么一想,STFT的原理非常简单,把一段长信号分帧(傅里叶变换适用于分析平稳的信号。我们假设在较短的时间跨度范围内,语音信号的变换是平坦的,这就是为什么要分帧的原因)、加窗,再对每一帧做傅里叶变换(FFT),最后把每一帧的结果沿另一个维度堆叠起来,得到类似于一幅图的二维信号形式。如果我们原始信号是声音信号,那么通过STFT展开得到的二维信号就是所谓的声谱图。
声谱图往往是很大的一张图,为了得到合适大小的声音特征,往往把它通过梅尔标度滤波器组(mel-scale filter banks),变换为梅尔频谱。在梅尔频谱上做倒谱分析(取对数,做DCT变换)就得到了梅尔倒谱。音频MFCC特征提取代码如下,这里主要使用第三方库librosa提取MFCC特征:
def get_spectrograms(sound_file):
'''Extracts melspectrogram and log magnitude from given `sound_file`.
Args:
sound_file: A string. Full path of a sound file.
Returns:
Transposed S: A 2d array. A transposed melspectrogram with shape of (T, n_mels)
Transposed magnitude: A 2d array.Has shape of (T, 1+hp.n_fft//2)
'''
# Loading sound file
y, sr = librosa.load(sound_file, sr=hp.sr) # or set sr to hp.sr.
# stft. D: (1+n_fft//2, T)
D = librosa.stft(y=y,
n_fft=hp.n_fft,
hop_length=hp.hop_length,
win_length=hp.win_length)
# magnitude spectrogram
magnitude = np.abs(D) #(1+n_fft/2, T)
# power spectrogram
power = magnitude**2 #(1+n_fft/2, T)
# mel spectrogram
S = librosa.feature.melspectrogram(S=power, n_mels=hp.n_mels) #(n_mels, T)
return np.transpose(S.astype(np.float32)), np.transpose(magnitude.astype(np.float32)) # (T, n_mels), (T, 1+n_fft/2)
embeding layer之后是一个encoder pre-net模块,它有两个隐藏层,层与层之间的连接均是全连接;第一层的隐藏单元数目与输入单元数目一致,第二层的隐藏单元数目为第一层的一半;两个隐藏层采用的激活函数均为ReLu,并保持0.5的dropout来提高泛化能力
def prenet(inputs, is_training=True, scope="prenet", reuse=None):
'''Prenet for Encoder and Decoder.
Args:
inputs: A 3D tensor of shape [N, T, hp.embed_size].
is_training: A boolean.
scope: Optional scope for `variable_scope`.
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns:
A 3D tensor of shape [N, T, num_units/2].
'''
with tf.variable_scope(scope, reuse=reuse):
outputs = tf.layers.dense(inputs, units=hp.embed_size, activation=tf.nn.relu, name="dense1")
outputs = tf.nn.dropout(outputs, keep_prob=.5 if is_training==True else 1., name="dropout1")
outputs = tf.layers.dense(outputs, units=hp.embed_size//2, activation=tf.nn.relu, name="dense2")
outputs = tf.nn.dropout(outputs, keep_prob=.5 if is_training==True else 1., name="dropout2")
return outputs # (N, T, num_units/2)
CBHG模块由1-D convolution bank ,highway network ,bidirectional GRU 组成。它的功能是从输入中提取有价值的特征,有利于提高模型的泛化能力。
输入序列首先会经过一个卷积层,注意这个卷积层,它有K个大小不同的1维的filter,其中filter的大小为1,2,3…K。这些大小不同的卷积核提取了长度不同的上下文信息。其实就是n-gram语言模型的思想,K的不同对应了不同的gram, 例如unigrams, bigrams, up to K-grams,然后,将经过不同大小的k个卷积核的输出堆积在一起(注意:在做卷积时,运用了padding,因此这k个卷积核输出的大小均是相同的),也就是把不同的gram提取到的上下文信息组合在一起,下一层为最大池化层,stride为1,width为2。代码如下:
def conv1d_banks(inputs, K=16, is_training=True, scope="conv1d_banks", reuse=None):
'''Applies a series of conv1d separately.
Args:
inputs: A 3d tensor with shape of [N, T, C]
K: An int. The size of conv1d banks. That is,
The `inputs` are convolved with K filters: 1, 2, ..., K.
is_training: A boolean. This is passed to an argument of `batch_normalize`.
Returns:
A 3d tensor with shape of [N, T, K*Hp.embed_size//2].
'''
with tf.variable_scope(scope, reuse=reuse):
outputs = conv1d(inputs, hp.embed_size//2, 1) # k=1
for k in range(2, K+1): # k = 2...K
with tf.variable_scope("num_{}".format(k)):
output = conv1d(inputs, hp.embed_size // 2, k)
outputs = tf.concat((outputs, output), -1)
outputs = normalize(outputs, type=hp.norm_type, is_training=is_training,
activation_fn=tf.nn.relu)
return outputs # (N, T, Hp.embed_size//2*K)
注意ouputs = normalize(outputs, type=hp.norm_type, is_training=is_training, activation_fn=tf.nn.relu)这行代码,这里对output做了batch normalization处理(每个mini_batch中),至于BN的作用,网上有很多资料,我这里就简单说下,我们知道不加BN的神经网络,每层都会有一个非线性话操作,如sigmoid或者RELU,这样一方面每层的输入分布和上一层都不相同,这样会使得模型的收敛和预测能力下降。其次,对于深层网络而言,这样会带来梯度弥散和梯度爆炸的问题,因为模型在back propogation的时候是依据链式法则,深度越深,问题越严重,BN引入其他参数抹去了w的scale的影响。公式网上都有,这里就不在搬了。
经过池化之后,会再经过两层一维的卷积层。第一个卷积层的filter大小为3,stride为1,采用的激活函数为ReLu;第二个卷积层的filter大小为3,stride为1,没有采用激活函数(在这两个一维的卷积层之间都会进行batch normalization)。
经过卷积层之后,会进行一个residual connection。也就是把卷积层输出的和embeding之后的序列相加起来。使用residual connection也是一个缓解神经网络太深带来的梯度弥散问题的方法。我们知道,在训练神经网络的时候,一个合适的layer size是很重要的,网络太深,会带来梯度弥散的问题,太浅又不能很好的学到特征,residual connection可以缓解网络太深带来的问题,就是把输入和经过卷积后结果相加,这样可以确保经过多层卷积后,没有丢失太多之前输入的信息。代码如下:
enc += prenet_out # (N, T, E/2) # residual connections
这里就是把前面prenet层后经过多层卷积后的结果和prenet的输出相加。
下一层输入到highway layers,highway nets的每一层结构为:把输入同时放入到两个一层的全连接网络中,这两个网络的激活函数分别采用了ReLu和sigmoid函数,假定输入为input,ReLu的输出为output1,sigmoid的输出为output2,那么highway layer的输出为output=output1∗output2+input∗(1−output2)。论文中使用了4层highway layer。
代码如下:
def highwaynet(inputs, num_units=None, scope="highwaynet", reuse=None):
'''Highway networks, see https://arxiv.org/abs/1505.00387
Args:
inputs: A 3D tensor of shape [N, T, W].
num_units: An int or `None`. Specifies the number of units in the highway layer
or uses the input size if `None`.
scope: Optional scope for `variable_scope`.
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns:
A 3D tensor of shape [N, T, W].
'''
if not num_units:
num_units = inputs.get_shape()[-1]
with tf.variable_scope(scope, reuse=reuse):
H = tf.layers.dense(inputs, units=num_units, activation=tf.nn.relu, name="dense1")
T = tf.layers.dense(inputs, units=num_units, activation=tf.nn.sigmoid, name="dense2")
C = 1. - T
outputs = H * T + inputs * C
return outputs
从代码中我们也能看出highway network公式为:
其中C等于1-T,x为输入, y 为对应的输出,T为transfer gate,C为carry gate,其实就是让网络的输出由两部分组成,分别是网络的直接输入以及输入变形后的部分。为什么要使用highway network的节奏呢,其实说白了也是一种减少缓解网络加深带来过拟合问题,以及减少较深网络的训练难度的一个trick。它主要受到LSTM门限机制的启发。
然后将输出输入到双向的GRU中,从GRU中输出的结果就是encoder的输出。GRU是RNN的一个变体,它和LSTM一样都使用了门限机制,不同的是它只有更新门和重置门。公式如下:
Reset gate
r(t) 负责决定h(t−1) 对new memory h^(t) 的重要性有多大, 如果r(t)约等于0 的话,h(t−1) 就不会传递给new memory h^(t)
new memory
h^(t) 是对新的输入x(t) 和上一时刻的hidden state h(t−1) 的总结。计算总结出的新的向量h^(t) 包含上文信息和新的输入x(t).
Update gate
z(t) 负责决定传递多少ht−1给ht 。 如果z(t) 约等于1的话,ht−1 几乎会直接复制给ht ,相反,如果z(t) 约等于0, new memory h^(t) 直接传递给ht.
Hidden state:
h(t) 由 h(t−1) 和)h^(t) 相加得到,两者的权重由update gate z(t) 控制。
代码如下:
def gru(inputs, num_units=None, bidirection=False, scope="gru", reuse=None):
'''Applies a GRU.
Args:
inputs: A 3d tensor with shape of [N, T, C].
num_units: An int. The number of hidden units.
bidirection: A boolean. If True, bidirectional results
are concatenated.
scope: Optional scope for `variable_scope`.
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns:
If bidirection is True, a 3d tensor with shape of [N, T, 2*num_units],
otherwise [N, T, num_units].
'''
with tf.variable_scope(scope, reuse=reuse):
if num_units is None:
num_units = inputs.get_shape().as_list[-1]
cell = tf.contrib.rnn.GRUCell(num_units)
if bidirection:
cell_bw = tf.contrib.rnn.GRUCell(num_units)
outputs, _ = tf.nn.bidirectional_dynamic_rnn(cell, cell_bw, inputs,
dtype=tf.float32)
return tf.concat(outputs, 2)
else:
outputs, _ = tf.nn.dynamic_rnn(cell, inputs,
dtype=tf.float32)
return outputs
到这里,CBHG的部分(encoder)就总结完毕了,CBHG的代码:
def encode(inputs, is_training=True, scope="encoder", reuse=None):
'''
Args:
inputs: A 2d tensor with shape of [N, T], dtype of int32.
seqlens: A 1d tensor with shape of [N,], dtype of int32.
masks: A 3d tensor with shape of [N, T, 1], dtype of float32.
is_training: Whether or not the layer is in training mode.
scope: Optional scope for `variable_scope`
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns:
A collection of Hidden vectors, whose shape is (N, T, E).
'''
with tf.variable_scope(scope, reuse=reuse):
# Load vocabulary
# char2idx, idx2char = load_vocab()
vocab, revocab = load_vocab()
# Character Embedding
inputs = embed(inputs, len(vocab), hp.embed_size) # (N, T, E)
# Encoder pre-net
prenet_out = prenet(inputs, is_training=is_training) # (N, T, E/2)
# Encoder CBHG
## Conv1D bank
enc = conv1d_banks(prenet_out, K=hp.encoder_num_banks, is_training=is_training) # (N, T, K * E / 2)
### Max pooling
enc = tf.layers.max_pooling1d(enc, 2, 1, padding="same") # (N, T, K * E / 2)
### Conv1D projections
enc = conv1d(enc, hp.embed_size//2, 3, scope="conv1d_1") # (N, T, E/2)
enc = normalize(enc, type=hp.norm_type, is_training=is_training,
activation_fn=tf.nn.relu, scope="norm1")
enc = conv1d(enc, hp.embed_size//2, 3, scope="conv1d_2") # (N, T, E/2)
enc = normalize(enc, type=hp.norm_type, is_training=is_training,
activation_fn=None, scope="norm2")
enc += prenet_out # (N, T, E/2) # residual connections
### Highway Nets
for i in range(hp.num_highwaynet_blocks):
enc = highwaynet(enc, num_units=hp.embed_size//2,
scope='highwaynet_{}'.format(i)) # (N, T, E/2)
### Bidirectional GRU
memory = gru(enc, hp.embed_size//2, True) # (N, T, E)
return memory
decoder模块主要分为三部分:
- pre-net
- Attention-RNN
- Decoder-RNN
Pre-net的结构与encoder中的pre-net相同,主要是对输入做一些非线性变换。
Attention-RNN的结构为一层包含256个GRU的RNN,它将pre-net的输出和attention的输出作为输入,经过GRU单元后输出到decoder-RNN中。
Decode-RNN为两层residual GRU,它的输出为输入与经过GRU单元输出之和。每层同样包含了256个GRU单元。第一步decoder的输入为0矩阵,之后都会把第t步的输出作为第t+1步的输入。(这里paper中使用了一个trick,就是每次decoder的时候,不仅仅预测1帧的数据,而是预测多个非重叠的帧。因为就像我们前面说到的提取音频特征的时候,我们会先分帧,相邻的帧其实是有一定的关联性的,所以每个字符在发音的时候,可能对应了多个帧,因此每个GRU单元输出为多个帧的音频文件。)
为什么这样做呢:
- 减小了model size,比如当前需要预测n个帧,如果每次一个帧的话,就对应了n个GRU,而如果每次预测r个帧的话,只需要n/r个GRU,从而减小了模型的复杂度
- 减少了模型训练和预测的时间
- 提高收敛的速度
其实下两点好处都是第一点好处带来的。代码如下:
def decode1(decoder_inputs, memory, is_training=True, scope="decoder1", reuse=None):
'''
Args:
decoder_inputs: A 3d tensor with shape of [N, T', C'], where C'=hp.n_mels*hp.r,
dtype of float32. Shifted melspectrogram of sound files.
memory: A 3d tensor with shape of [N, T, C], where C=hp.embed_size.
is_training: Whether or not the layer is in training mode.
scope: Optional scope for `variable_scope`
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns
Predicted melspectrogram tensor with shape of [N, T', C'].
'''
with tf.variable_scope(scope, reuse=reuse):
# Decoder pre-net
dec = prenet(decoder_inputs, is_training=is_training) # (N, T', E/2)
# Attention RNN
dec = attention_decoder(dec, memory, num_units=hp.embed_size) # (N, T', E)
# Decoder RNNs
dec += gru(dec, hp.embed_size, False, scope="decoder_gru1") # (N, T', E)
dec += gru(dec, hp.embed_size, False, scope="decoder_gru2") # (N, T', E)
# Outputs => (N, T', hp.n_mels*hp.r)
out_dim = decoder_inputs.get_shape().as_list()[-1]
outputs = tf.layers.dense(dec, out_dim)
return outputs
和seq2seq网络不通的是,tacotron在decoder-RNN输出之后并没有直将其作为输出通过Griffin-Lim算法合成音频,而是添加了一层post-processing模块。
为什么要添加这一层呢?
首先是因为我们使用了Griffin-Lim重建算法,根据频谱生成音频,Griffin-Lim原理是:我们知道相位是描述波形变化的,我们从频谱生成音频的时候,需要考虑连续帧之间相位变化的规律,如果找不到这个规律,生成的信号和原来的信号肯定是不一样的,Griffin Lim算法解决的就是如何不弄坏左右相邻的幅度谱和自身幅度谱的情况下,求一个近似的相位,因为相位最差和最好情况下天壤之别,所有应该会有一个相位变化的迭代方案会比上一次更好一点,而Griffin Lim算法找到了这个方案。这里说了这么多,其实就是Griffin-Lim算法需要看到所有的帧。post-processing可以在一个线性频率范围内预测幅度谱(spectral magnitude)。
其次,post-processing能看到整个解码的序列,而不像seq2seq那样,只能从左至右的运行。它能够通过正向传播和反向传播的结果来修正每一帧的预测错误。
论文中使用了CBHG的结构来作为post-processing net,前面已经详细介绍过。代码如下:
def decode2(inputs, is_training=True, scope="decoder2", reuse=None):
'''
Args:
inputs: A 3d tensor with shape of [N, T', C'], where C'=hp.n_mels*hp.r,
dtype of float32. Log magnitude spectrogram of sound files.
is_training: Whether or not the layer is in training mode.
scope: Optional scope for `variable_scope`
reuse: Boolean, whether to reuse the weights of a previous layer
by the same name.
Returns
Predicted magnitude spectrogram tensor with shape of [N, T', C''],
where C'' = (1+hp.n_fft//2)*hp.r.
'''
with tf.variable_scope(scope, reuse=reuse):
# Decoder pre-net
prenet_out = prenet(inputs, is_training=is_training) # (N, T'', E/2)
# Decoder Post-processing net = CBHG
## Conv1D bank
dec = conv1d_banks(prenet_out, K=hp.decoder_num_banks, is_training=is_training) # (N, T', E*K/2)
## Max pooling
dec = tf.layers.max_pooling1d(dec, 2, 1, padding="same") # (N, T', E*K/2)
## Conv1D projections
dec = conv1d(dec, hp.embed_size, 3, scope="conv1d_1") # (N, T', E)
dec = normalize(dec, type=hp.norm_type, is_training=is_training,
activation_fn=tf.nn.relu, scope="norm1")
dec = conv1d(dec, hp.embed_size//2, 3, scope="conv1d_2") # (N, T', E/2)
dec = normalize(dec, type=hp.norm_type, is_training=is_training,
activation_fn=None, scope="norm2")
dec += prenet_out
## Highway Nets
for i in range(4):
dec = highwaynet(dec, num_units=hp.embed_size//2,
scope='highwaynet_{}'.format(i)) # (N, T, E/2)
## Bidirectional GRU
dec = gru(dec, hp.embed_size//2, True) # (N, T', E)
# Outputs => (N, T', (1+hp.n_fft//2)*hp.r)
out_dim = (1+hp.n_fft//2)*hp.r
outputs = tf.layers.dense(dec, out_dim)
return outputs
最后使用Griffin-Lim算法来将post-processing net的输出合成为语音。到这里就基本结束了,感兴趣的话可以看下论文加深理解: https://arxiv.org/pdf/1703.10135.pdf
语音合成,又称文语转换(Text To Speech,TTS),是一种可以将任意输入文本转换成相应语音的技术。传统语音合成系统通常包括前端和后端两个部分。前端主要对输入文本进行分析,提取某些语言学信息:中文合成系统的前端部分一般包含文本正则化、分词、词性预测、多音字消歧、韵律预测等模块[1].后端则通过一定方法,例如参数合成或拼接合成、生成语音波形.
參数合成指基于统计参数建模的语音合成[2].该方法在训练阶段对语言声学特征、时长信息进行上下文相关建模,在合成阶段通过时长模型和声学模型预测声学特征参数,对声学特征参数做后处理,最终利用声码器恢复语音波形。在语音库相对较小的情况下,这类方法可能得到较稳定的合成效果.其缺点是往往存在声学特征参数“过平滑”问题,另外声码器也可能对音质造成损伤。
拼接合成指基于单元挑选和波形拼接的语音合成[3].其训练阶段与参数合成方式的基本相同,但在合成阶段通过模型计算代价来指导单元挑选,并采用动态规划算法选出最优单元序列,最后对选出的单元进行能量规整和波形拼接。拼接合成直接使用真实的语音片段,能最大限度保留语音音质.缺点是一般需要较大音库,且无法保证领域外文本的合成效果.
传统的语音合成系统都是相对复杂的,例如:前端需要较强的语言学背景,不同语言的语言学知识差异明显,通常需要特定领域的专家支持:后端的参数系统需要对语音的发声机理有一定了解,而传统参数系统建模时难以避免信息损失,限制了合成语音表现力的提升:同在后端的拼接系统对语音库要求较高,也常需人工介入指定挑选规则和参数[4].
为改善这些问题,新的语音合成方式应运而生.其中端到端合成便是一种非常重要的发展趋势.在这种模式下,将文本或者注音字符输入系统,而系统则直接输出音频波形.这降低了对语言学知识的要求,有利于表现更丰富的发音风格和韵律感,也可以相对方便地支持不同语种.
近年来语音合成发展迅猛,如谷歌的Tacotron、Tacotron 2、WaveNet、ParallelWaveNet[5-8],百度的DeepVoice、DeepVoice 2、ClariNet[9-11],英伟达的WaveGlow[12]等.Tacotron是第一个真正意义上的端到端语音合成系统,它允许输入文本或注音字符,输出线性谱,再经过声码器Griffin-Lim转换为波形.Tacotron 2在Tacotron的基础上进行了模型简化,去掉了复杂的CBHG(1-D Convolution Bank+Highway Network+BidirectionalGRU (Gated Recurrent Unit》结构,使用了新颖的注意力机制Location-Sensitive Attention,提高了对齐稳定性.WaveNet及其之后的Parallel WaveNet并非端到端系统,它们依赖其他模块对输入进行预处理,提供特征.仿照PixelRNN图像生成方式,WaveNet依据之前采样点来生成下一采样点,结构为带洞卷积[13].百度的ClariNet使用单高斯简化ParallelWaveNet的KL (Kullback-Leibler)目标函数,改进了蒸馏法算法,使得结构更简单稳定,并且通过Bridge-net连接了特征预测网络和WaveNet,实现了端到端合成.
自2017年以来,端到端语音合成的研究进入了超高速发展时期,谷歌、百度和英伟达等研究机构不断推陈出新,在合成速度、风格迁移、合成自然度方面精益求精。然而,根据领域内文献资料,端到端语音合成目前仅能合成英文,未见成型的中文系统.相较英文,中文语音合成存在一些难点,例如:汉字不表音:中文存在大量多音字和变调现象:中文发音韵律较英文发音更为复杂,如儿化音等.
本文设计了一种中文语音合成方案,基于Tacotron 2在以下几个方面进行了改进.
(1)针对汉字不表音、变调和多音字等问题,添加预处理模块,将中文转化为注音字符.
(2)使用中文音频语料预训练Tacotron 2的解码器,之后进行微调,显著减少拼音中文音频对所需的训练数据量.
(3)使用多层感知机代替停止符(Stop Token)处的线性变换,显著减少合成急促停顿现象.
(4)利用Transformer中的多头注意力(MultiHead Attention)改进Tacotron 2的位置敏感注意力(Locative Sensitive Attention)[14],使其能够捕获到更多语音信息,提升合成音质.
1 相关工作
1.1 序列到序列生成模型
序列到序列的生成模型[15]将输入序列(x1,x2,…,xt)转化为输出序列(y1,y2,…,yr).机器翻译通常先将源语言编码到隐空间,然后再解码到目标语言,有其中,h、S分别是编码器和解码器的隐状态,c是由注意力机制计算得来的上下文向量,由编码器隐状态h加权进行计算,即
1.2 Tacotron 2
Tacotron 2将英文作为输入,直接从英语文本生成声音波形,如图1所示:输入文本经词嵌入后首先送入3层CNN (Convolutional Neural Network)以获取序列中的上下文信息,接着进入双向LSTM(Long Short-Term Memory)组成的编码器.梅尔频谱(在训练阶段,每次送入固定长度的真实频谱;在推断阶段,每次送入上一个时间步的输出)首先进入预处理网络,预处理网络的输出与上一个时间步的上下文向量拼接送入2层LSTM,LSTM的输出被用作计算本时间步的上下文向量,并且经线性映射后用来预测停止符和梅尔频谱.为了提取更为高维的特征,用于预测梅尔频谱的LSTM输出被带残差的5层CNN组成的后处理网络提纯优化,最后输出梅尔频谱[6].
1.3 Transformer
如图2所示,Transformer是一种完全依赖注意力机制的端到端序列生成模型[14],在机器翻译领域显示出了其优异的性能.Transformer模型的编码器由Ⅳ个基本层堆叠而成:每个基本层包含2个子层,第一子层是1个Attention,第二子层是1个全连接前向神经网络.Transformer模型的解码器也由Ⅳ个基本层堆叠:每个基本层除了与编码器相同的2个子层外,还增加了1个掩码多头注意力子层,所有子层都引入了残差边和Layer Normalization.Transformer的编、解码器都含有的多头注意力机制借鉴了CNN中多个卷积核的叠加,实质上是将注意力机制独立执行几遍后拼接,以更充分地抽取序列中的信息.
2 中文语音合成方案
与英文语音合成相比,中文语音合成主要存在以下几个难点.
(1)无法直接使用中文作为文本输入,需要添加文本拼音/国际音标转换器,并且要求在该预处理阶段解决中文变调和多音字问题.
(2)对语料要求较高,需要保证说话人单一,幅度变化小,背景噪音小等.相较Tacotron2训练高音质的英文语音至少达25 h[16],目前高质量中文语音合成语料较少.
(3)中文发音韵律变化较英文复杂.
(4)中文语音合成中,往往发生语音生成急促停顿的现象,尤其常见最后一个字无法正常发音的问题.
针对上述问题,本文提出了一个基于Tacotron 2的中文端到端語音合成方案,如图3所示,并就文本拼音转换器(Text to Phoneme)、预训练模块、注意力机制、停止符预测及后处理等进行了特殊设计.
2.1 文本一拼音转换器
不同于英文,汉字不含发音信息,可考虑先将中文转化为音素,实验证明,将中文转化为拼音或国际音标,合成后音质相差不大.出于更加熟悉、便于纠错等原因,本文最终采用拼音.
中文中存在变调现象,如“第一”“十一”中的“一”读阴平,“一致”“一切”中读阳平,“一丝不苟”“一本万利”中读去声,而“读一读”“看一看”则读轻声.考虑到变调现象在中文中虽然存在,但比例并不高,本文采用规则匹配的方法解决.对于多音字问题,本文首先对输入文本进行中文分词,然后利用词库对多音字进行正确注音.通过上述方法,基本可以正确地将中文文本转化为表音字符,然后送入模型进行语音生成.
2.2 预训练
目前领域内的高质量拼音音频合成语料稀少,而Tacotron 2对语料的需要量却较大.在本文中,解码器使用中文音频进行初始化训练.在预训练阶段,解码器以教师指导模式预测下一个语音帧,即以上一帧预测下一帧音频,不需要对应的文本输入,这要求解码器在帧级别学习声学自回归模型.需要说明的是,预训练阶段解码器仅依靠上一帧进行预测,而微调阶段则需要基于解码器的额外输出进行推断,这可能带来训练和推断的不匹配.
实验结果表明,模型能有效学习语音中的声学信息,并通过少量语料得到较好音质.
2.3 多头注意力机制
为了适用中文复杂的韵律变化,本文将Tacotron 2中Location-Sensitive Attention扩展为MultiHead Location-Sensitive Attention,即
多头注意力将S、H、F通过参数矩阵映射再进行Attention运算,然后把多个子注意力结果拼接起来.类似于CNN中的多卷积核对一张图片提取特征的过程,能够有效获取序列中的信息,从而使解码器预测音频时,字与字间的衔接以及整个句子的韵律变化更接近真实人声.
2.4 停止符预测和后处理网络
不同于Tacotron 2统一用线性变换预测梅尔频谱和停止符,本文分别使用线性变换预测梅尔频谱,用多层感知机(Multi-Layer Perceptron,MLP)预测停止符,并且使用后处理网络优化重建梅尔频谱.
在中文合成过程中,语音常常遇到戛然而止的现象,影响语音流畅性.类似问题在英文合成实践中也存在,但并不明显.这种停顿感主要是由于停止符预测的正负样本不平衡造成的.本文通过将线性变换改为3层MLP并在二元交叉熵上加权f实验中将该权重设置为6.0),较好地解决了生成过程突然停顿的问题.
另外,由于Tacotron 2的WaveNet生成较慢,本文使用Griffin-Lim作为声码器[17],同时在原有的后处理网络添加CBHG,显著提高了音质.
3 实验结果与分析
本文通过实验验证了本文所提框架的有效性.
3.1 训练步骤
使用4块英伟达P100训练模型,利用8h私有的拼音音频语料和50 h中文音频作为训练数据集.私有数据集的前后均保持100 ms静音间隔,其中批处理规模(Batch Size)设置为32,过小的规模将造成训练不稳定并影响合成音质,过大则容易引发内存溢出问题.
3.2 文本拼音转换器
Tacotron 2直接将英文文本输入模型进行训练,因为英文字母在单词中的发音变化较少,如字母“a)的发音只有[ei]、[a:]和[ae].但即便抛开中文汉字的多音字问题,希望模型直接学习每个汉字的發音都是不现实的:将汉字转化为注音字符,如国际音标或拼音,是较可行的思路.然而如果在数据预处理过程中,注音标注若出错,合成结果必将失败,本文通过规则匹配法基本解决了这类问题,但该方法也存在局限性,仍有少量(低于4%)汉字注音出现错误.
3.3 Griffin-Lim设置
由于WaveNet生成速度过慢的问题尚未解决,本文选用Griffin-Lim作为模型的声码器,迭代次数设置为30.Tacotron 2直接使用带残差的5层CNN作为后处理网络,但其对梅尔频谱的优化不充分,因此添加CBHG进一步提取特征以有效提升音质,在实验中,通过将原始的录音音频转化为梅尔频谱,再使用Griffin-Lim转换回来,发现有明显的音质损伤,可以推断Griffin-Lim是影响音质的瓶颈.另外为了进一步减少信息损失,本文将梅尔频谱的输出维度由80改为160.
3.4 合成音频样例
本文提供了一些中文合成样例,参见https://github.com/cnlinxi/tacotron2/tree/master/samples.这些样例由任意给定的中文文本通过文本拼音转换器转化为拼音后,输入已经训练好的模型合成.模型训练利用8h拼音音频样本和50 h的音频样本,训练步数为15万步,每步耗时约3.3 s.
3.5 剥离分析
3.5.1 预训练
当前公开的质量较高的中文语音合成语料为THCHS_30[18],该数据集音频时长约为30 h,其对应的拼音标注较准确.但是THCHS-30中有多个说话人,男女声混杂,背景噪音很大.利用语料训练后合成的语音有的音频为男声,有的为女声,甚至同一句话一半为男声一半为女声,并且合成后音质较差.鉴于上述,本文考虑使用8h高质量私有语料进行预训练.但Tacotron 2合成高质量语音通常要求较大的训练样本量,因此需要一种减少训练样本需求的方法.文献[19]提出使用预训练的词向量(英文)以减少Tacotron 2训练样本,思路具有启发性.考虑到汉字不表音,本文曾考虑使用预训练的拼音词向量以增强信息,但拼音预训练词向量非常罕见,资源难以获得.实验中发现,通过对解码器使用单独的中文音频进行预训练,也能获得较好的初始化效果.特别地,在预训练冻结编码器时,解码器的输入端应给予轻微的扰动值,以减小预训练和微调不匹配时带来的误差.图4分别给出了10万步时使用解码器预训练和没有使用解码器预训练获得的梅尔频谱.从图4可以看到,相较前者,后者锐利而清晰.
3.5.2 多头注意力机制
多头注意力机制能够对特定序列通过多个角度反复提取信息,并将各个子注意力模块的输出结果进行拼接,令生成语音时使用的信息更丰富,提升了合成语音音质.工程上可以使用梅尔倒谱距离(Mel Cepstral Distance,MCD)来评价音质,其值越小越好[20].表1给出了使用10 min左右(213句)验证集在10万步的模型上计算得到的MCD.本文还对比了不同头数注意力机制对合成语音的影响,可以看到,头数的增加可能提高生成语音的质量.但同时也将使得训练速度变慢,内存占用增大,收敛速度减缓,因此未来存在优化的必要.
3.6 与其他语音合成系统的比较
如表2所示,经15万步、4头注意力训练得到的中文tacotron2,其MCD的值为17.11,与文献[19]给出的18.06具有可比性.
文献[19]中英文Tacotron 2的评价印象分(Mean Opinion Score,MOS),即人类主观评分为4.526±0.066,优于文献[2]中拼接式语音合成系统的4.166±0.091和文献[3]中参数式语音合成系统的3.492±0.096.
需要说明的是,本文在实验中合成相同的64句话,合成音频时长为331.5 s,耗时366.11 s,暂时无法满足实时要求.
4 总结
本文设计并通过实验验证了一个基于Tacotron 2的中文CNN语音合成方案,在语料有限的情况下,可以实现端到端的较高质量中文语音合成.梅尔频谱、梅尔倒谱距离等的实验对比结果表明了其有效性,可较好地适应中文语音合成的要求:就目前业内一般仅能端到端语音合成英文的局面,是一个有益探索.
但本文方案目前尚存在一些问题,如:中文多音字辨识没有得到彻底解决:合成语音中无法完全避免杂音,仍存在少量不合理停顿现象:对实时性的支持有待改善等.今后可持续进行优化并开展较大规模人类主观评测.
[参考文献]
[1]MOHAMMADI s H,KAIN A.An overview of voice conversion systems[J]. Speech Communication, 2017,88:65-82.
[2]GONZALVO x,TAZARI s, CHAN c A,et al Recent advances in Google real-time HMM-driven unit selectionsynthesizer[C]//Interspeech 2016. 2016: 22382242.
[3]ZEN H,AGIOMYRGIANNAKIS Y,EGBERTS N,et al.Fast,compact,and high quality LSTM-RNN basedstatistical parametric speech synthesizers for mobile devices[C]//Interspeech 2016. 2016: 2273-2277.
[4]TAYLOR P.Text-to-Speech Synthesis[M]. Cambridge: Cambridge University Press, 2009.
[5]WANG Y,SKERRY-RYAN R J,STANTON D,et al Tacotron: Towards end-to-end speech synthesis[Jl. arXivpreprint arXiv:1703.10135, 2017.
[6] SHEN J,PANG R,WEISS R J,et al. Natural tts synthesis by conditioning wavenet on mel spectrogrampredictions[C]//2018 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)IEEE, 2018: 4779-4783
[7] VAN DEN OORD A,DIELEMAN s,ZEN H,et al.WaveNet:A generative model for raw audio[Jl. arXiv preprint arXiv:1609.03499, 2016.
[8]OORD A,LI Y,BABUSCHKIN I, et al.Parallel WaveNet: Fast high-fidelity speech synthesis[J]. arXiv preprintarXiv:1711.10433, 2017.
[9]ARIK s o,Chrzanowski M,Coates A,et al.Deep voice: Real-time neural text-to-speech[J]. arXiv preprintarXiv:1702.07825, 2017.
[10]ARIK s, DIAMOS G,GIBIANSKY A,et al.Deep voice 2: Multi-speaker neural text-to-speech[J]. arXiv preprintarXiv:1705.08947, 2017.
[11] PING W, PENG K,CHEN J ClariNet: Parallel Wave Generation in End-to-End Text-to-Speech[J]. arXivpreprint arXiv:1807.07281, 2018.
[12] PRENGER R,VALLE R,CATANZARO B.WaveGlow:A Flow-based Generative Network for Speech Synthe-sis[J]. arXiv preprint arXiv:1811.00002, 2018.
[13] OORD A,KALCHBRENNER N,KAVUKCUOGLU K.Pixel recurrent neural networks[J]. arXiv preprintarXiv:1601.06759, 2016.
[14]VASWANI A,SHAZEER N,PARMAR N,et al.Attention is all you need[C]//3lst Annual Conference on NeuralInformation Processing Systems. NIPS. 2017: 5998-6008
[15] SUTSKEVER I, VINYALS o,Le Q V.Sequence to sequence learning with neural networks[C]//28th AnnualConference on Neural Information Processing Systems. NIPS. 2014: 3104-3112
[16] FREEMAN P,VILLEGAS E,KAMALU J Storytime-end to end neural networks for audiobooks[R/OL].[2018-08-28]. http://web.stanford.edu/class/cs224s/reports/Pierce_Freeman.pdf
[17] GRIFFIN D,LIM J Signal estiruation from modified short-time Fourier transform[J]. IEEE Transactions onAcoustics, Speech, and Signal Processing, 1984, 32(2): 236-243
[18]WANG D,ZHANG x W. Thchs-30:A free chinese speech corpus[J]. arXiv preprint arXiv:1512.01882, 2015.
[19]CHUNG Y A,WANG Y,HSU w N,et al Semi-supervised training for improving data efficiency in end-to-endspeech synthesis[J]. arXiv preprint arXiv:1808.10128, 2018.
[20] KUBICHEK R.Mel-cepstral distance measure for objective speech quality assessment[C]//Communications,Computers and Signal Processing, IEEE Pacific Rim Conference on. IEEE, 1993: 125-128.