简介:基于深度循环神经网络(DRNN)实现的单通道音乐人声分离Python源码,可运用于计算机、人工智能、通信工程、自动化、电子信息等专业的毕业设计、课程设计或期末大作业,也适合作为深度学习初学者的进阶练习。压缩包内一共包含5个文件,其中4个Python脚本负责网络模型构建、训练推理、SDR评估以及音频与TXT格式数据处理,1个Markdown说明文档对项目结构、运行方式与参数设置做了简要说明。整个资源包仅8KB,体量轻巧、结构清晰,便于快速部署和二次修改。截至目前已有379人学习浏览。代码经实际测试可正常运行,除基础的人声分离流程外,还附带Conv-TasNet相关实现与评估脚本,方便使用者对比不同网络在分离效果上的差异;既可直接用于课设、毕设或项目初期演示,也为进一步扩展功能提供了可借鉴的代码框架。
1. 单通道音乐人声分离:DRNN 这条路到底为什么有人选
一个很常见的需求是:手里只有一条音轨,比如一段录制好的翻唱、一部老电影的混音声道,或者只是手机录的“清唱+背景音乐”视频,想把主唱的声音单独提出来。这时候没有立体声声像可参考,没有多轨工程可用,能利用的只有信号本身的时间和频率结构。DRNN(Deep 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 数据集目录怎么组织更方便做训练对
音乐人声分离常用的公开评测集是 MUSDB18,18 首完整歌曲的混音,每首歌自带 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_fft=2048、hop_length=512,窗函数用 Hann 窗。对 44.1kHz 的音乐信号,n_fft=2048 对应约 46ms 的分析窗,频点数为 1025,每个频点间隔约 21.5Hz。这个频率分辨率对男声基频和大多数乐器谐波都够用。
各参数的影响整理成一张表:
| 参数 | 常见取值 | 对分离结果的影响 | 备注 |
|---|---|---|---|
| n_fft | 2048 | 频率分辨率约 21.5Hz,谐波更清晰 | 对低频鼓点不够细可提到 4096 |
| hop_length | 512 | 帧移约 11.6ms,时间平滑 | 与 n_fft=2048 匹配,掩码抖动作小 |
| window | hann | 旁瓣泄漏较小 | 不要随意换成矩形窗 |
| center | True | 补零使逆变换更稳定 | 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, sr=44100, n_fft=2048, hop=512): # 统一采样率并转单声道 mix, _ = librosa.load(mix_path, sr=sr, mono=True) vocal, _ = librosa.load(vocal_path, sr=sr, mono=True) # 对齐长度后取幅值谱 length = min(len(mix), len(vocal)) mix, vocal = mix[:length], vocal[:length] mix_spec = np.abs(librosa.stft(mix, n_fft=n_fft, hop_length=hop)) vocal_spec = np.abs(librosa.stft(vocal, n_fft=n_fft, hop_length=hop)) return mix_spec, vocal_spec def sample_window(mix_spec, vocal_spec, n_frames=128): 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_frames=128): 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, dtype=torch.float32, requires_grad=False) mask_t = torch.tensor(mask_target.T, dtype=torch.float32, requires_grad=False) 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_bins=1025, hidden=512, layers=3, dropout=0.3): super().__init__() self.pre = nn.Sequential( nn.Linear(n_bins, hidden * 2), nn.ReLU(), nn.LayerNorm(hidden * 2), # 对频点维度做归一化 ) self.lstm = nn.LSTM( input_size=hidden * 2, hidden_size=hidden, num_layers=layers, batch_first=True, bidirectional=True, dropout=dropout 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_bins | 1025 | n_fft=2048 对应的频点数 |
| hidden | 512 | 双向后实际隐层宽度为 1024 |
| layers | 3 | 再加深到 5 层收益变小,训练变慢 |
| n_frames | 128 | 约 1.49 秒上下文,兼顾显存 |
| batch_size | 8 | 依赖 GPU 显存,加大可提升稳定性 |
| 初始学习率 | 1e-3 | Adam 默认,15 个 epoch 后减半 |
| 梯度裁剪阈值 | 5.0 | LSTM 沿时间展开时防止梯度爆炸 |
4.3 训练循环:归一化、损失与梯度裁剪
训练循环里我会对每个 batch 做输入标准化:对混合谱在频点维度上减均值、除标准差。注意 mask 目标不要做同样的标准化,因为它是比例值,标准化后就不再是“人声占比”了。
# train.py import torch import torch.nn.functional as F from model import DRNNSeparator model = DRNNSeparator(n_bins=1025, hidden=512, layers=3, dropout=0.3) opt = torch.optim.Adam(model.parameters(), lr=1e-3) sched = torch.optim.lr_scheduler.StepLR(opt, step_size=15, gamma=0.5) def normalize_spec(x): mean = x.mean(dim=2, keepdim=True) std = x.std(dim=2, keepdim=True) + 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(f"epoch {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_fft=2048, hop=512): y, sr = librosa.load(wav_path, sr=44100, mono=True) stft = librosa.stft(y, n_fft=n_fft, hop_length=hop) mix_mag = np.abs(stft) mix_phase = np.angle(stft) # 与训练时相同的标准化流程 feat = torch.tensor(mix_mag.T, dtype=torch.float32).unsqueeze(0) mean = feat.mean(dim=2, keepdim=True) std = feat.std(dim=2, keepdim=True) + 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_length=hop) # 伴奏谱同理: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 初始 lr=1e-3 跑前 10 个 epoch 肉眼可见 loss 下降,但 3 层 LSTM 的损失面很粗糙,后期不改小学习率的话会陷入局部抖动。我用 StepLR 每 10 个 epoch 乘 0.6,比每 15 个 epoch 减半更平滑。
5.3 更进一步的思路:二次回归和相位补偿
当成型的 DRNN 已经能分离出不错的人声时,常见做法是再做一次“残差分离”:第一次分离出的伴奏残差里通常还留有人声尾巴,把伴奏残差再当输入,模型二次输出掩码,与第一遍人声按能量比融合。这个两段式在结构上不增加模型参数,却往往能在 SDR 上再提升 0.3 到 0.6dB。若想对相位敏感一些,可以在逆变换后对输出的“电音感”做频谱插值,或者引入短时相位估计网络,不过那已经超出 DRNN 本身,属于另一个话题了。先把掩码、帧长和训练节奏调对,单通道人声分离的可听效果就足够让人满意。
本文还有配套的精品资源,点击获取