把 UVR 的 MDX-Net 人声分离搬进浏览器:模型好搬,STFT 难写
本站的人声分离工具,能在浏览器标签页里把一首歌拆成人声和伴奏两条音轨,全程不上传。背后的模型是 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] 的张量:
- 4 个平面:左声道的实部、虚部,然后是右声道的实部、虚部;
- 3072 个频点:7680 点 FFT 产出 3841 个频点,只取最低的 3072 个;
- 256 帧:44.1 kHz 下帧移 1024 个样本,一次调用约处理 5.9 秒音频。
也就是说,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 的默认行为完全一致,因为网络训练时见到的就是那样算出来的频谱。关键有三处:
- 周期 Hann 窗:
0.5 − 0.5·cos(2πi/N),不是除以N−1的对称版本。 - 反射补边:
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]; } - 逆变换除以窗平方的包络:
torch.istft把加窗的各帧重叠相加之后,要除以window²重叠相加得到的包络。包络只取决于帧的排布,所以只算一次;两端包络趋近于零的地方要防一下除零。
这些都不新鲜,PyTorch 源码里就是这么写的。但每一处都是「差不多对」的结果听起来也「差不多对」、其实是错的地方,所以我们的原则是照抄,不自己发明。
怎么证明是对的:用 PyTorch 当参照,逐样本比
还没开始做界面,我们先写了两个程序,处理同一段 20 秒的 WAV:
reference.py:torch.stft→ ONNX Runtime(Python,CPU)→torch.istft,分块方式照搬 UVR;compare.mjs:我们的engine.js→ ONNX Runtime(Node,CPU),把原始的 float32 频谱和波形导出来。
再用一个小脚本比较两边:
| 比什么 | 相关系数 | 相对误差 | 最大绝对差 |
|---|---|---|---|
| 输入频谱(每块 4 × 3072 × 256) | 1.000000 | −136.8 dB | 6.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 倍之后,排在下一位的瓶颈就是网络外面那一圈代码。
给做同类事情的人的几条小建议
- 分块由页面驱动,不放在引擎里。页面每 5.9 秒调一次引擎,进度条才是真的,「停止」按钮才真能停,内存也保持平稳。代价是每块约 2 MB 的结构化克隆拷贝,和其他开销比可以忽略。
- 用「带上下文再裁掉」代替交叉淡化。每块读取时两侧各多带
n_fft/2个样本的上下文,只保留中间部分,和 UVR 拼接整首歌的方式完全一样,没有淡化曲线要调。 - onnxruntime-node 没有
wasm执行提供者。对拍脚本一开始直接沿用浏览器配置,报了「backend not found」。开发期加一个providers: ['cpu']覆盖就好了。 - 只用 fp32。UVR 发布的就是 fp32 权重,我们没有自己导出 fp16 或 int8。66.8 MB 已在预算之内,重新量化就得把刚证明过的东西再验一遍。
试一试
人声分离工具用的就是本文描述的引擎:最长 10 分钟的歌,输出两条 16 位 44.1 kHz 的 WAV,音频始终不离开你的电脑。工具页上注明了 Ultimate Vocal Remover 的署名并附有完整许可证。如果你也在做类似的事,想交流 STFT 的细节,欢迎来信。
文中提到的工具: 去人声、提伴奏——在浏览器里跑的人声分离