# 语音合成与声音 *语音合成(Text-to-Speech Synthesis)逆向执行 ASR 流水线,从书面文本生成自然听感的音频。本文涵盖 TTS 流水线(文本规范化、G2P、声学模型、声码器)、Tacotron、WaveNet、HiFi-GAN、声音克隆、声音转换以及语音活动检测(VAD)。* - 在文件 01 中,我们构建了信号处理工具包:波形、语谱图、梅尔滤波器组和 MFCC。在文件 02 中,我们将语音转换为文本。现在我们反方向操作:给定文本,合成自然听感的语音。这就是**语音合成(TTS)**,一个同样通向声音转换、声音克隆和语音活动检测的问题。 - 将 TTS 想象成一场舞台表演。剧本就是文本输入。导演(声学模型)决定每句台词应该如何发音——音高、时长、重音。管弦乐队(声码器)随后演奏乐谱,产生听众实际听到的声波。现代神经 TTS 用媲美人类说话者的演绎,取代了基于规则系统那种僵硬、机械的发音。 ![TTS 流水线:文本被规范化、转换为音素、由声学模型处理生成梅尔语谱图,然后通过声码器生成最终波形](../images/tts_pipeline.svg) - **语音合成流水线** 标准 TTS 流水线包含四个阶段:(1) 文本规范化,(2) 音素转换,(3) 声学模型,(4) 声码器。一些现代系统将阶段 3 和 4 合并为一个端到端模型,但这种概念分解仍然有用。 - **文本规范化** 将原始文本转换为可发音的形式。缩写展开("Dr."变为"Doctor")、数字变为词语("1984"变为"nineteen eighty-four")、货币符号被口头发音("$5"变为"five dollars"),以及处理 URL 或特殊字符。这一阶段通常基于规则和语言特定文法,不过也存在神经规范化模型。此处的错误会传播到所有下游阶段:如果"St."被读作"saint"而不是"street",整个发音就错了。 - **字素到音素(G2P)转换** 将规范化文本映射为音素序列。英语尤其不规则("though"、"through"、"tough"中的"ough"发音各不相同),因此词典查找(CMU 发音词典)处理常见词语,而神经序列到序列模型(第 06 章的编码器-解码器或第 07 章的 Transformer)处理词汇表外的词语。浅层正字法语言(西班牙语、芬兰语)需要更简单的 G2P。输出通常是 IPA(国际音标)序列或等效的内部音素集合。 - **声学模型** 接收音素序列并产生中间声学表示,几乎总是**梅尔语谱图**(文件 01)。梅尔语谱图捕获每个时间帧的频谱包络,编码了声码器重构波形所需的感知相关信息。声学模型必须决定时长(每个音素持续多久)、音高(基频 $F_0$)和能量(响度)。 - **声码器** 接收梅尔语谱图并产生原始音频波形。这是一个不适定的反演问题:由于相位信息已被丢弃,许多波形可以产生相同的语谱图。经典声码器(Griffin-Lim、WORLD)使用迭代或信号模型方法,但神经声码器现在在质量上占主导地位。 - **声码器:WaveNet**(van den Oord 等人,2016)是第一个生成几乎与人类录音无法区分的语音的神经声码器。它自回归地对波形建模,预测每个样本 $x_t$ 的条件概率依赖于所有先前样本: $$P(x) = \prod_{t=1}^{T} P(x_t \mid x_1, \ldots, x_{t-1}, c)$$ - 其中 $c$ 是条件信号(梅尔语谱图)。每个样本是 16 位,因此对 65536 个值进行朴素 softmax 是不切实际的。WaveNet 使用 **μ-law 压扩** 减少到 256 个量化级别,或者后来的变体使用 logistics 混合分布。 - WaveNet 的核心构建模块是**扩张因果卷积**。因果意味着滤波器权重只看过去样本(无未来泄露)。扩张意味着滤波器以指数增长的间隔跳过样本:扩张因子 $1, 2, 4, 8, \ldots, 512$。这提供了指数级大的感受野,同时保持参数量线性增长。 - 每层的门控激活函数为: $$z = \tanh(W_{f} \ast x) \odot \sigma(W_{g} \ast x)$$ - 其中 $W_f$ 和 $W_g$ 是滤波器和门控卷积权重,$\ast$ 表示扩张因果卷积,$\odot$ 是逐元素乘法。这种门控机制(来自第 06 章的 LSTM)允许网络控制信息流。 - WaveNet 产生卓越的质量,但推理速度极慢:生成一秒 24 kHz 音频需要 24000 次顺序前向传播。这推动了所有后续声码器研究。 - **WaveRNN**(Kalchbrenner 等人,2018)用单层循环网络取代了 WaveNet 的深层卷积堆叠。它将每个 16 位样本拆分为粗(高 8 位)和细(低 8 位)分量,使用 GRU(第 06 章)预测每个分量。这种双 softmax 方法显著减少了计算量,同时保持了高质量。经过精心内核优化后,WaveRNN 在移动 CPU 上足以实现实时运行。 - **WaveGlow**(Prenger 等人,2019)是一种基于**流**的声码器,完全避免了自回归生成。它使用一系列可逆变换(仿射耦合层,第 06 章的正则化流)将简单高斯分布映射到波形分布。训练使用变量变换公式最大化精确对数似然: $$\log P(x) = \log P(z) + \sum_{i} \log \left| \det \frac{\partial f_i}{\partial f_{i-1}} \right|$$ - 其中 $z = f(x)$ 是通过将 $x$ 传递经流得到的潜在变量。推理时,抽取样本 $z \sim \mathcal{N}(0, I)$ 并通过逆流以单次并行前向传播推出。WaveGlow 用模型大小(耦合层的大网络)换取生成速度。 - **HiFi-GAN**(Kong 等人,2020)使用**生成对抗网络**从梅尔语谱图合成波形。生成器通过一系列转置卷积对梅尔语谱图进行上采样,每个卷积后跟一个**多感受野融合(MRF)**模块。MRF 模块并行应用多个具有不同核大小和扩张率的残差块,然后将它们的输出求和。这使得生成器能够同时捕获多个时间尺度的模式。 ![HiFi-GAN 生成器架构:梅尔语谱图输入经过转置卷积上采样层,每层后跟多感受野融合块,这些融合块组合了具有不同扩张模式的并行残差堆叠](../images/hifi_gan_generator.svg) - HiFi-GAN 使用两种鉴别器类型。**多周期鉴别器(MPD)**通过以不同周期(2、3、5、7、11)折叠一维波形,将其重塑为二维,然后应用二维卷积。这捕获了不同基频下的周期结构。**多尺度鉴别器(MSD)**在原始波形、2 倍降采样和 4 倍降采样版本上操作,捕获不同时间分辨率下的模式。 - 训练目标结合了对抗损失、**梅尔语谱图重构损失**(合成音频与真实音频的梅尔语谱图之间的 L1 距离)和**特征匹配损失**(中间鉴别器特征之间的 L1 距离): $$\mathcal{L}_G = \mathcal{L}_{\text{adv}}(G) + \lambda_{\text{mel}} \mathcal{L}_{\text{mel}}(G) + \lambda_{\text{fm}} \mathcal{L}_{\text{fm}}(G)$$ - HiFi-GAN 实现了与 WaveNet 相当的合成质量,同时速度提升超过 1000 倍,可在单个 GPU 上实现实时生成。 - **神经源-滤波器(NSF)模型**将传统信号处理与神经网络相结合。在经典源-滤波器模型中,浊音由声源激励(基频 $F_0$ 处的周期脉冲序列)通过声道滤波器(频谱包络)产生。NSF 模型用神经网络替代手工设计的滤波器,同时保留显式源信号。输入的 $F_0$ 轮廓提供了纯数据驱动声码器有时难以处理的精细音高控制。 - **声学模型:Tacotron**(Wang 等人,2017)是第一个直接将字符序列转换为梅尔语谱图的端到端神经 TTS 系统。它使用带注意力机制的编码器-解码器架构(第 07 章)。编码器使用卷积库、高速网络和双向 GRU 处理字符/音素序列。解码器是一个自回归 GRU,逐个预测梅尔帧,使用前一帧和注意力上下文作为输入。 - **Tacotron 2**(Shen 等人,2018)显著改进了架构。编码器是一个 3 层一维卷积堆叠后跟双向 LSTM(第 06 章)。解码器是一个 2 层 LSTM,带**位置敏感注意力**,该注意力机制不仅基于编码器输出和解码器状态,还基于先前步骤累积的注意力权重来条件化。这防止了注意力跳过或重复词语的常见失败模式。 ![Tacotron 2 架构:字符/音素编码器包含卷积层和 BiLSTM,位置敏感注意力对齐到梅尔语谱图帧,自回归解码器包含停止标记预测](../images/tacotron2_architecture.svg) - 解码器步骤 $i$ 下编码器位置 $j$ 的位置敏感注意力能量为: $$e_{i,j} = w^T \tanh(W_s s_{i-1} + W_h h_j + W_f f_{i,j} + b)$$ - 其中 $s_{i-1}$ 是前一个解码器状态,$h_j$ 是位置 $j$ 处的编码器输出,$f_{i,j}$ 是通过将累积注意力权重 $\sum_{k 通道 params['input_w'] = jr.normal(keys[0], (7, n_mels, channels)) * 0.02 params['input_b'] = jnp.zeros(channels) # 上采样块(转置卷积) in_ch = channels for i, rate in enumerate(upsample_rates): k_size = rate * 2 scale = jnp.sqrt(2.0 / (in_ch * k_size)) out_ch = in_ch // 2 params[f'up{i}_w'] = jr.normal(keys[i+1], (k_size, in_ch, out_ch)) * scale params[f'up{i}_b'] = jnp.zeros(out_ch) # 每个尺度下的残差块 params[f'res{i}_0'] = init_residual_block(jr.fold_in(keys[i+4], 0), out_ch, 3, 1) params[f'res{i}_1'] = init_residual_block(jr.fold_in(keys[i+4], 1), out_ch, 3, 3) in_ch = out_ch # 输出投影到单声道波形 params['output_w'] = jr.normal(keys[8], (7, in_ch, 1)) * 0.02 params['output_b'] = jnp.zeros(1) params['upsample_rates'] = upsample_rates return params def generator_forward(params, mel): """mel: (batch, time, n_mels) -> waveform: (batch, time * prod(rates), 1)。""" # 输入投影 h = jax.lax.conv_general_dilated( mel.transpose(0, 2, 1), params['input_w'].transpose(2, 1, 0), window_strides=(1,), padding='SAME' ).transpose(0, 2, 1) + params['input_b'] for i, rate in enumerate(params['upsample_rates']): h = jax.nn.leaky_relu(h, negative_slope=0.1) # 通过转置卷积上采样 k_size = rate * 2 h = jax.lax.conv_transpose( h.transpose(0, 2, 1), params[f'up{i}_w'].transpose(2, 1, 0), strides=(rate,), padding='SAME' ).transpose(0, 2, 1) + params[f'up{i}_b'] # 残差块 h = residual_block(params[f'res{i}_0'], h) h = residual_block(params[f'res{i}_1'], h) h = jax.nn.leaky_relu(h, negative_slope=0.1) out = jax.lax.conv_general_dilated( h.transpose(0, 2, 1), params['output_w'].transpose(2, 1, 0), window_strides=(1,), padding='SAME' ).transpose(0, 2, 1) + params['output_b'] return jnp.tanh(out) # 创建一个合成梅尔语谱图(模拟元音) n_mels = 80 n_frames = 50 mel = jnp.zeros((1, n_frames, n_mels)) # 在低频梅尔频带中添加能量(模拟共振峰) mel = mel.at[:, :, 5:15].set(1.0) mel = mel.at[:, :, 20:25].set(0.6) # 初始化并运行生成器 key = jr.PRNGKey(42) params = init_generator(key, n_mels=n_mels, upsample_rates=(8, 8, 4), channels=128) waveform = generator_forward(params, mel) print(f"输入梅尔形状:{mel.shape}") print(f"输出波形形状:{waveform.shape}") print(f"上采样因子:{8 * 8 * 4} = {8*8*4}x") fig, axes = plt.subplots(2, 1, figsize=(12, 6)) axes[0].imshow(mel[0].T, aspect='auto', origin='lower', cmap='magma') axes[0].set_title('输入梅尔语谱图') axes[0].set_ylabel('梅尔频带') axes[0].set_xlabel('帧') waveform_np = waveform[0, :, 0] axes[1].plot(waveform_np[:2000], color='#9b59b6', linewidth=0.5) axes[1].set_title('生成器输出波形(未经训练 - 随机噪声)') axes[1].set_ylabel('振幅') axes[1].set_xlabel('样本') plt.tight_layout() plt.show() print("注意:输出是噪声,因为生成器未经训练。") print("在实践中,对抗损失 + 梅尔损失训练会将其塑造成语音。") ``` - **任务 4:使用简单 RNN 的语音活动检测。** 在合成音频特征上训练一个基于小型 GRU 的 VAD 模型,对帧进行语音或静音分类。 ```python import jax import jax.numpy as jnp import jax.random as jr import matplotlib.pyplot as plt # 生成合成对数梅尔能量特征及语音/静音标签 def generate_vad_data(key, n_sequences=100, n_frames=200, n_features=40): """模拟对数梅尔特征:语音区域能量更高且具有结构。""" keys = jr.split(key, 5) all_features = [] all_labels = [] for i in range(n_sequences): k = jr.fold_in(keys[0], i) k1, k2, k3 = jr.split(k, 3) # 随机语音/静音模式 label = jnp.zeros(n_frames) n_segments = jr.randint(k1, (), 2, 6) for seg in range(int(n_segments)): start = jr.randint(jr.fold_in(k2, seg), (), 0, n_frames - 20) length = jr.randint(jr.fold_in(k3, seg), (), 10, 50) end = jnp.minimum(start + length, n_frames) label = label.at[int(start):int(end)].set(1.0) # 特征:语音帧具有更高能量 + 频谱结构 noise = jr.normal(jr.fold_in(keys[1], i), (n_frames, n_features)) * 0.3 speech_pattern = jnp.outer(label, jnp.exp(-jnp.arange(n_features) / 15.0)) features = speech_pattern * 2.0 + noise + 0.1 all_features.append(features) all_labels.append(label) return jnp.stack(all_features), jnp.stack(all_labels) key = jr.PRNGKey(123) features, labels = generate_vad_data(key) train_features, train_labels = features[:80], labels[:80] test_features, test_labels = features[80:], labels[80:] # 基于 GRU 的简单 VAD 模型 def init_vad_model(key, input_dim=40, hidden_dim=64): keys = jr.split(key, 6) scale_ih = jnp.sqrt(2.0 / input_dim) scale_hh = jnp.sqrt(2.0 / hidden_dim) return { 'W_z': jr.normal(keys[0], (input_dim, hidden_dim)) * scale_ih, 'U_z': jr.normal(keys[1], (hidden_dim, hidden_dim)) * scale_hh, 'b_z': jnp.zeros(hidden_dim), 'W_r': jr.normal(keys[2], (input_dim, hidden_dim)) * scale_ih, 'U_r': jr.normal(keys[3], (hidden_dim, hidden_dim)) * scale_hh, 'b_r': jnp.zeros(hidden_dim), 'W_h': jr.normal(keys[4], (input_dim, hidden_dim)) * scale_ih, 'U_h': jr.normal(keys[5], (hidden_dim, hidden_dim)) * scale_hh, 'b_h': jnp.zeros(hidden_dim), 'W_out': jr.normal(jr.fold_in(keys[0], 99), (hidden_dim, 1)) * 0.1, 'b_out': jnp.zeros(1), } def gru_step(params, h, x): """单步 GRU。""" z = jax.nn.sigmoid(x @ params['W_z'] + h @ params['U_z'] + params['b_z']) r = jax.nn.sigmoid(x @ params['W_r'] + h @ params['U_r'] + params['b_r']) h_tilde = jnp.tanh(x @ params['W_h'] + (r * h) @ params['U_h'] + params['b_h']) h_new = (1 - z) * h + z * h_tilde return h_new def vad_forward(params, x): """x: (batch, time, features) -> logits: (batch, time)。""" batch_size, n_frames, _ = x.shape hidden_dim = params['W_z'].shape[1] h = jnp.zeros((batch_size, hidden_dim)) outputs = [] for t in range(n_frames): h = gru_step(params, h, x[:, t, :]) logit = (h @ params['W_out'] + params['b_out']).squeeze(-1) outputs.append(logit) return jnp.stack(outputs, axis=1) def bce_loss(params, features, labels): """VAD 的二元交叉熵损失。""" logits = vad_forward(params, features) probs = jax.nn.sigmoid(logits) probs = jnp.clip(probs, 1e-7, 1 - 1e-7) loss = -(labels * jnp.log(probs) + (1 - labels) * jnp.log(1 - probs)) return jnp.mean(loss) grad_fn = jax.jit(jax.value_and_grad(bce_loss)) # 训练 params = init_vad_model(jr.PRNGKey(0)) lr = 5e-3 losses = [] for epoch in range(200): loss_val, grads = grad_fn(params, train_features, train_labels) params = jax.tree.map(lambda p, g: p - lr * g, params, grads) losses.append(float(loss_val)) if epoch % 50 == 0: print(f"轮次 {epoch}:损失 = {loss_val:.4f}") # 在测试集上评估 test_logits = vad_forward(params, test_features) test_preds = (jax.nn.sigmoid(test_logits) > 0.5).astype(jnp.float32) accuracy = jnp.mean(test_preds == test_labels) print(f"\n测试准确率:{accuracy:.4f}") # 可视化一个测试示例 idx = 0 fig, axes = plt.subplots(3, 1, figsize=(14, 7)) axes[0].imshow(test_features[idx].T, aspect='auto', origin='lower', cmap='magma') axes[0].set_title('对数梅尔能量特征') axes[0].set_ylabel('梅尔频带') axes[1].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60', label='真实值') axes[1].plot(jax.nn.sigmoid(test_logits[idx]), color='#e74c3c', linewidth=1.5, label='预测概率') axes[1].axhline(0.5, color='gray', linestyle='--', linewidth=0.8) axes[1].set_ylabel('语音概率') axes[1].legend() axes[1].set_title('VAD 预测') axes[2].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60', label='真实值') axes[2].fill_between(range(200), test_preds[idx], alpha=0.4, color='#f39c12', label='预测(阈值=0.5)') axes[2].set_ylabel('语音 / 静音') axes[2].set_xlabel('帧') axes[2].legend() axes[2].set_title('VAD 二值决策') plt.tight_layout() plt.show() ```