歌声转换SVC主流方法原理剖析6 — HQ-SVC

pre

本文SVC指的是歌声转换(Singing Voice Conversion (SVC)),例如常见且开源的 So-VITS-SVC, RVC, DDSP-SVC
关键词:歌声转换、声音克隆、声音超分、Zero-Shot

拖了好久,第6篇总算生了出来,这次的属于 zero-shot 方案,而且还带超分(16 kHz 到 44.1 kHz)的功能

系列解析:

code

https://github.com/ShawnPi233/HQ-SVC

latest commit: 853a18883f6d380d5e9f9b4bf244d602b6d9d320

数据

预处理

HQ-SVC 的训练代码还没有开源,推理时的预处理流程采用 get_processed_file 时实时计算特征,不做缓存

核心特征提取逻辑在 data_preprocessing.py#L224,流程如下:

  1. 加载音频:分别以 44.1kHz 和 16kHz 读取同一条音频(audio_44kaudio),前者用于 F0/Volume/Mel 提取,后者用于 FACodec 编码

  2. 并行提取:使用 ThreadPoolExecutor(max_workers=4) 同时跑四个子任务:

    • F0:RMVPE 提取基频,可选 full(全插值)或 part( 边缘填充)两种插值模式
    • Volume:逐帧 RMS 提取响度
    • Mel:STFT 提取梅尔频谱
    • FACodec:编码器提取 content_emb_t + 解码器分离出 spk_emb_t
  3. 后处理对齐:所有特征对齐到梅尔频谱的帧数 seq_len,插值、截断或补零的方式

预处理流程在推理时按需调用,配置在 hq_svc_infer.yaml 里,比较简洁。

FACodec(内容与说话人解耦)

FACodec 来自 24 年的一篇 TTS 论文 NaturalSpeech 3 ,用它而不是 HuBERT / ContentVec,是 HQ-SVC 跟之前的 SVC 方法最大的不同之处。

一些疑问

FACodec 在 data_preprocessing.py#L261-270 中加载使用:

audio_t = torch.from_numpy(wav_pad(audio)).unsqueeze(0).unsqueeze(0).to(device)
enc_out = fa_encoder(audio_t)
prosody = fa_encoder.get_prosody_feature(audio_t)
content_emb_t, _, _, _, spk_emb_t = fa_decoder(enc_out, prosody, eval_vq=False, vq=True)

其中 fa_decoder 直接吐出了内容编码content_emb_t跟说话人嵌入spk_emb_t,让我们进入解码器相关代码 utils/Amphion/models/codec/ns3_codec/facodec.py#L1086 ,能看到

def forward(self, x, prosody_feature, vq=True, get_vq=False, eval_vq=True, speaker_embedding=None, n_quantizers=None, quantized=None,):
        ...
        x_timbre = x
        outs, qs, commit_loss, quantized_buf = self.quantize(
            x, prosody_feature, n_quantizers=n_quantizers
        )

        x_timbre = x_timbre.transpose(1, 2)
        x_timbre = self.timbre_encoder(x_timbre, None, None)
        x_timbre = x_timbre.transpose(1, 2)
        spk_embs = torch.mean(x_timbre, dim=2)
        return outs, qs, commit_loss, quantized_buf, spk_embs

也就是说,他们将编码器输出enc_out进行量化,结果outs直接作为内容编码content_emb_t,而说话人嵌入spk_emb_t则没有经过量化,是通过单独的 Transformer 编码器timbre_encoder提取的。但关键是量化的结果真的就是内容编码吗?

让我们来看看这个quantize函数:

def quantize(self, x, prosody_feature, n_quantizers=None):
    outs, qs, commit_loss, quantized_buf = 0, [], [], []

    # prosody
    f0_input = prosody_feature.transpose(1, 2)  # (B, T, 20)
    ...
    f0_quantizer = self.quantizer[0]
    out, q, commit, quantized = f0_quantizer(f0_input, n_quantizers=n_quantizers)
    outs += out
    qs.append(q)
    quantized_buf.append(quantized.sum(0))
    ...

    # phone
    phone_input = x
    phone_quantizer = self.quantizer[1]
    out, q, commit, quantized = phone_quantizer(
        phone_input, n_quantizers=n_quantizers
    )
    outs += out
    ...

    # residual
    if self.vq_num_q_r > 0:
        ...
        residual_input = x - (quantized_buf[0] + quantized_buf[1]).detach()
        out, q, commit, quantized = residual_quantizer(
            residual_input, n_quantizers=n_quantizers
        )
        outs += out
        ...

    ...
    return outs, qs, commit_loss, quantized_buf

