Seed-VC,DiT语音生成模型

95 阅读7分钟

留下阅读 (2024) Zero-shot Voice Conversion with Diffusion Transformers 的痕迹。

Zero-Shot 语音转换,旨在将源语音转换为与未见说话者的参考语音的音色相匹配。代码已经开源:GitHub 仓库

我看这个实现更单纯,不像 Vevo 还能做 TTS 那样笨重。看完论文就开始调试代码。

总览

要转换说话人音色,先要想办法提取出说话人除音色以外的所有信息。自回归的方法难免会泄漏说话人的音色信息,导致转换不彻底;Bottle neck 设计能抑制音色泄漏的问题,但可能导致像是语义信息的丢失。

对于音色信息的提取,传统方法将来源音频转换为固定长度的向量是不够的。

总之文章亮点:

  • 对于提取非音色特征,不使用 VQ-VAE 这种方法,在避免混入音色信息的同时保留完整的语义信息
  • 对于提取音色特征,使用完整的来源音频而不是预先处理为固定长度的向量,以获得更好的性能

具体方法

一个 Diffusion Transformer 结构。层数为 NN 隐空间维度为 dd。使用的 U-Net 结构 TODO 查看这个 unet 是怎么个事

  • 通过 AdaLN(adaptive layernor)将 Timestamp 信息融入到 Transformer 层中 TODO 还提到 time 作为 token,不知道这个 time 是指哪个 time
  • 使用 RoPE 位置编码

还有一个基于卷积的插值器,能将语义信息的序列插值到与语音匹配的长度。

记一系列符号:

  • 扩散时间步 t[0,1]t\in[0,1]
  • 音色向量 etimbreRde_\text{timbre}\in \mathbb{R}^d
  • 语义信息 S=[s1,,sL]\mathrm{S}=[s_1,\dots,s_L],其中 siRds_i\in\mathbb{R}^dLL 为插值后的长度
  • 噪声特征 A~t=[a~1t,,a~Lt]\tilde{A}_t=[\tilde{a}^t_1,\dots,\tilde{a}^t_L],其中 a~it\tilde{a}^t_i 是扩散时间步 tt 下的噪声特征

输入到 DiT 的序列:

c=[etimbre,S]c=[e_\text{timbre},S]

如何避免语义信息隐含音色信息

避免提取的语义信息隐含音色信息,最好的办法是在训练时不断修改音色,迫使模型只去学习如何提取语义信息。有两种方法:

  • 使用现有的变声器模型
  • 使用 TTS 模型

TODO 论文写的个啥玩意儿

其他配置

用到的模型

使用变声模型 OpenVoiceV2 对语义信息来源音频进行音色变换

使用 Whisper-small 的 Encoder 部分作为语义编码器

使用 CAM++ 提取音色信息

使用 BigVGANV2 将梅尔频谱转换为实际的声音波形。

使用 RMVPE 提取歌声的额外信息。

用到的数据集

训练数据集,Emilia-101k5,由大约 101,000 小时的语音数据组成,涵盖了广泛的说话风格和内容。该数据集从互联网上的公开来源编译而成,提供了多样化的语言和声学变化。

评估数据集,从 LibriTTS test-clean 数据集中随机选择 100 个语音作为源语音,8 个语音作为目标音色。

用到的评测指标

  • SECS,Speaker Similarity,转换音频与目标音频 两者嵌入的余弦相似度。论文使用 Resemblyzer 模型获得语音嵌入
  • WER,Word Error Rate,转换音频与目标音频 两者使用语音识别的结果差异。论文使用针对 ASR 微调的 HuBERT 模型进行语音识别
  • CER,Character Error Rate,与 WER 类似,但是在 character 级别而不是 word 级别计算差异
  • DNSMOS P.835 Scores,使用微软的 DNSMOS P.835 模型评测生成语音的质量,针对音频质量(SIG,Signal Distortion)和底噪(BAK,Background Noise Instrusiveness)两方面

代码阅读

现在(20250910)seed-vc 仓库有一个 v2 版本。奇怪的是这版用上了 AR。读一读 v2 的流程。

