单通道音乐人声分离的DRNN实战:原理与PyTorch实现
简介基于深度循环神经网络DRNN实现的单通道音乐人声分离Python源码可运用于计算机、人工智能、通信工程、自动化、电子信息等专业的毕业设计、课程设计或期末大作业也适合作为深度学习初学者的进阶练习。压缩包内一共包含5个文件其中4个Python脚本负责网络模型构建、训练推理、SDR评估以及音频与TXT格式数据处理1个Markdown说明文档对项目结构、运行方式与参数设置做了简要说明。整个资源包仅8KB体量轻巧、结构清晰便于快速部署和二次修改。截至目前已有379人学习浏览。代码经实际测试可正常运行除基础的人声分离流程外还附带Conv-TasNet相关实现与评估脚本方便使用者对比不同网络在分离效果上的差异既可直接用于课设、毕设或项目初期演示也为进一步扩展功能提供了可借鉴的代码框架。1. 单通道音乐人声分离DRNN 这条路到底为什么有人选一个很常见的需求是手里只有一条音轨比如一段录制好的翻唱、一部老电影的混音声道或者只是手机录的“清唱背景音乐”视频想把主唱的声音单独提出来。这时候没有立体声声像可参考没有多轨工程可用能利用的只有信号本身的时间和频率结构。DRNNDeep Recurrent Neural Network深度循环神经网络解决这个问题的思路是把音频切成短时频谱再让堆叠的循环层去预测一个“人声掩码”哪些频点是人声主导哪些频点是伴奏主导。掩码乘回原谱再逆变换成波形。这个路线既不需要手工设计滤波器也不需要乐理先验训练代码用 Python 和 PyTorch 就能在单卡 GPU 上跑出可试听的结果。下面按原理、数据、训练、评估四层展开照着做三天内能复现出第一个 demo。2. 为什么 DRNN 比简单滤波更适合单通道音乐人声分离2.1 单通道难点没有相位差只有时间和频率两个线索立体声分离可以借助左右声道到达时间的差异、声像位置、甚至中侧分量展开来分离人声和伴奏但单通道输入把这些捷径全砍掉了。混合信号在数学上就是简单相加混合 人声 伴奏外加可能的混响和噪声。若只看单个频点、单独一帧的幅度人声和伴奏经常重叠在同一个频带上很难判断当前帧是歌手的元音还是钢琴的延音。人的听觉能分辨是因为听的不是一帧而是一段时间的演化。男声基频通常在 85 到 180Hz 之间女声基频在 165 到 255Hz 之间歌声里有音节边界、颤音、辅音起始这些明显的时序模式而伴奏的鼓点、贝斯、吉他有另一套稳定的节奏和和声结构。DRNN 的核心假设就是只要模型能看足够长的上下文它就可以根据“上一帧在唱什么”“下一帧准备唱什么”来推断当前帧里人声占多少比例。这正是一个序列到序列的预测问题循环网络比独立处理每一帧的前馈网络天然更合适。2.2 DRNN 的“深度”具体体现在两个层面从网络结构看单层 LSTM 已经具备时间记忆能力但单层的隐藏状态必须同时承担“区分频谱细节”和“编码长距离上下文”两种职责表示效率会很快到顶。DRNN 沿着时间步堆叠多个循环层把表示能力拆到不同抽象层级上底层循环层处理短时频谱的局部模式比如单个共振峰在几十毫秒内的变化中间层把相邻频点组合成类似音素的模式高层则在大约半秒到一秒的尺度上建模音节结构和走向。下面的示意结构是 DRNN 类人声分离源码里最常见的主干输入对数幅度谱经过多层双向 LSTM最后用 sigmoid 输出掩码。# 示意结构双向 LSTM 输出人声掩码 class MaskRNN(nn.Module): def forward(self, x): # x: [B, T, F] 对数幅度谱 h, _ self.lstm(x) # 在时间维度 T 上递归 return torch.sigmoid(self.proj(h)) # 每个频点输出一个人声比例这里B是批量大小T是帧数F是频率点数。双向 LSTM 表示每个位置的输出同时依赖它之前和之后的帧。离线分离不要求实时用双向会比单向多拿 1 到 2 个百分点的指标如果将来要部署到直播场景或只能看过去帧的环境再改成单向 LSTM 并用状态缓存代价是低音区和句尾会稍浑浊。循环单元的选择也很影响最终指标。三种常见基底对比基底单元参数量时间建模能力在分离源码中出现频率LSTM中有门控长上下文好最常见GRU较少接近 LSTM偶见双向 RNN更多离线效果好不可流式离线项目常用2.3 用掩码回归而不是直接预测音频波形另一个容易被忽略的选择是模型输出人声波形还是人声掩码。早期 WaveNet 类的做法直接输出采样点好处是不需要逆变换恢复相位但代价是非常消耗算力而且采样点级别的 L1 损失并不等于听觉质量。频谱掩码的做法把问题缩小了很多模型只要判断每个时频格子里有多少比例属于人声。假设在同一个时频点人声幅度是 V伴奏幅度是 A理想比掩码定义为IRM V / (V A)训练时用混合幅度谱的频点相加近似这个比例网络输出与 IRM 之间的距离用 L1 或 L2 损失约束。推理阶段用掩码乘混合谱幅度再使用混合信号的相位恢复波形。这个“混合相位”近似之所以能 work是因为人耳对相位误差的敏感度远低于幅度误差在单通道分离的绝大多数源码实现中这就是默认做法。3. 搭建 DRNN 训练集从 WAV 到频谱对3.1 数据集目录怎么组织更方便做训练对音乐人声分离常用的公开评测集是 MUSDB1818 首完整歌曲的混音每首歌自带 vocals、drums、bass、other 四个分轨。做法是把 drums、bass、other 加起来得到伴奏轨再与 vocals 组成另一个训练对。若是自己收集数据目录结构可以保持简单dataset/ ├── train/ │ ├── mix/ # 混合后的单声道 wav │ ├── vocals/ # 人声分轨 wav ├── test/ │ ├── mix/ │ └── vocals/这里有一个隐藏细节很多人会把训练用的 wav 直接丢给模型但 DRNN 很少直接消费波形。为了对齐帧数加载时必须把混合和人声的长度裁剪一致最好在预处理阶段就统一采样率为 44100Hz 并转成单声道。使用立体声源做训练时我对左右声道直接求平均再进网络这样测试时的单声道输入才不会有通道失配。3.2 n_fft 与 hop_length必须成对确认的参数STFT 参数直接决定模型看到的时间分辨率和频率分辨率。默认配置通常是 n_fft2048、hop_length512窗函数用 Hann 窗。对 44.1kHz 的音乐信号n_fft2048 对应约 46ms 的分析窗频点数为 1025每个频点间隔约 21.5Hz。这个频率分辨率对男声基频和大多数乐器谐波都够用。各参数的影响整理成一张表参数常见取值对分离结果的影响备注n_fft2048频率分辨率约 21.5Hz谐波更清晰对低频鼓点不够细可提到 4096hop_length512帧移约 11.6ms时间平滑与 n_fft2048 匹配掩码抖动作小windowhann旁瓣泄漏较小不要随意换成矩形窗centerTrue补零使逆变换更稳定PyTorch 与 librosa 要保持一致若 n_fft 提至 4096频率点间距缩到约 10.8Hz低频贝斯线条会更稳但同一时间内帧数变少LSTM 看到的上下文在帧数上变短需要同步把训练切片的帧数从 128 提到 160 或 256否则句尾的人声会被切断。3.3 数据预处理代码对数幅度谱与随机裁剪实际做训练时整首歌的频谱可能有上万帧直接放进 LSTM 显存必定爆炸。所以我在预处理时把频谱切成固定长度的片段并按需动态加载。下面是抽取训练对的核心函数# data_utils.py import numpy as np import librosa def load_mag_pair(mix_path, vocal_path, sr44100, n_fft2048, hop512): # 统一采样率并转单声道 mix, _ librosa.load(mix_path, srsr, monoTrue) vocal, _ librosa.load(vocal_path, srsr, monoTrue) # 对齐长度后取幅值谱 length min(len(mix), len(vocal)) mix, vocal mix[:length], vocal[:length] mix_spec np.abs(librosa.stft(mix, n_fftn_fft, hop_lengthhop)) vocal_spec np.abs(librosa.stft(vocal, n_fftn_fft, hop_lengthhop)) return mix_spec, vocal_spec def sample_window(mix_spec, vocal_spec, n_frames128): T mix_spec.shape[1] if T n_frames: pad_width ((0, 0), (0, n_frames - T)) return np.pad(mix_spec, pad_width), np.pad(vocal_spec, pad_width) start np.random.randint(0, T - n_frames 1) return mix_spec[:, start:start n_frames], vocal_spec[:, start:start n_frames]代码里用的是线性幅度谱没有取对数。原因是训练目标是人声掩码掩码本质上是一个比例线性谱做分母更稳定。取对数后 0 值帧会出现负无穷还得依赖 log1p 之类的技巧但这对掩码计算并没有额外好处。训练时直接对分片后的线性谱做 per-sample 标准化网络输入会平滑很多。4. 训练与推理完整 python 源码的骨架4.1 Dataloader 与掩码目标的计算这一节给出一个能跑的 PyTorch Dataloader。数据对的流程是读取混合与人声的幅值谱随机切出 128 帧然后把人声幅值除以混合幅值截断到 0 到 1 之间作为网络的训练目标。# dataset.py import torch from torch.utils.data import Dataset from data_utils import load_mag_pair, sample_window class VoiceSeparationDataset(Dataset): def __init__(self, file_pairs, n_frames128): self.file_pairs file_pairs # [(mix_path, vocal_path), ...] self.n_frames n_frames def __len__(self): return len(self.file_pairs) def __getitem__(self, idx): mix_path, vocal_path self.file_pairs[idx] mix_spec, vocal_spec load_mag_pair(mix_path, vocal_path) mix_spec, vocal_spec sample_window(mix_spec, vocal_spec, self.n_frames) # mask 目标人声幅度 / 混合幅度clip 防止除零和过冲 eps 1e-8 mask_target np.clip(vocal_spec / (mix_spec eps), 0.0, 1.0) # 转成 [T, F] 并保证连续内存 mix_t torch.tensor(mix_spec.T, dtypetorch.float32, requires_gradFalse) mask_t torch.tensor(mask_target.T, dtypetorch.float32, requires_gradFalse) return mix_t, mask_t这个数据集强调的是“直接做人声掩码的监督”。若 mask 值集中在 0 到 0.3 之间也不要吃惊因为大多数时频格子里伴奏能量或静音占比更高网络训练初期输出会整体偏小。4.2 完整 DRNN 模型结构与超参表在网络实现中我通常在进入 LSTM 前加一个线性升维层让稀疏的频点特征先被投影到更宽的表示空间缓解循环层第一层的压力。完整模型如下# model.py import torch import torch.nn as nn class DRNNSeparator(nn.Module): def __init__(self, n_bins1025, hidden512, layers3, dropout0.3): super().__init__() self.pre nn.Sequential( nn.Linear(n_bins, hidden * 2), nn.ReLU(), nn.LayerNorm(hidden * 2), # 对频点维度做归一化 ) self.lstm nn.LSTM( input_sizehidden * 2, hidden_sizehidden, num_layerslayers, batch_firstTrue, bidirectionalTrue, dropoutdropout if layers 1 else 0.0, ) self.out nn.Linear(hidden * 2, n_bins) # 双向输出是 2*hidden def forward(self, x): # x: [B, T, F] x self.pre(x) x, _ self.lstm(x) return torch.sigmoid(self.out(x))关键超参如下表这套配置在 44.1kHz 音乐上是比较稳的起点参数数值说明n_bins1025n_fft2048 对应的频点数hidden512双向后实际隐层宽度为 1024layers3再加深到 5 层收益变小训练变慢n_frames128约 1.49 秒上下文兼顾显存batch_size8依赖 GPU 显存加大可提升稳定性初始学习率1e-3Adam 默认15 个 epoch 后减半梯度裁剪阈值5.0LSTM 沿时间展开时防止梯度爆炸4.3 训练循环归一化、损失与梯度裁剪训练循环里我会对每个 batch 做输入标准化对混合谱在频点维度上减均值、除标准差。注意 mask 目标不要做同样的标准化因为它是比例值标准化后就不再是“人声占比”了。# train.py import torch import torch.nn.functional as F from model import DRNNSeparator model DRNNSeparator(n_bins1025, hidden512, layers3, dropout0.3) opt torch.optim.Adam(model.parameters(), lr1e-3) sched torch.optim.lr_scheduler.StepLR(opt, step_size15, gamma0.5) def normalize_spec(x): mean x.mean(dim2, keepdimTrue) std x.std(dim2, keepdimTrue) 1e-5 return (x - mean) / std for epoch in range(40): model.train() total_loss 0.0 for mix, mask_target in loader: # mix: [B, T, F] 线性幅度谱 mix normalize_spec(mix) opt.zero_grad() mask_pred model(mix) # 预测掩码 [B, T, F] loss F.l1_loss(mask_pred, mask_target) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) opt.step() total_loss loss.item() sched.step() print(fepoch {epoch:02d} | loss {total_loss / len(loader):.4f})损失用 L1 而不是 L2是因为 L1 对掩码中大量接近 0 的格子惩罚更温和不容易把网络逼向所有输出都偏小。验证时可以每 5 个 epoch 保存一次 checkpoint并在验证集上做一次主观试听。4.4 推理时从掩码还原单通道人声波形训练完成后推理流程和训练前置刚好相反# inference.py import numpy as np import librosa import torch def separate_vocals(model, wav_path, n_fft2048, hop512): y, sr librosa.load(wav_path, sr44100, monoTrue) stft librosa.stft(y, n_fftn_fft, hop_lengthhop) mix_mag np.abs(stft) mix_phase np.angle(stft) # 与训练时相同的标准化流程 feat torch.tensor(mix_mag.T, dtypetorch.float32).unsqueeze(0) mean feat.mean(dim2, keepdimTrue) std feat.std(dim2, keepdimTrue) 1e-5 feat (feat - mean) / std model.eval() with torch.no_grad(): mask model(feat) # [1, T, F] mask mask.squeeze(0).cpu().numpy().T # 恢复为 [F, T] # 乘回原始混合幅度谱用混合相位重建 vocal_mag mask * mix_mag vocal_stft vocal_mag * np.exp(1j * mix_phase) vocal_wav librosa.istft(vocal_stft, hop_lengthhop) # 伴奏谱同理accomp_mag (1 - mask) * mix_mag return vocal_wav, sr输出后建议再做一个 5Hz 到 18kHz 的带通滤波去掉极低频的直流噪声和超高频的伪影听感会干净很多。这个后处理不改变主体结构但能明显压住掩码噪声。5. 效果评估与三个能立竿见影的 DRNN 调参技巧5.1 先听混音再用 SDR 量化分离质量的主观标准是“人声完整、伴奏残留少、没有金属声”。客观标准常用 BSS Eval 工具包里的 SDR信号失真比、SIR声源干扰比、SAR伪影比。SDR 同时衡量泄漏、失真和伪影是最常被论文引用的数字SIR 专门看伴奏串扰SAR 看有没有“唧唧”的加工噪声。评估时一般取 1 秒窗口计算中位数比均值更稳定。我自己的门槛是 SDR 达到 8dB 以上才值得继续调参6dB 以下基本属于没有分离干净。5.2 三个最容易见效的调参位置第一个是 n_frames。128 帧适合大多数语句但如果歌手喜欢长拖音切到 192 帧往往能把句尾残留压掉反之若乐器碎碎密密帧数过长反而让伴奏也跟着掩码抖动。第二个是双向 LSTM 的层间 dropout。很多人训练到 10 个 epoch 发现验证 loss 上升就以为是数据不够其实是 LSTM 的记忆单元记住了训练集里特定几首歌的和声走向。把 dropout 从 0.2 提到 0.4再配合每次采样随机选歌通常能压住过拟合。第三个是学习率衰减节奏。ADAM 初始 lr1e-3 跑前 10 个 epoch 肉眼可见 loss 下降但 3 层 LSTM 的损失面很粗糙后期不改小学习率的话会陷入局部抖动。我用 StepLR 每 10 个 epoch 乘 0.6比每 15 个 epoch 减半更平滑。5.3 更进一步的思路二次回归和相位补偿当成型的 DRNN 已经能分离出不错的人声时常见做法是再做一次“残差分离”第一次分离出的伴奏残差里通常还留有人声尾巴把伴奏残差再当输入模型二次输出掩码与第一遍人声按能量比融合。这个两段式在结构上不增加模型参数却往往能在 SDR 上再提升 0.3 到 0.6dB。若想对相位敏感一些可以在逆变换后对输出的“电音感”做频谱插值或者引入短时相位估计网络不过那已经超出 DRNN 本身属于另一个话题了。先把掩码、帧长和训练节奏调对单通道人声分离的可听效果就足够让人满意。本文还有配套的精品资源点击获取