能看到这个 outs,实际上是三个量化器输出的和。三个量化器的任务,按照注释来看,应该分别对应三个子空间,有各自的码本。借助量化和有效的的损失设计,从编码结果enc_out中分离内容跟韵律,最后去掉前面两者,剩下的则是细节:

  • 韵律子空间:对应 quantizer[0](F0 分支)
  • 内容子空间:对应 quantizer[1](phone 分支)
  • 细节子空间:对应 quantizer[2](residual 分支)

也就是说在 FACodec 原始设计里,quantizer[1]的输出才应该是内容编码,而 outs(韵律 + 内容 + 声学细节) 则是喂给语音解码器的总特征,用来重建语音波形。

但不知为什么,HQ-SVC 这里选择outs作为内容编码,似乎白费了 FACodec 辛苦分离的努力。

回归预处理流程

总之,借助FACodec编码器将 16kHz 音频压缩为 256 维帧级特征enc_out,然后使用残差矢量量化RVQ得到内容特征(content_emb) 、并同时过一个 TransformerEncoder 得到说话人嵌入(spk_emb)(256 维全局向量),注意spk_emb是没有经过量化的(代码中这部分缝到了解码器上面)。

这样在推理时,源音频提供 content_embf0,目标音频提供 spk_emb ,于是就能实现零样本转换得到效果【又是熟悉的解耦与组合】:不需要目标说话人的训练数据,只需几秒参考音频的 spk_emb。相当于把重任交给了FACodec,它的解耦彻底与否决定了本项目的音色转换上限。【但根据目前的分析来看,恐怕这个上限会比较低?】

主要流程

infer

入口应该是 ./gradio_app.py#L80,加载了HQ-SVC模型,然后是经典操作:对预处理提取的特征先合成梅尔频谱,然后借助声码器合成最终波形,

mel_g = NET_G(src_data['vq_post'].unsqueeze(0).to(device), f0, src_data['vol'].unsqueeze(0).to(device), spk_ave, gt_spec=None, infer=True, infer_speedup=ARGS.infer_speedup, method=ARGS.infer_method, vocoder=VOCODER)
wav_g = VOCODER.infer(mel_g, f0) if ARGS.vocoder == 'nsf-hifigan' else VOCODER.infer(mel_g)

上面src_data['vq_post']就是预处理的内容特征content_emb_t

F0Predictor

顺着 ./gradio_app.py 能找到 utils/models/models_v2_beta.py#L84 有个 HQ_SVC 类,Forward 开头就是 F0Predictor,它是一个轻量级的双头 MLP,将说话人空间映射到音高空间:

tar_log_f0_mean, tar_log_f0_var = self.f0_predictor(self.ddsp_model.unit2ctrl.timbre_extractor(spk))
src_log_f0_mean, src_log_f0_var = self.f0_predictor(self.ddsp_model.unit2ctrl.timbre_extractor(src_spk))
f0, shift_key = adjust_f0(f0, src_log_f0_mean, src_log_f0_var, tar_log_f0_mean, tar_log_f0_var)
print(f'shift key: {shift_key}')

当存在源说话人嵌入,且显式开启时,分别对源说话人跟目标说话人预测一组音高统计量(均值和方差),然后计算半音偏移量施加到源 F0 上,将音区移调至匹配目标音区并保留了源语音的相对旋律轮廓。感觉跟 sov 的F0Decoder很有一点神似:

def adjust_f0(src_f0, src_log_f0_mean, src_log_f0_var, tar_log_f0_mean, tar_log_f0_var):
    """根据目标分布调整 F0"""
    semitone_difference = 12 * (tar_log_f0_mean - src_log_f0_mean) / torch.log(torch.tensor(2.0))
    semitone_difference_rounded = torch.round(semitone_difference)
    adjustment_factor = torch.pow(2, semitone_difference_rounded / 12)
    adjusted_f0 = src_f0 * adjustment_factor
    return adjusted_f0, semitone_difference_rounded

然后修正之后的 F0 直接扔给 DDSP 模型,得到一个粗糙波形。然后对这个粗糙波形提取梅尔频谱,作为初始条件,扔给另一个扩散模型,进行新梅尔频谱的合成。最后再靠声码器跟 F0 合成最终的音频:

# DDSP
ddsp_wav, hidden, timbre = self.ddsp_model(x, f0, volume, spk, infer=True)
ddsp_mel = vocoder.extract(ddsp_wav)  # 提取 DDSP 频谱作为扩散起点

# 扩散
if k_step > 0:
    mel = self.diff_model(hidden, timbre_f0, gt_spec=ddsp_mel, infer=True, infer_speedup=10, method='dpm-solver')
else:
    mel = ddsp_mel  # 不扩散,直接用 DDSP 输出

# 声码器解码
wav = vocoder.infer(mel, f0)  # NSF-HiFiGAN: mel + F0 → 波形

看着很熟悉?简直就是 DDSP-SVC/RIFT-SVC 的流程,除了最开始的内容编码提取跟 F0 的处理,并没有什么不同。

Unit2ControlFacV5A

不过在具体的模型架构上还是有一点小改变的,除了不再需要单独的说话人嵌入层nn.Embedding(n_spk, 256)(FACodec能直接提取说话人嵌入),在utils/models/ddsp/unit2control.py#l120 能看到使用了FiLM将内容特征x与四个条件特征timbre_f0, style, phase, volume融合:

x = self.stack(units.transpose(1, 2)).transpose(1, 2)
...
condition_style = torch.cat([timbre_f0, style_feat, phase_feat, volume_feat], dim=-1)
...
x = self.film(x, condition_style)

而DDSP-SVC里面则是通过简单加法实现的:DDSP-SVC/ddsp/unit2control.py#L75

x = self.stack(units.transpose(1, 2)) + self.stack2(exciter)
x = x.transpose(1, 2) + self.volume_embed(volume)
...

话说FiLM这玩意跟AdaIN真像呢,论文也是同一年的。

特性 FiLM AdaIN (Adaptive Instance Normalization)
核心公式 y = γ * x + β y = γ * (x - μ) / σ + β
作用对象 直接作用于原始特征 x 作用于归一化后的特征 (x - μ) / σ
γ 和 β 来源 由一个外部条件向量 c(如语言嵌入)通过网络预测生成 通常由一个风格图像的特征统计量(均值、方差)计算得到
应用场景 多模态融合(如视觉问答)、条件生成 风格迁移(如将一张图的艺术风格迁移到另一张图)
目的 根据条件动态调整特征通道的响应强度和偏移 将一张图的内容与另一张图的风格(通道统计信息)对齐

train

目前训练代码仍未开源, training codes issues#2

模型架构

DDSP

输入 units_frames(FACodec content, B×T×256),f0volumespk(FACodec speaker embed, B×256)

输出 DDSP 合成波形 signal,隐层特征 hidden(B×T×256),音色嵌入 timbre

目的 基于减法合成原理生成初级音频:梳状波激励信号 + 噪声激励信号,分别通过帧级滤波器整形后叠加,overlap-add 合成波形。滤波器参数由 Unit2Control 模块预测。

核心合成公式utils/models/ddsp/vocoder.py#L186

signal_fft = combtooth_fft * src_filter + noise_fft * noise_filter

Unit2ControlFacV5A

输入 content units, F0, phase, volume, spk_embed

输出 控制参数字典 {harmonic_magnitude, harmonic_phase, noise_magnitude},隐层特征 x,音色嵌入 timbre

目的 将多源条件融合并预测 DDSP 滤波器参数。这是 HQ-SVC 的条件工程核心,位于 utils/models/ddsp/unit2control.py