具体流程

  1. 以采样率 22050 读取 source_wavetarget_wave
  2. 衍生出采样率 16000 的 source_wave_16ktarget_wave_16k
  3. 对两个 22050 采样率的音频进行 stft 并施加 mel,获得 source_mel target_mel。代表音色信息
    1. 参数:n_fft=1024,n_mels=80,hop_size=256
    2. 使用 librosa.filters.mel() 获得梅尔滤波器。其参数 fmax 默认为采样率一半
    3. 使用 torch.hann_window(1024) 获得窗
    4. 首尾填充 n_fft - hop_size 长度,模式 reflect,再进行 center=False 的 torch.stft()
      1. 使用 center=True 会隐含首位填充 n_fft 长度的操作。不知道代码中的做法是为了什么 TODO
    5. torch.view_as_real(),分离虚数实数域。此时维度 [1, 513, 1074, 2]
    6. 虚数实数平方并相加。此时维度 [1, 513, 1074]
    7. 施加梅尔滤波器。此时维度 [1, 80, 1074]
    8. torch.log(torch.clamp(x, min=1e-5)),取对数
  4. 使用模型对采样率 16000 音频提取语义特征,获得 source_content_indices target_content_indices
    1. 进入 AstralQuantizer
    2. 使用 hubert-large-ll60k 模型进行特征提取。奇怪的是并没有用上作为 tokenizer 的 whisper-small 模型。似乎使用了 stft,提取的特征维度为 [1, 729, 1024],batch time hidden_size
    3. 进入 ConvNeXtV2Stage,对提取出的特征进行卷积,维度变为 [1, 729, 512]
      1. 映射特征,Conv1d(1024, 512, kernel_size=1, stride=1)
      2. 一系列的 ConvNeXtV2Block 卷积层
    4. 进入 BinarySphericalQuantize,对特征进行离散化
      1. Linear(512, 11),获得 11 个码本
      2. 用 L2 范数标准化,F.normalize(dim = -1),获得 x
      3. 以 0 为分界点,二值化到 0 和 1
      4. 码本掩码 [1024,512,…,1],长度刚好也是 11。用二值化结果加权相加,获得 indices
      5. 计算损失。我在想,不需要这么复杂。只需要让 x 与 -1 或 1 的距离缩短,以及所有 batch 的码本整体尽可能靠近 0
        1. 首先计算 x 量化到 -1 和 1 的概率。通过码本维度分别乘上 -1 和 1 再 sigmoid 获得
        2. 通过信息熵公式 H(X)=i=1nP(xi)logP(xi)H(X)=-\sum^n_{i=1}P(x_i)\log P(x_i) 获得损失。损失越小,量化越确定
        3. 会在各个 batch 和所有 batch 两个维度计算出两个损失,分别令量化确定、码本利用率提升(即不允许各 batch 都使用同一套码本)
        4. 这个损失权重 0.1
  5. 对采样率 16000 音频提取风格特征,获得 target_style
    1. 使用 torchaudio.compliance.kaldi.fbank() 将波形转换为 FBank 特征频谱,并减去均值。此时维度 [1, 1458, 80]
    2. 使用 CAMPPlus 处理,经过 MLP 和池化获得无关时间的固定长度向量。网络层写得很绕,我觉得不如 Linear 直接
      1. self.head,用一个 MLP 将 80 维度映射到 320
      2. self.xvector,用一个 MLP 将 320 维度映射到 512,将时间维度 1458 映射到 729(减半)
      3. self.stats,取时间维度的均值和方差 并替代原来的两个维度,则新维度的大小为 1024(512 的 2 倍)
      4. self.dense,其实就是线性层,1024 映射到 192
  6. 通过目标语义特征 target_content_indices 获得长度为 target_mel.size(1) 的插值结果 prompt_condition
    1. Embedding(2048, 512),是的隐含了嵌入映射
    2. 在时间维度使用 F.interpolate(mode='nearest') 进行插值
    3. 一个 MLP。由 Conv1d(512, 512, kernel_size=3, strick=1, padding=1) -> LayerNorm -> nn.Mish 重复 4 遍构成,没有残差
  7. 同上,通过源语义特征 source_content_indices 获得长度为 source_mel.size(1) 的插值结果 cond
  8. 接下来用 cond 分割一系列 chunk_cond 进行正式生成
  9. 目标语义 prompt_condition 与本次源语义 chunk_cond 拼接获得 cat_condition
    1. 但在后面的流匹配流程中 prompt_condition 部分会被全部置 0,起不了作用
  10. 进行核心的流匹配环节
    1. 参数:有 cat_condition,然后 target_mel 是 prompt,target_style 是 style
    2. 创建初始噪声,隐空间维度 80,长度与 cat_condition 一致
    3. 创建余弦的时间 schedule,准备进入流匹配
    4. 定义 x,这是初始噪声的翻版,但其 target_mel 长度部分,即对应 prompt_condition 部分被全部置零
    5. 定义 prompt_x,是初始噪声的另一种翻版,但其 target_mel 长度部分由 target_mel 代替,其余置零
    6. 一切准备好,进入 DiT 模块
      1. 时间经过正弦编码,再 Linear -> SiLU -> Linear
      2. cat_condition 经过一个 Linear,特征维度 512 不变
      3. 在特征维度拼接 x prompt_x cat_condition,获得 x_in
      4. x_in 经过 Linear,特征维度变回 512
      5. target_style 经过 Liner 映射维度到 512
      6. target_style 拼接到 x_in 序列开头
      7. 将时间编码拼接到 x_in 序列开头(虽然后续流程依然会将时间信息用到 AdaptiveLayerNorm)
      8. 使用 13 层 Transformer 网络层处理 x_in
      9. 经过 MLP,Linear -> SiLU -> Linear,特征维度从 512 映射回原来的 mel 频谱维度 80
    7. 根据输入特征不同,获得三种 DiT 处理结果:
      1. prompt_x target_style cat_condition
      2. 只有 cat_condition 语义信息
      3. 啥信息都没有
    8. 三种结果通过 (1 + 0.7 + 0.7)a - 0.7b -0.7c 的方式进行组合
      1. 这两个 0.7 分别通过 intelligebility_cfg_rate 和 similarity_cfg_rate 配置项控制
    9. 预测的流方向乘上单位时间,加在 x 噪声上
    10. 一直重复直到完成采样
  11. 从流匹配结果取出代表本次源语义 chunk_cond 的部分
  12. 进入 BigVGAN 进行音频解码,完成本次 chunk 的处理

