简介这是一套基于深度学习的单声道人声分离盲源分离项目面向音频处理、语音分离与深度学习应用开发者使用RNN与U-Net模型从混合音乐中提取纯净人声。压缩包内共7个Python文件整体仅16KB涵盖模型构建、数据预处理、配置管理、训练评估等完整流程其中循环神经网络模块用于捕捉音频时序依赖U-Net模块在频谱图上进行端到端分割配合预处理脚本可将WAV转换为频谱图并标准化评估脚本支持SISNR与SDR指标衡量分离质量。已有700人学习下载适合想入门声音分离任务、复现并扩展RNNU-Net基线方法的开发者。通过该项目可快速搭建实验环境理解模型在时间维度和频谱图上的协作机制并基于实际代码完成调参、训练和结果评估。1. 人声分离为什么从盲源分离转向RNN和U-Net拿到一首歌的混合音频想只留下人声这比想象中难。传统盲源分离BSS假设各源信号统计独立但音乐中人声与伴奏在频域上高度重叠独立成分分析ICA这类方法在单声道输入下基本失效。所以现在主流做法是把人声分离当成监督学习问题用混合音频的频谱图作为输入让人工神经网络去预测一个人声掩码再把这个掩码乘回原频谱最后通过逆短时傅里叶变换ISTFT还原出时域人声波形。这个思路下RNN和U-Net是两种很典型的骨架RNN擅长捕捉音频帧之间的时间依赖U-Net则把频谱图当图像做像素级分割。这份项目资源里刚好把两者都实现了还带预处理、训练、评估的完整脚本适合刚入手音频深度学习的人用来跑通流程也适合想对比不同网络结构效果的人做实验。2. 数据管线WAV到频谱图的预处理与数据加载人声分离模型并不直接吃原始波形至少在U-Net这类以频谱图为输入的方案里第一步都是把单声道音频变成二维的幅度频谱。preprocess.py做的就是这个事data.py负责把处理好的特征组织成batchconfig.py统一管理所有超参数。这三个脚本是后面所有模型训练和推理的基础如果预处理出的频谱图有截断或者掩码错位后面网络再强也白搭。2.1 预处理流程短时傅里叶变换和掩码生成我一般会把整个预处理拆成四步读WAV、分帧加窗、做短时傅里叶变换STFT、计算训练目标。代码上看起来大致是这样import numpy as np import librosa def wav_to_spectrogram(wav_path, n_fft1024, hop_length512): # 读取音频强制单声道 y, sr librosa.load(wav_path, sr22050, monoTrue) # 计算STFT返回复数频谱 spec librosa.stft(y, n_fftn_fft, hop_lengthhop_length) mag np.abs(spec) # 幅度谱 phase np.angle(spec) # 相位谱重建时要用 return mag, phase, sr这里n_fft1024对应频率分辨率约21.5Hzhop_length512是相邻帧之间的平移长度。对于人声分离这个组合能兼顾时间分辨率和频率分辨率帧太长会让辅音里的瞬态被抹掉帧太短则低频段频率分辨率不足人声基频和伴奏低音容易混在一起。monoTrue强制转成单声道因为本项目场景就是单声道音乐分离。接着要生成训练标签。监督学习需要混合谱和对应的人声谱所以预处理时得同时读入混合音频和纯净人声音频让模型学习从混合谱到人声掩码的映射。常见的训练目标有两种理想二值掩码IBM和理想比例掩码IRM。IRM的公式是def compute_irm(mix_mag, vocal_mag, beta0.5): # 防止分母为0加一个极小值 epsilon 1e-8 irm (vocal_mag ** beta) / (mix_mag ** beta epsilon) return np.clip(irm, 0.0, 1.0)beta通常取0.5对应维纳滤波的幅度比形式。IRM的好处是保留了连续的软掩码而不是非0即1网络在训练时更容易收敛重建出的音频也不会出现明显的“音乐噪声”感。preprocess.py里应该就是把混合谱和人声谱都存在内存或磁盘上供后续data.py读取。2.2 数据加载与数据增强data.py的核心是提供一个PyTorch的Dataset类它要做的事情不只是把预处理好的频谱图返回给模型还要处理长度对齐和增强。由于一段音频的频谱图帧数是随时长变化的而U-Net在下采样后要求尺寸能被2的幂次整除所以常见做法是随机裁剪固定帧数比如256帧。import torch from torch.utils.data import Dataset class VocalSeparationDataset(Dataset): def __init__(self, mix_list, vocal_list, num_frames256, augmentTrue): self.mix_list mix_list self.vocal_list vocal_list self.num_frames num_frames self.augment augment def __len__(self): return len(self.mix_list) def __getitem__(self, idx): mix_mag np.load(self.mix_list[idx]) # shape: [freq_bins, time_frames] vocal_mag np.load(self.vocal_list[idx]) # 随机裁剪时间维 if self.augment and mix_mag.shape[1] self.num_frames: start np.random.randint(0, mix_mag.shape[1] - self.num_frames) mix_mag mix_mag[:, start:start self.num_frames] vocal_mag vocal_mag[:, start:start self.num_frames] # 转成float tensor并增加通道维 mix_tensor torch.from_numpy(mix_mag).unsqueeze(0).float() vocal_tensor torch.from_numpy(vocal_mag).unsqueeze(0).float() return mix_tensor, vocal_tensor这里随机裁剪就是一种时间维的增强相当于让模型见过各种片段位置的人声变化。如果你想让模型对音高更鲁棒可以在取频谱后对频率轴做随机平移几个bin或者对幅度做随机的全局缩放模拟不同响度。我个人不太建议在幅度谱上做翻转增强因为人声频谱的时间结构不是对称的翻转时间轴会让模型学到错误的因果依赖。2.3 配置文件中的关键参数config.py通常是个简单的集中定义参数的文件但它的作用往往被低估。我在复现类似项目时会把下面这些参数单独抽出来方便做网格搜索。参数名建议取值范围含义与影响n_fft512 ~ 2048控制频率分辨率越大低频越细但时间分辨率下降hop_length128 ~ 512控制帧重叠度越小频谱时间分辨率越高计算量也越大batch_size8 ~ 32显存允许范围内尽量大太小时BatchNorm不稳定lr1e-4 ~ 3e-4训练初期用大一点后期配合调度器降低num_frames128 ~ 512每个样本的频谱时间帧数影响模型感受野rnn_hidden128 ~ 256RNN隐层维度决定了时序建模容量unet_base_channels16 ~ 64U-Net第一层卷积通道数后续层按倍数递增实际跑的时候我会先固定n_fft1024和hop_length512把时间帧设成256这样单样本shape是[1, 513, 256]显存占用大约几十MB很好迭代。等模型结构改得差不多了再回去调n_fft看低频人声和乐器的分离边界。3. 模型实现RNN时序建模与U-Net频谱图分割的互补设计模型部分是这份代码的重头戏。unet.py和newunet.py都实现了U-NetRNN.py则单独给了循环神经网络的结构。在单声道人声分离里U-Net负责从频谱图局部纹理里提取特征但它对长距离时间依赖的表征偏弱RNN恰好能通过隐藏状态跨帧传播信息。两者互补是合理的实际项目中也常看到把RNN模块插入U-Net瓶颈层或者解码器路径的做法。3.1 U-Net的编码-解码与跳跃连接U-Net最初是为医学图像分割设计的但用在频谱图上也很自然。频谱图的横轴是时间帧纵轴是频率bin人声的基频和泛音在图上呈现为横向的纹路伴奏的节奏鼓点则表现为竖直条纹。U-Net的下采样过程逐步增大感受野上采样过程把语义特征和低层细节融合。一个紧凑的实现如下import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base16): super().__init__() self.enc1 ConvBlock(in_ch, base) self.enc2 ConvBlock(base, base * 2) self.enc3 ConvBlock(base * 2, base * 4) self.pool nn.MaxPool2d(2) self.up1 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.up2 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.dec1 ConvBlock(base * 4, base * 2) self.dec2 ConvBlock(base * 2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.enc1(x) # [B, base, H, W] e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) d1 self.up1(e3) d1 torch.cat([d1, e2], dim1) # 跳跃连接 d1 self.dec1(d1) d2 self.up2(d1) d2 torch.cat([d2, e1], dim1) d2 self.dec2(d2) return self.out(d2)注意中间的torch.cat跳跃连接把编码器同尺度的特征拼到解码器上。这样做能保留频谱中高频的瞬态细节否则上采样会把吉他扫弦、齿音这些边缘信息直接抹掉。base一般取32或64这个项目里config.py如果没特殊说明我觉得保持32比较稳。3.2 RNN在频域或时序上的建模方式RNN处理音频通常有两种方式一种是把频谱的每一帧当作时间步输入特征是当前帧的完整频域向量RNN按时间顺序逐帧处理另一种是先把频谱经过卷积网络得到特征图再把特征图在时间维度展开送入RNN。前者简单直接后者更常见于混合架构。RNN.py里大概率是标准LSTM或GRU封装。以单层双向LSTM为例import torch.nn as nn class BDLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, ) self.linear nn.Linear(hidden_size * 2, input_size) def forward(self, x): # x: [B, T, F] T是时间帧数F是频率bin数 out, _ self.lstm(x) return self.linear(out)双向LSTM可以同时利用当前帧前后的上下文。人声在时间上是有长期连续性的双向建模能利用后面的元音来修正前面的辅音掩码。hidden_size不宜设得过大因为频谱频率维度通常有513个bin双向LSTM的隐层如果也取256参数规模会很大训练时容易过拟合。3.3 newunet.py中的融合设计newunet.py从命名上看像是U-Net的改进版本。常见的一种融合方式是编码器输出特征后先经过一个RNN层然后再进入解码器。这样U-Net提取的局部特征先被RNN沿时间轴整合再上采样恢复细节。给出一个可运行的融合片段class NewUNet(nn.Module): def __init__(self, base32, rnn_hidden128): super().__init__() self.encoder ... # 复用上面UNet的编码器 self.rnn nn.LSTM( input_sizebase * 4, # 瓶颈层通道数 hidden_sizernn_hidden, num_layers1, batch_firstTrue, bidirectionalFalse, ) self.hidden_proj nn.Linear(rnn_hidden, base * 4) self.decoder ... # 复用解码器部分 def forward(self, x): B, C, F, T x.shape enc self.encoder(x) # [B, base*4, F/8, T/8] # 把频率维和通道维合并当作LSTM输入特征 rnn_in enc.permute(0, 3, 1, 2).reshape(B, T // 8, -1) rnn_out, _ self.rnn(rnn_in) rnn_out self.hidden_proj(rnn_out) # 还原形状后进解码器 rnn_out rnn_out.reshape(B, T // 8, base * 4, F // 8).permute(0, 2, 3, 1) return self.decoder(rnn_out)这里的关键是把[B, C, F, T]转成[B, T, C*F]送入LSTM意味着特征图在时间维上被压缩了8倍RNN建模的是高层语义帧之间的变化而不是逐帧原始频谱。这样计算量小很多也能让RNN看到更全局的上下文。如果你想把RNN放在解码器内部逐级融合通常不是靠这种permute方式而是在每次上采样后把特征沿时间维做一次轻量GRU但那样代码复杂度会高不少。4. 训练与评估损失函数、SISNR/SDR指标以及eval.py的验证流程模型结构确定后训练环节决定了最终分离质量。eval.py在整个项目里承担了验证和测试的角色它输出的指标不仅是学术上的数字也能直接反映人声是否被掏空、伴奏是否有残留。这一章把训练循环和评估代码的关键点拆开讲顺带说几个我在实践里踩过的坑。4.1 训练循环与损失选择很多初做音频分离的人会直接拿L1损失训练但L1作用于幅度谱时像素间差不被惩罚得很狠导致分离出的频谱会出现许多小洞听感上像“沙沙”的噪声。用L2MSE会更平滑但对峰值响应惩罚过重容易把人声的瞬态给压平。我在这个项目的场景下一般用L1损失并在后半段训练切换到感知相关的损失不过config.py里如果已经定义了损失函数最好先跑通再改。训练循环的核心部分如下def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for mix, vocal in dataloader: mix mix.to(device) vocal vocal.to(device) pred_mask model(mix) # 用预测掩码乘混合谱得到估计的人声谱 est_vocal pred_mask * mix loss criterion(est_vocal, vocal) # 直接优化幅度谱 optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里pred_mask * mix是逐元素相乘预测出的掩码会直接作用在混合谱上。之所以不直接让模型输出人声谱是因为掩码的数值范围在0到1之间输出层加Sigmoid后更容易训练。损失函数计算的是估计人声谱与真实人声谱的差距而不是掩码与IRM标签的差距这样能避免掩码误差在频谱上被放大。训练时建议用Adam学习率从1e-3开始每10个epoch乘以0.5衰减。batch size根据显存调整如果模型层数深一个样本的显存占用可能到几百MB那就把batch size降到4或者8。4.2 eval.py中的指标计算评估人声分离最常用的客观指标是SDRSignal-to-Distortion Ratio和SISNRScale-Invariant Signal-to-Noise Ratio。eval.py里应该实现了其中至少一种。SISNR是SDR的改进版它对预测信号的幅度缩放不敏感因此更符合人耳感知。计算方法如下import numpy as np def si_snr(reference, estimation): # reference和estimation都是shape [T]的一维数组 ref reference - np.mean(reference) est estimation - np.mean(estimation) # 把估计信号投影到参照信号方向上 ref_norm np.dot(ref, ref) 1e-8 proj np.dot(est, ref) / ref_norm * ref noise est - proj snr 10 * np.log10(np.dot(proj, proj) / (np.dot(noise, noise) 1e-8)) return snr这个指标的核心是先去除直流分量然后做正交分解。投影部分代表与参照信号相关的信号能量残差部分代表干扰和失真。SISNR越高说明分离出的人声越接近目标。通常SISNR在10dB以上能听到比较清晰的人声15dB以上基本没有明显伴奏残留。eval.py的执行流程一般是先读入测试WAV经过与训练相同的STFT得到频谱模型预测掩码然后通过ISTFT重建时域信号最后和纯净人声做SISNR计算。所以我需要强调preprocess.py里的STFT参数必须与eval.py里的完全一致否则重建出的声音会有明显的“梳状滤波”失真。4.3 过拟合和训练不稳定排查训练人声分离模型最大的坑是验证集SISNR高但听感差。这是因为模型可能只在频谱图上做对了“整体形状”人声的齿音和气息被当作噪声丢弃了。我通常会在训练过程中同时保存预测的音频样例每个epoch末尾放一两个片段到tensorboard或直接写成wav耳朵听的判断比指标更直接。另一个常见问题是训练loss下降但SISNR不涨。这可能是因为损失函数优化的是幅度谱而ISTFT重建时用了混合音频的相位人声与伴奏的相位关系在混合过程中发生了变化导致重建出的时域信号与目标人声有相位偏差。解决的办法是训练时用一个可微的ISTFT层或者像某些项目那样把相位也作为预测目标的一部分但那样难度会大不少。如果你只是想获得可听的人声幅度掩码加原始混合相位是常规操作别太纠结。5. 参数调优和分离效果验证的实用技巧模型和代码跑通后大多数人会想知道怎么让分离效果更好。这里说两个我从实际对比中得到的经验一个是关于频谱参数对结果的直接影响另一个是用eval.py做快速A/B验证的小方法。5.1 频率分辨率和掩码类型的选择先看频率分辨率。n_fft1024时频率bin宽度约21.5Hz人声基频男声约100-150Hz女声约200-300Hz能被分到几个bin里但低音bass与男声的基频重叠还是分不开。如果把n_fft提高到2048低频分辨率提升到约10.8Hz低频段人声和低音之间的掩码会更干净但是时间分辨率会下降鼓点、拍手这类瞬态会糊。所以不要盲目加大n_fft在GPU显存允许下可以用n_fft2048, hop_length1024来保持时间帧数不变。掩码类型也很关键。如果项目里默认用IRM但你的数据里伴奏是强节奏型IRM往往会给人声较高的比例值导致鼓点残留明显。这时可以试试二值掩码IBM把小于阈值的掩码直接置0能在一定数据分布下提升SISNR但也会带来“音乐噪声”听感。我给个简单策略先用IRM训练训练完毕后把掩码做一个软阈值处理让小于0.2的值变成原来的0.8次方降低低置信度区域的干扰。5.2 用eval.py做快速验证和试听我的做法是在eval.py基础上加一个批量测试目录的循环让它逐一输出每个文件的SISNR和预测wav。这样改几行代码就能知道参数修改是正向还是负向。参考片段如下python eval.py --test_dir ./data/test/mix --out_dir ./output/pred --ref_dir ./data/test/vocaleval.py内部会读取config.py的模型结构参数加载权重然后对每个wav做STFT、推理、ISTFT和指标计算。当你修改了n_fft或rnn_hidden后重新训练完模型直接跑这个命令对比不同参数下的SISNR均值。注意每次只改一个变量同时跑三组以上因为单条音频的SISNR波动很大。5.3 从单模型到集成的小技巧如果你想把效果再推高一点可以尝试训练两个模型一个用unet.py一个用newunet.py然后在预测阶段对两个模型的掩码做平均。掩码平均比波形平均更安全因为两个模型可能在不同频率区域各有所长平均掩码能减少单一模型的系统性偏差。集成后SISNR通常能提升0.5dB左右听感上人声更稳定。最后一个建议人声分离模型的训练数据很重要如果直接用网上随便下的歌曲和人声分轨去训练人声和伴奏的偏移不同步会导致模型学废。训练前检查一遍数据对齐用支持度高的歌曲人声乐句的起止时间误差控制在10ms内否则eval.py出来的指标再高也没有实际意义。本文还有配套的精品资源点击获取