条件融合流程:

  1. Timbre 解耦timbre_extractor(spk) → MLP(256→512→256) → timbre_embedstyle_embed = spk - timbre_embed(残差即风格)
  2. 特征 Embedding:F0 → log(1+f0/700) → MLP;Phase → phase/π → MLP;Volume → MLP
  3. 拼接 + 融合[timbre_f0, style, phase, volume] 四路 256 维特征拼接 → 1×1 Conv 降维回 256
  4. FiLM 调制x = x * scale(condition) + bias(condition),用融合条件对 content 特征做特征线性调制
  5. Transformer 解码:3 层 PCmer(Post-Conv Merge Transformer)+ LayerNorm + Linear 输出

相比 DDSP-SVC 原版的简单相加 c + f0_embed + spk_embed,HQ-SVC 用 FiLM 让条件与内容做乘性交互而非加性拼接,信息融合更充分。另外 style = spk - timbre 的残差设计也很有意思——把说话人嵌入拆成"身份"和"风格"两部分,分别注入不同位置。

CFGDiffusion / GaussianDiffusion

输入 DDSP 隐层 hidden(条件),DDSP 合成频谱 ddsp_mel(起点),可选 gt_spec(训练真值)

输出 细化后的梅尔频谱

目的 对 DDSP 初级输出的频谱做扩散模型细化,修正谐波细节和噪声塑形。

