把 UVR 的 MDX-Net 人声分离搬进浏览器:模型好搬,STFT 难写

博客 · · SwordFishSoft

本站的人声分离工具,能在浏览器标签页里把一首歌拆成人声和伴奏两条音轨,全程不上传。背后的模型是 Ultimate Vocal Remover(UVR,作者 Anjok07 与 aufr33,MIT 许可)项目里的 UVR-MDX-NET Voc_FT:一个 66.8 MB 的 fp32 ONNX 文件,ONNX Runtime Web 在 WebGPU 和 WebAssembly 上都能原样跑起来。

让模型跑起来并不难,难的是让它吐出人声而不是噪声。这篇记录的就是中间这一段:ONNX 文件里没有、得自己在 JavaScript 里补上的信号处理;我们怎么拿 PyTorch 逐样本对拍;还有一项我们写了、后来才发现什么也证明不了的检查。

模型只认频谱

打开 UVR-MDX-NET-Voc_FT.onnx 看,就是一个普通的卷积 U-Net:66 个 Relu、40 个 Conv、27 个 BatchNormalization、22 个 MatMul、5 个 ConvTranspose,opset 13,里面没有 STFT 算子。输入输出都是 [batch, 4, 3072, 256] 的张量:

也就是说,UVR 的 Python 代码在模型外面做的那些事,浏览器都得自己做一遍:把音频切块,用和训练时一模一样的参数做短时傅里叶变换,把复数频谱喂给网络,做逆变换,再把各块拼回去。Transformers.js 没有「音频进、音频出」的任务可以借用,最后写成了一个自定义引擎,约 340 行 JavaScript,跑在 Web Worker 里。

参数别靠猜:UVR 是按哈希查表的

MDX-Net 的每个权重都对应四个不在 ONNX 文件里的数:n_fft、dim_f、dim_t,以及叫 compensate 的增益。网上各种教程、帖子给的数值因模型而异,很容易抄混。错一个数,网络照样输出一张频谱,只是这张频谱没有任何意义。

UVR 自己的做法是查表:它的 application_data 仓库里有一份 model_data.json(我们抓取时有 59 条)。表的键既不是文件名,也不是整个文件的哈希,而是权重文件最后 10000 × 1024 字节的 MD5:

with open('UVR-MDX-NET-Voc_FT.onnx', 'rb') as f:
    f.seek(-10000 * 1024, 2)
    print(hashlib.md5(f.read()).hexdigest())
# 77d07b2667ddf05b9e3175941b4454a0

这个哈希对应的是 n_fft 7680、dim_f 3072、dim_t 2^8、compensate 1.021,主音轨为「Vocals」。整个文件的 MD5 在表里查不到。还有个坑:表里相邻的几条长得很像,比如有一条 FFT 长度和频点数都一样,但 compensate 是 1.045,而且是伴奏模型。抄错一行,增益就差一点,而你很可能永远发现不了。

7680 不是 2 的幂

7680 = 29 × 3 × 5。常见的小型 JavaScript FFT 库大多只支持 2 的幂,用不了。我们写了一个递归的混合基 Cooley–Tukey,基数取 4、2、3、5。第一步是把长度分解成这几个基数:

function radicesOf(n) {
  const out = [];
  let m = n;
  for (const r of [4, 2, 3, 5]) while (m % r === 0) { out.push(r); m /= r; }
  if (m !== 1) throw new Error(`fft size ${n} has a prime factor > 5`);
  return out;
}

基 2 和基 4 手写了蝶形运算。基 3、基 5 在分解里只各出现一次,用一个通用的小 DFT 就够了。旋转因子预先算好,存在 Float64Array 里。再加上一个老办法:长度 n 的实数 FFT 可以用长度 n/2 的复数 FFT 加一步后处理算出来,所以每个 7680 点实变换实际只做 3840 点复变换。全部运算用 float64,只有交给 ONNX Runtime 的那个张量是 float32。

和 torch.stft 一个细节一个细节地对齐

FFT 只是容易的那一半。难的那一半,是和 torch.stft / torch.istft 的默认行为完全一致,因为网络训练时见到的就是那样算出来的频谱。关键有三处:

  1. 周期 Hann 窗:0.5 − 0.5·cos(2πi/N),不是除以 N−1 的对称版本。
  2. 反射补边:center=True 时两端各补 n_fft/2 个样本,镜像补,而且不重复边界上的那个样本:
    function reflectPad(x, out) {
      const n = x.length;
      out.set(x, TRIM);
      for (let j = 0; j < TRIM; j++) out[j] = x[TRIM - j];
      for (let j = 0; j < TRIM; j++) out[TRIM + n + j] = x[n - 2 - j];
    }
  3. 逆变换除以窗平方的包络:torch.istft 把加窗的各帧重叠相加之后,要除以 window² 重叠相加得到的包络。包络只取决于帧的排布,所以只算一次;两端包络趋近于零的地方要防一下除零。

