歌声转换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)的功能
系列解析:
- 歌声转换SVC主流方法原理剖析1 — DDSP-SVC
- 歌声转换SVC主流方法原理剖析2 — RIFT-SVC
- 歌声转换SVC主流方法原理剖析3 — So-VITS-SVC
- 歌声转换SVC主流方法原理剖析4 — ReFlow-VAE-SVC
- 歌声转换SVC主流方法原理剖析5 — LATHER-SVC
code
https://github.com/ShawnPi233/HQ-SVC
latest commit: 853a18883f6d380d5e9f9b4bf244d602b6d9d320
数据
预处理
HQ-SVC 的训练代码还没有开源,推理时的预处理流程采用 get_processed_file 时实时计算特征,不做缓存
核心特征提取逻辑在 data_preprocessing.py#L224,流程如下:
-
加载音频:分别以 44.1kHz 和 16kHz 读取同一条音频(
audio_44k和audio),前者用于 F0/Volume/Mel 提取,后者用于 FACodec 编码 -
并行提取:使用
ThreadPoolExecutor(max_workers=4)同时跑四个子任务:- F0:RMVPE 提取基频,可选
full(全插值)或part( 边缘填充)两种插值模式 - Volume:逐帧 RMS 提取响度
- Mel:STFT 提取梅尔频谱
- FACodec:编码器提取
content_emb_t+ 解码器分离出spk_emb_t
- F0:RMVPE 提取基频,可选
-
后处理对齐:所有特征对齐到梅尔频谱的帧数
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_emb 与 f0,目标音频提供 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 (Adaptive Instance Normalization) |
|---|---|---|
| 核心公式 | y = γ * x + β |
y = γ * (x - μ) / σ + β |
| 作用对象 | 直接作用于原始特征 x |
作用于归一化后的特征 (x - μ) / σ |
| γ 和 β 来源 | 由一个外部条件向量 c(如语言嵌入)通过网络预测生成 |
通常由一个风格图像的特征统计量(均值、方差)计算得到 |
| 应用场景 | 多模态融合(如视觉问答)、条件生成 | 风格迁移(如将一张图的艺术风格迁移到另一张图) |
| 目的 | 根据条件动态调整特征通道的响应强度和偏移 | 将一张图的内容与另一张图的风格(通道统计信息)对齐 |
train
目前训练代码仍未开源, training codes issues#2
模型架构
DDSP
输入 units_frames(FACodec content, B×T×256),f0,volume,spk(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。
条件融合流程:
- Timbre 解耦:
timbre_extractor(spk)→ MLP(256→512→256) →timbre_embed;style_embed = spk - timbre_embed(残差即风格) - 特征 Embedding:F0 →
log(1+f0/700)→ MLP;Phase →phase/π→ MLP;Volume → MLP - 拼接 + 融合:
[timbre_f0, style, phase, volume]四路 256 维特征拼接 → 1×1 Conv 降维回 256 - FiLM 调制:
x = x * scale(condition) + bias(condition),用融合条件对 content 特征做特征线性调制 - 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
WaveNet(utils/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 训练有四项损失:
- ddsp_loss:DDSP 合成频谱与真值梅尔频谱的 MSE,衡量初级网络质量
- diff_loss:扩散模型噪声预测损失(L2),衡量细化网络质量
- spk_loss:InfoNCE 说话人对比损失,τ=0.1,确保音色嵌入的说话人区分度
- 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
- 源音频处理:加载 → FACodec 提取 content + spk → RMVPE 提取 F0 → Volume
- 目标说话人提取:多条参考音频分别提取 spk_embed → 平均 得到目标说话人嵌入
- F0 调整:可选手动 shift_key 或 AUTO PITCH(源/目标有效 F0 均值的半音差)
- 模型推理:content + 调整后 F0 + volume + 目标 spk → DDSP → Diffusion → Mel
- 声码器: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真不太合适吧)

浙公网安备 33010602011771号