HQ-SVC 支持两种扩散模式(utils/models/models_v2_beta.py#L107-L112):

  • CFGDiffusion:当 guidance_scale >= 0 时启用,使用 ControlWaveNet 作为去噪网络,支持 Classifier-Free Guidance(推理时放大条件信号的影响)
  • GaussianDiffusion:标准 DDPM,使用 WaveNet 作为去噪网络

WaveNet / ControlWaveNet

WaveNetutils/models/wavenet.py#L274-L319):经典结构,20 层 ResidualBlock(dilation=1),每层含膨胀卷积 + 时间步嵌入 + 门控激活。条件通过 1×1 Conv 注入。

ControlWaveNet:HQ-SVC 的特色版本,在 ResidualBlock 上引入了说话人条件调制。代码中实现了三种调制方式:

  • ResidualBlockAdaIN:Instance Norm + learnable scale/shift(类似 StyleGAN 的 AdaIN)
  • ResidualBlockFiLM:LayerNorm + AdaLN scale/shift(更稳定)
  • ResidualBlockNew:FiLM 调制直接作用于 conditioner 投影

推理时根据 guidance_scale 做 CFG:条件注入时用说话人嵌入,无条件时随机/零嵌入,两者输出加权组合。

F0Predictor

输入 音色嵌入 timbre(B×256)

输出 f0_mean, f0_var(各 B×1)

目的 从说话人音色中预测其典型音高分布(均值和方差),用于推理时自动调整源 F0 到目标说话人的音域范围。

调整逻辑(utils/models/models_v2_beta.py#L43-L49):

semitone_difference = 12 * (tar_log_f0_mean - src_log_f0_mean) / log(2)
adjusted_f0 = src_f0 * 2^(round(semitone_difference) / 12)

即:计算源/目标说话人音高均值的半音差,四舍五入后整体平移 F0。这比手动设 shift_key 更优雅——模型自己知道目标该唱多高。

SpeakerClassifier + InfoNCE

输入 音色嵌入 timbre(B×256)

输出 分类 logits(B×100),用于计算对比损失

目的 训练时施加 InfoNCE 说话人对比损失,强制 timbre_extractor 提取的嵌入在同类说话人间相似、异类间远离——确保 content 和 speaker 的解耦质量。

InfoNCE 实现(utils/models/models_v2_beta.py#L16-L33):有监督模式(同 spk_id 为正例)和无监督模式(自身为唯一正例),温度参数 τ=0.1

训练

损失函数

HQ-SVC 训练有四项损失:

  1. ddsp_loss:DDSP 合成频谱与真值梅尔频谱的 MSE,衡量初级网络质量
  2. diff_loss:扩散模型噪声预测损失(L2),衡量细化网络质量
  3. spk_loss:InfoNCE 说话人对比损失,τ=0.1,确保音色嵌入的说话人区分度
  4. f0_loss:F0 预测器输出的均值/方差与真值 F0 统计量的 L1 损失
# 核心逻辑(简化)
losses = {}
losses['ddsp_loss'] = F.mse_loss(ddsp_mel, gt_spec)
losses['diff_loss'] = diff_model(hidden, timbre_f0, gt_spec=gt_spec, k_step=k_step, infer=False)
losses['spk_loss']  = infonce_loss(timbre, spk_id, 0.1)  # mode 中含 infonce 时
losses['f0_loss']   = get_f0_loss(spk, f0_predictor(timbre), f0)  # mode 中含 pred_f0 时

与 DDSP-SVC 的相比,HQ-SVC 多了一个 spk_loss(InfoNCE)

推理

推理流程在 gradio_app.py#L92-L148,支持两种模式:

零样本 SVC

  1. 源音频处理:加载 → FACodec 提取 content + spk → RMVPE 提取 F0 → Volume
  2. 目标说话人提取:多条参考音频分别提取 spk_embed → 平均 得到目标说话人嵌入
  3. F0 调整:可选手动 shift_key 或 AUTO PITCH(源/目标有效 F0 均值的半音差)
  4. 模型推理:content + 调整后 F0 + volume + 目标 spk → DDSP → Diffusion → Mel
  5. 声码器:Mel + F0 → NSF-HiFiGAN → 44.1kHz 波形

超分辨率

不提供目标参考音频时,源音频的 spk_embed 直接用于重建——DDSP + Diffusion 在同一说话人身份下做频谱细化,实现 16kHz → 44.1kHz 的超分辨率。

if is_reconstruction:
    # 源音频自重建,spk_embed 来自自身
    t_data = get_processed_file(source_audio, ...)
    spk_ave = t_data['spk'].squeeze()
else:
    # 多条目标音频平均 spk_embed
    spk_ave = torch.stack(spk_list).mean(dim=0).squeeze()

目标说话人嵌入做了缓存(按文件名 hash),同一组参考音频不重复提取——实用的工程优化

碎碎念

总体是能看出是在 DDSP 框架上进行的改进,但正如文章中所说的,要命的是核心创新点 FACodec 内容特征似乎拿错了。根据我的实测,即便用上 20 条,参考音频去生成效果也非常一般,保留了大量源说话人的特征,并且参考音频的数量多少,听不出差别。

测试基于自己 fork 的仓库,因为原仓库只给了linux的环境包,于是自己fork了一个,去掉了fairseq跟gradio,使用leaf作为ui层进行推理。

实测生成的根本就是源音频,没啥差别,输入源音频为 物述有栖的「テオ」,参考音频为惠惠(めぐみん)的游戏原声提取+部分个人主题曲(之前训练其他svc所用的训练集)。对于so-vits-svc而言,最明显的是在 1:30 的地方会将 「眼差しを」嘴瓢成 「まななしの」,以及 1:32 「僕達はもう」—>「僕達はのう」。而HQ-SVC完全没这个问题,不如说整首歌听起来只是更沙哑一点的alice原版本。此外也尝试使用天神子兔音的版本作为推理源,效果稍微好一点?但还是很明显,一听就露馅。

但好消息是真的很快,3060,20个参考音频,一共3秒推理完成:

sample time step: 100%|██████████| 10/10 [00:03<00:00,  3.30it/s]

由于作者没开源训练代码,只能说强烈建议作者试一下换一个拿内容特征的方式看看,测试结果与分析的非常贴合,完全就是受到了内容特征溢出的源说话人特征影响。不过毕竟是zero-shot,说到底也没理由比其他动辄几千几十万步训练的方法更好,而且就个人而言,svc没啥zero-shot需求,还是希望效果能尽量再好一些。

音频超分辨率没测,没这个需求。

(话说这名字取HQ真不太合适吧)

posted @ 2026-08-18 21:52  NoNoe  阅读(12)  评论(0)    收藏  举报