简化流程

首先是准备工作:

  1. 输入 source_wavetarget_wave,分别作为 语音输入 和 目标音色参考
  2. 获得梅尔频谱 source_mel target_mel 代表音色信息。具体方式是 stft -> 施加梅尔滤波器 -> 虚实域取模 -> 取对数
  3. 使用 ASTRAL Quantizatio 获得离散化的语义信息 source_content_indices target_content_indices
  4. 使用 CAM++ 获得目标风格特征 target_style
  5. 借助 Embedding 将离散语义信息 source_content_indices 转换为序列 cond,并在序列维度插值到与 source_mel 相同的长度
  6. 同 5,借助 Embedding 将离散语义信息 target_content_indices 转换为序列 prompt_condition,并在序列维度插值到与 target_mel 相同的长度

现在,我们手里有四个特征:

  • 输入语义特征 cond
  • 目标语义特征 prompt_condition
  • 目标音色信息 target_mel
  • 目标风格特征 target_style

接下来为流匹配做准备:

  1. 创建序列长度与 cond 相同的高斯噪声,在通道维度拼接到 cond 身上
  2. 由之前步骤可知 prompt_conditiontarget_mel 序列长度相同。在通道维度将 target_mel 拼接到 prompt_condition 身上
  3. 此时输入语义特征 cond 包含了噪声,目标语义特征 prompt_condition 包含了其音色信息
  4. condprompt_condition 在序列维度进行拼接,获得 cat_condition
  5. 将目标风格特征 target_style 也拼接到 cat_condition

现在获得成分复杂的 cat_condition。一截代表输入语音,一截代表音色参考语音,还有一小截额外的风格控制信息。

接下来会将 cat_condition 视为流匹配中 t=0 时的状态,使用 DiT 模块将其不断采样到 t=1。流匹配流程在此不赘述。

采样完成后,只取 cat_condition 中代表输入语义特征 cond 的部分,将其送入 BigVGAN 进行音频解码,获得音色转换后的音频。

相当匪夷所思的

冗余公式,

t_span = t_span + (-1) * (torch.cos(torch.pi / 2 * t_span) - 1 + t_span)

多余操作,

cond_in_module = self.cond_projection
cond = cond_in_module(cond)

不同卷积,不同激活函数混用

ConvNeXt v2

本节参考来源:zhuanlan.zhihu.com/p/192881098…

ConvNeXtV2Block 流程:

  1. 深度卷积,Conv(512, 512, kernel_size=7, stride1, padding=3, groups=512)
  2. LayerNorm
  3. 隐空间维度三倍,Linear(512, 1536)
  4. GELU 激活
  5. GRN
  6. 隐空间维度还原,Linear(1536, 512)
  7. 残差

深度可分离卷积(Depthwise Separable Convolution),先空间卷积(groups 等于 in_features),再通道混合,能极大减少参数量与计算量。

全局响应归一化(Global Response Normalization,GRN),一种自定义的归一化调节层。序列维度取 L2 范数,该范数除以其通道维度的均值 最终获得一个 scale,在空间和通道维度对输入进行一个 scale。然后再进行 affine。整体还进行残差连接。

class GRN(nn.Module):
    def __init__(self, dim):
        super().__init__()
        self.gamma = nn.Parameter(torch.zeros(1, 1, dim))
        self.beta = nn.Parameter(torch.zeros(1, 1, dim))

    def forward(self, x):
        Gx = torch.norm(x, p=2, dim=1, keepdim=True)
        Nx = Gx / (Gx.mean(dim=-1, keepdim=True) + 1e-6)
        return self.gamma * (x * Nx) + self.beta + x

总结

基于 DiT,能转换语音音色,支持歌声。对比现有 SOTA 在 DNSMOS P.835 相关指标吃亏外,SECS 和 WER 都要更高。