这些都不新鲜,PyTorch 源码里就是这么写的。但每一处都是「差不多对」的结果听起来也「差不多对」、其实是错的地方,所以我们的原则是照抄,不自己发明。

怎么证明是对的:用 PyTorch 当参照,逐样本比

还没开始做界面,我们先写了两个程序,处理同一段 20 秒的 WAV:

再用一个小脚本比较两边:

比什么相关系数相对误差最大绝对差
输入频谱(每块 4 × 3072 × 256)1.000000−136.8 dB6.1e-5(峰值 533)
人声波形1.000000−132.4 dB< 1e-6

−132 dB 的相对误差就是 float32 的舍入,不是真实差异。引擎第一次完整跑就对了。我们觉得这要归功于先查表定参数再写代码,而不是写得有多巧。

要说明一点:这段 20 秒的测试音频是合成的。伴奏用 numpy 生成,「歌手」是 macOS 的 say 朗读,加了变调和颤音,从第 5 秒唱到第 13.55 秒。它很适合检验两套实现是否一致,也能在人声静默的段落量出串音有多少;但它不能说明模型处理你喜欢的那张唱片效果如何,那得拿真歌自己试。

一项永远不会失败的检查

和 UVR 一样,我们的伴奏是用 原曲 − 人声 算出来的,不是模型直接给的。最初的验收规格里写着一条:「人声 + 伴奏与原曲之差须低于 −40 dBFS,用以验证重叠相加。」页面上报出来的是 −240 dBFS,看上去好得不得了。

可它毫无意义。人声 + (原曲 − 人声) − 原曲 按定义恒等于零,−240 dB 不过是为了避免 log(0) 加上去的那个极小值取了 20·log10。不管 STFT 写得对不对,这项检查都会通过。

直觉上的改法也不行:不过模型、只做一次 STFT → iSTFT,再和输入比,残差只比信号低 15 dB 左右。原因是正当的:模型只看 0–3071 号频点,对应的上限是 3072 × 44100 / 7680 = 17,640 Hz,逆变换前我们把更高的频点都清零了,所以往返一次本来就会改变信号。

真正有效的是幂等性。正确的流水线是一个投影:往返一次,去掉 17.6 kHz 以上的内容;拿它的输出再往返一次,应该什么都不变。窗函数错了、少了归一化、补边差一个样本,都会立刻暴露。实测第二次与第一次之差在 Chromium 上是 −151 dB,在 WebKit 上是 −127 dB。验收报告里真正检验重叠相加的是这个数;那个 −240 我们留在报告里,提醒自己空洞的测试长什么样。

GPU 一快,JavaScript 就露出来了

单块的 ONNX Runtime 原始耗时(Apple M2 Max,无头 Chromium,ORT Web 1.26-dev):

后端模型本身 / 每块整块(含 STFT/iSTFT)
WebGPU约 0.31 秒约 0.54 秒
WebAssembly,4 线程约 4.8 秒约 5.1 秒

走 CPU 时,信号处理的开销可以忽略。走 WebGPU 时,按两列相减估算,JavaScript 的变换每块要 0.23 秒左右,约占总时间的 43%。每块要做 1024 次 3840 点复数 FFT:2 个声道 × 256 帧 × 正逆各一次。端到端算,20 秒样例在 WebGPU 上 2.2 秒、WebAssembly 上 19.8 秒;一首 3 分 04 秒的歌分别是 16.9 秒和 157.5 秒,JavaScript 堆峰值 202 MB。

变换部分我们还没优化。下一步显然可以把两个声道分给两个 Worker、把 FFT 挪进带 SIMD 的 WebAssembly,或者干脆在 GPU 上做 STFT。不过有条经验值得单独写下来:对只吃特征、不吃原始数据的模型,要盯紧的是前后处理。GPU 把网络提速 15 倍之后,排在下一位的瓶颈就是网络外面那一圈代码。

给做同类事情的人的几条小建议

试一试

人声分离工具用的就是本文描述的引擎:最长 10 分钟的歌,输出两条 16 位 44.1 kHz 的 WAV,音频始终不离开你的电脑。工具页上注明了 Ultimate Vocal Remover 的署名并附有完整许可证。如果你也在做类似的事,想交流 STFT 的细节,欢迎来信。

文中提到的工具: 去人声、提伴奏——在浏览器里跑的人声分离