留下阅读 (2024) Zero-shot Voice Conversion with Diffusion Transformers 的痕迹。
Zero-Shot 语音转换,旨在将源语音转换为与未见说话者的参考语音的音色相匹配。代码已经开源:GitHub 仓库。
我看这个实现更单纯,不像 Vevo 还能做 TTS 那样笨重。看完论文就开始调试代码。
总览
要转换说话人音色,先要想办法提取出说话人除音色以外的所有信息。自回归的方法难免会泄漏说话人的音色信息,导致转换不彻底;Bottle neck 设计能抑制音色泄漏的问题,但可能导致像是语义信息的丢失。
对于音色信息的提取,传统方法将来源音频转换为固定长度的向量是不够的。
总之文章亮点:
- 对于提取非音色特征,不使用 VQ-VAE 这种方法,在避免混入音色信息的同时保留完整的语义信息
- 对于提取音色特征,使用完整的来源音频而不是预先处理为固定长度的向量,以获得更好的性能
具体方法
一个 Diffusion Transformer 结构。层数为 隐空间维度为 。使用的 U-Net 结构 TODO 查看这个 unet 是怎么个事
- 通过 AdaLN(adaptive layernor)将 Timestamp 信息融入到 Transformer 层中 TODO 还提到 time 作为 token,不知道这个 time 是指哪个 time
- 使用 RoPE 位置编码
还有一个基于卷积的插值器,能将语义信息的序列插值到与语音匹配的长度。
记一系列符号:
- 扩散时间步
- 音色向量
- 语义信息 ,其中 , 为插值后的长度
- 噪声特征 ,其中 是扩散时间步 下的噪声特征
输入到 DiT 的序列:
如何避免语义信息隐含音色信息
避免提取的语义信息隐含音色信息,最好的办法是在训练时不断修改音色,迫使模型只去学习如何提取语义信息。有两种方法:
- 使用现有的变声器模型
- 使用 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 的流程。
具体流程
- 以采样率 22050 读取
source_wave和target_wave - 衍生出采样率 16000 的
source_wave_16k和target_wave_16k - 对两个 22050 采样率的音频进行 stft 并施加 mel,获得
source_meltarget_mel。代表音色信息- 参数:n_fft=1024,n_mels=80,hop_size=256
- 使用
librosa.filters.mel()获得梅尔滤波器。其参数 fmax 默认为采样率一半 - 使用
torch.hann_window(1024)获得窗 - 首尾填充 n_fft - hop_size 长度,模式 reflect,再进行 center=False 的 torch.stft()
- 使用 center=True 会隐含首位填充 n_fft 长度的操作。不知道代码中的做法是为了什么 TODO
torch.view_as_real(),分离虚数实数域。此时维度 [1, 513, 1074, 2]- 虚数实数平方并相加。此时维度 [1, 513, 1074]
- 施加梅尔滤波器。此时维度 [1, 80, 1074]
torch.log(torch.clamp(x, min=1e-5)),取对数
- 使用模型对采样率 16000 音频提取语义特征,获得
source_content_indicestarget_content_indices- 进入
AstralQuantizer - 使用 hubert-large-ll60k 模型进行特征提取。奇怪的是并没有用上作为 tokenizer 的 whisper-small 模型。似乎使用了 stft,提取的特征维度为 [1, 729, 1024],batch time hidden_size
- 进入
ConvNeXtV2Stage,对提取出的特征进行卷积,维度变为 [1, 729, 512]- 映射特征,Conv1d(1024, 512, kernel_size=1, stride=1)
- 一系列的
ConvNeXtV2Block卷积层
- 进入
BinarySphericalQuantize,对特征进行离散化- Linear(512, 11),获得 11 个码本
- 用 L2 范数标准化,F.normalize(dim = -1),获得
x - 以 0 为分界点,二值化到 0 和 1
- 码本掩码 [1024,512,…,1],长度刚好也是 11。用二值化结果加权相加,获得
indices - 计算损失。我在想,不需要这么复杂。只需要让
x与 -1 或 1 的距离缩短,以及所有 batch 的码本整体尽可能靠近 0- 首先计算
x量化到 -1 和 1 的概率。通过码本维度分别乘上 -1 和 1 再 sigmoid 获得 - 通过信息熵公式 获得损失。损失越小,量化越确定
- 会在各个 batch 和所有 batch 两个维度计算出两个损失,分别令量化确定、码本利用率提升(即不允许各 batch 都使用同一套码本)
- 这个损失权重 0.1
- 首先计算
- 进入
- 对采样率 16000 音频提取风格特征,获得
target_style- 使用
torchaudio.compliance.kaldi.fbank()将波形转换为 FBank 特征频谱,并减去均值。此时维度 [1, 1458, 80] - 使用
CAMPPlus处理,经过 MLP 和池化获得无关时间的固定长度向量。网络层写得很绕,我觉得不如 Linear 直接- self.head,用一个 MLP 将 80 维度映射到 320
- self.xvector,用一个 MLP 将 320 维度映射到 512,将时间维度 1458 映射到 729(减半)
- self.stats,取时间维度的均值和方差 并替代原来的两个维度,则新维度的大小为 1024(512 的 2 倍)
- self.dense,其实就是线性层,1024 映射到 192
- 使用
- 通过目标语义特征
target_content_indices获得长度为target_mel.size(1)的插值结果prompt_condition- Embedding(2048, 512),是的隐含了嵌入映射
- 在时间维度使用 F.interpolate(mode='nearest') 进行插值
- 一个 MLP。由 Conv1d(512, 512, kernel_size=3, strick=1, padding=1) -> LayerNorm -> nn.Mish 重复 4 遍构成,没有残差
- 同上,通过源语义特征
source_content_indices获得长度为source_mel.size(1)的插值结果cond - 接下来用
cond分割一系列chunk_cond进行正式生成 - 目标语义
prompt_condition与本次源语义chunk_cond拼接获得cat_condition- 但在后面的流匹配流程中
prompt_condition部分会被全部置 0,起不了作用
- 但在后面的流匹配流程中
- 进行核心的流匹配环节
- 参数:有
cat_condition,然后target_mel是 prompt,target_style是 style - 创建初始噪声,隐空间维度 80,长度与
cat_condition一致 - 创建余弦的时间 schedule,准备进入流匹配
- 定义
x,这是初始噪声的翻版,但其target_mel长度部分,即对应prompt_condition部分被全部置零 - 定义
prompt_x,是初始噪声的另一种翻版,但其target_mel长度部分由target_mel代替,其余置零 - 一切准备好,进入 DiT 模块
- 时间经过正弦编码,再 Linear -> SiLU -> Linear
cat_condition经过一个 Linear,特征维度 512 不变- 在特征维度拼接
xprompt_xcat_condition,获得x_in x_in经过 Linear,特征维度变回 512target_style经过 Liner 映射维度到 512- 将
target_style拼接到x_in序列开头 - 将时间编码拼接到
x_in序列开头(虽然后续流程依然会将时间信息用到 AdaptiveLayerNorm) - 使用 13 层 Transformer 网络层处理
x_in - 经过 MLP,Linear -> SiLU -> Linear,特征维度从 512 映射回原来的 mel 频谱维度 80
- 根据输入特征不同,获得三种 DiT 处理结果:
- 有
prompt_xtarget_stylecat_condition - 只有
cat_condition语义信息 - 啥信息都没有
- 有
- 三种结果通过 (1 + 0.7 + 0.7)a - 0.7b -0.7c 的方式进行组合
- 这两个 0.7 分别通过 intelligebility_cfg_rate 和 similarity_cfg_rate 配置项控制
- 预测的流方向乘上单位时间,加在
x噪声上 - 一直重复直到完成采样
- 参数:有
- 从流匹配结果取出代表本次源语义
chunk_cond的部分 - 进入 BigVGAN 进行音频解码,完成本次 chunk 的处理
简化流程
首先是准备工作:
- 输入
source_wave和target_wave,分别作为 语音输入 和 目标音色参考 - 获得梅尔频谱
source_meltarget_mel代表音色信息。具体方式是 stft -> 施加梅尔滤波器 -> 虚实域取模 -> 取对数 - 使用 ASTRAL Quantizatio 获得离散化的语义信息
source_content_indicestarget_content_indices - 使用 CAM++ 获得目标风格特征
target_style - 借助 Embedding 将离散语义信息
source_content_indices转换为序列cond,并在序列维度插值到与source_mel相同的长度 - 同 5,借助 Embedding 将离散语义信息
target_content_indices转换为序列prompt_condition,并在序列维度插值到与target_mel相同的长度
现在,我们手里有四个特征:
- 输入语义特征
cond - 目标语义特征
prompt_condition - 目标音色信息
target_mel - 目标风格特征
target_style
接下来为流匹配做准备:
- 创建序列长度与
cond相同的高斯噪声,在通道维度拼接到cond身上 - 由之前步骤可知
prompt_condition与target_mel序列长度相同。在通道维度将target_mel拼接到prompt_condition身上 - 此时输入语义特征
cond包含了噪声,目标语义特征prompt_condition包含了其音色信息 - 将
cond与prompt_condition在序列维度进行拼接,获得cat_condition - 将目标风格特征
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 流程:
- 深度卷积,Conv(512, 512, kernel_size=7, stride1, padding=3, groups=512)
- LayerNorm
- 隐空间维度三倍,Linear(512, 1536)
- GELU 激活
- GRN
- 隐空间维度还原,Linear(1536, 512)
- 残差
深度可分离卷积(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 都要更高。