简介:项目集合了 Bi-LSTM 与 CNN-GAN 两种生成式模型,面向希望系统性复现深度学习作曲流程的 Python 开发者与 AI 音乐研究者。基于 GiantMIDI-Piano 数据,将巴洛克、古典、浪漫、现代主义四个时期作为训练与评测对象,并以音高直方图、FID、最近邻和用户调查等指标横向对比模型表现。整个资源共1516个文件,压缩包约62.49MB,核心包括1494个MIDI乐曲文件、4个Python脚本、3个Jupyter Notebook、4个Markdown说明和2个PDF文档,另有各时期笔记文件辅助梳理实验设计。当前已有480人学习或下载。下载后通过 README 即可快速了解数据预处理、模型训练与评估流程;MIDI 音乐可用 MuseScore 打开观看乐谱或导出 MP3,便于直接听取不同时代风格的生成效果。对希望扩展 AI 作曲、生成音乐评估方法的人来说,是兼具复现价值与参考意义的完整实验包。
1. 为什么要用 Bi-LSTM 和 CNN-GAN 创作古典音乐
早期 LSTM 生成古典音乐,最大的问题不是音符错,而是乐句太长之后失去和声意图。Bi-LSTM 能同时读前后音,CNN-GAN 擅长在钢琴卷帘矩阵上稳定生成。把两者串起来,就能用标签控制巴洛克、古典、浪漫三个时代。
整条链路:MIDI 转钢琴卷帘,Bi-LSTM 编码时代与序列,CNN-GAN 条件生成,最后在 Jupyter 里导出 MIDI。适合会跑 Python、想在 notebook 中逐步看矩阵的算法工程师和音乐创作者。
下面按数据、结构、训练、调优四个环节展开;每个代码块都能改参后直接执行,不是只能看概念。
2. 准备数据:把 MIDI 转成 Bi-LSTM 和 CNN-GAN 能学的矩阵
2.1 为什么先转成钢琴卷帘,而不是直接训练音频
作曲家留下的记录是乐谱,数字化之后以 MIDI 事件的形式存在。音频直接做生成,需要同时处理音色、混响和录音环境,模型难以把精力集中在“和声进行”上。把 MIDI 转到钢琴卷帘(piano roll)之后,每个时间步是一根 128 维的向量,对应 MIDI 音高编号,输入张量变成(batch, time_steps, 128)。
这种表示有一个隐藏优点:不需要关心演奏速度变化。节奏被量化到固定步长后,模型能平等地比较不同作曲家的动机长度。缺点也很明显,量化会让装饰音、切分音丢失一部分信息,所以ticks_per_step不能设成 1,否则一个四分音符会被切成几百行,训练收敛非常慢。
2.2 给每首曲子加上“时代标签”是条件生成的关键
风格并不是一个能靠回归预测的连续数:巴赫的平均律长句极少跨小节切分,比肖邦的华彩段落稳定得多。把代号 0、1、2、3 直接塞进网络,会引入错误的排序关系,比如模型以为浪漫主义比古典主义“更大”。正确做法是先查表得到 one-hot 向量,再通过一个小型 embedding 层转成稠密向量。
预处理阶段要做四件事:读入 MIDI,过滤掉鼓与不常用的乐器轨道,按拍点切片,保存为.npy的同时把时代标签存进np.array。时代不能漏标,训练后期如果发现两次运行结果风格互换,十有八九是标签顺序错位。
2.3 可直接运行的 MIDI 预处理代码
import mido import numpy as np def midi_to_piano_roll(path, ticks_per_step=8, max_len=512): mid = mido.MidiFile(path) notes = {} current = 0 for msg in mid: if not hasattr(msg, 'type'): continue if msg.type == 'note_on' and msg.velocity > 0: notes.setdefault(msg.note, []).append([current, None, msg.velocity]) elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): for slots in notes.get(msg.note, []): if slots[1] is None: slots[1] = current break current += msg.time if msg.time else 0 total = min(int(current / ticks_per_step), max_len) roll = np.zeros((total, 128), dtype=np.float32) for note, slots in notes.items(): for s, e, vel in slots: if s is None or e is None: continue roll[int(s / ticks_per_step):int(e / ticks_per_step), note] = vel / 128.0 return roll这里current累积的是解析器内部的 tick 时间,ticks_per_step=8意味着每 8 个 tick 合并成一行。运行前用ticks_per_step=4和16各试一次,在 Jupyter 里用plt.imshow(roll[:128].T)检查音符是否明显断裂。如果没有断裂,再用它生成训练数据。
2.4 数据增强与切片策略
古典乐的乐句往往重复两遍,直接随机切会得到大量类似片段,导致判别器过拟合。常用做法是按小节边界切,再随机把相邻两小节做力度增强,或者拆掉低音声部。因为钢琴音乐的高声部旋律比低声部重要,训练时可选地丢弃低一个八度的部分,这让模型学会从高音声部反推和声。
我一般把每个切片保存为单独数组,用全局索引记录它属于哪首原曲,这样之后做验证集不会把同一首曲子的相邻片段同时放进训练和验证。没有这一步,验证 loss 会虚低,风格识别准确率看起来高,实际换一首新曲子就崩。
3. 构建 Bi-LSTM 与 CNN-GAN:结构选择与 Jupyter 可执行代码骨架
3.1 用条件向量把“时代”织入两个模型
生成模型里最常出现的问题是:训练到一半,所有时代都生成同一类织体。这就是条件变量没有真正作用到特征图。常见的解决方式是在生成器入口做一次拼接,再在中间层做一次调制。拼接是把条件向量和隐编码按通道合并,而调制更接近 Adaptive Instance Normalization,把风格向量的分布重新作用到特征图上。
def condition_modulate(features, style_emb, gamma_conv, beta_conv): style = style_emb.unsqueeze(2).unsqueeze(3) gamma = gamma_conv(style) beta = beta_conv(style) return gamma * features + beta上面的gamma_conv、beta_conv是普通卷积层,用来把(batch, style_dim)放大到(batch, C, 1, 1)。如果想省显存,只在第一个卷积块和最后一个卷积块用调制就够了;每个块都接会大幅增加参数量,在数据量小的古典音乐集上容易过拟合。
3.2 Bi-LSTM 在生成器里的具体位置
生成器的目标是从噪声 z 得到一个(T, 128)的卷帘图。如果直接z -> ConvTranspose2d,卷积核的感受野不足以覆盖六小节以上的和声连接。我在 CNN 之前加入 Bi-LSTM,让它先产生一段“粗草稿”序列。
import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, z_dim=128, style_dim=4, hidden=128): super().__init__() self.style_emb = nn.Embedding(style_dim, 16) self.fc_in = nn.Linear(z_dim + 16, hidden) self.lstm = nn.LSTM(hidden, hidden // 2, batch_first=True, bidirectional=True) def forward(self, z, style_label): style = self.style_emb(style_label) # (B, 16) h = self.fc_in(torch.cat([z, style], dim=1)) # (B, hidden) h = h.unsqueeze(1).expand(-1, 8, -1) # 制造 8 步序列 out, _ = self.lstm(h) # (B, 8, hidden) return out.reshape(-1, 8 * hidden)expand(-1, 8, -1)之后每个变量仍然是同一个张量,不占用新内存。取出后先接第一层转置卷积,把 8 步隐状态当成 8 个时间 token 拼接为 8×hidden 的特征矩阵。注意hidden要能被 2 整除,否则双向拼接后维度不对称,容易在后续卷积层报尺寸错误。
3.3 CNN 生成器和判别器怎么分配通道
生成端一般用转置卷积,但不要在音高维做下采样。一个常见的错误是把 128 音高先压缩到 32,生成后再升回 128,这样卷积核沿音高移动时会把相邻音高混叠,造成和弦的“金属声”。正确做法是保持音高维不变,只在时间维做stride=2的上采样。
| 模块 | 输入形状 | 卷积参数 | 输出形状 |
|---|---|---|---|
| Deconv_1 | B×8×1×1 | ConvT2d(128, 64, 3, 2, 1) | B×64×16×1 |
| Deconv_2 | B×64×16×1 | ConvT2d(64, 32, 3, 2, 1) | B×32×32×1 |
| Deconv_3 | B×32×32×1 | ConvT2d(32, 16, 3, 1, 1) | B×16×32×1 |
| Deconv_4 | B×16×32×1 | ConvT2d(16, 1, 1, 1, 0) | B×1×32×1 |
这张表只作步长示意。实际训练时建议把最后一层的输出宽度补成 T=128。判别器用 5×5 卷积核对(1, T, 128)输入,前两层步长取 2,最后一层做 Global Average Pooling 后再接二分类和风格分类。
3.4 判别器用 LSGAN 还是 BCE
| 目标函数 | 输出层 | 典型表现 |
|---|---|---|
| BCE | Sigmoid | 早期快,后期容易出现判别器“麻木” |
| LSGAN | 不加 Sigmoid | 梯度不会过早消失,对 batch size 敏感 |
| WGAN-GP | 线性输出加梯度惩罚 | 最稳定,但计算量多约 30% |
在十万步以内的古典音乐任务里,通常直接用 LSGAN。它的梯度来源始终是“距离”,越训练越平缓,不会像 BCE 那样在判别器接近 0 时直接消失。判别器的最后一层不要再接 Sigmoid,否则 LSGAN 的数学假设失效,你会看到 loss 不断降低但生成内容只有随机噪声。
3.5 联合训练的最小循环
opt_G.zero_grad() z = torch.randn(B, z_dim).to(device) label = torch.randint(0, style_dim, (B,)).to(device) fake = generator(z, label) d_fake = discriminator(fake, label) loss_g = ((d_fake - 1) ** 2).mean() loss_g.backward() opt_G.step()每次迭代里,生成器先拿随机标签训练,再由判别器分别处理真数据和生成数据。把fake传给判别器计算loss_d时要用.detach(),否则判别器 backward 时梯度会流进生成器,导致两个优化器同时更新同一个参数字段,损失曲线会抖动得很厉害。
4. 在 Jupyter 里搭好环境并跑通训练循环
4.1 从 Python 安装到 Jupyter 依赖清单
整套工程不依赖云平台,本地装了 Python 3.9+ 就行。如果还没装好环境,先看 python 安装教程把解释器装好,再打开 Jupyter notebook。依赖方面,在 notebook 第一个单元格执行:
!pip install mido pretty_midi torch tqdm matplotlib如果还需要处理表格型标签,可以单独执行!pip install pandas。这类!指令会直接创建 shell 子进程,装完不需要重启内核。如果 jupyter notebook 无法运行,先执行jupyter kernelspec list查看当前内核指向的 python 路径。另一个常见问题是 jupyter notebook 单元格执行代码没有任何反应,多半是因为内核和当前 python 环境不一致,在终端执行:
python -m ipykernel install --user --name torchWindows 下还会遇到编码问题,!pip如果报 gbk 错误,先执行import sys; sys.executable看真实路径,不要在!里直接写python -m pip。
4.2 训练循环的最小实现
下面这段代码是上一章模型的训练主干,适合放在独立单元格里,用%time测每次迭代耗时:
from torch.utils.data import DataLoader, TensorDataset def train_single_epoch(generator, discriminator, loader, opt_G, opt_D, style_dim, z_dim, device): for piano_roll_batch, style_batch in loader: piano_roll_batch = piano_roll_batch.to(device).unsqueeze(1) # (B,1,T,128) style_batch = style_batch.to(device) B = piano_roll_batch.size(0) opt_G.zero_grad() fake = generator(torch.randn(B, z_dim, device=device), style_batch) loss_g = ((discriminator(fake, style_batch) - 1) ** 2).mean() loss_g.backward() opt_G.step() opt_D.zero_grad() loss_real = ((discriminator(piano_roll_batch, style_batch) - 1) ** 2).mean() loss_fake = ((discriminator(fake.detach(), style_batch)) ** 2).mean() loss_d = 0.5 * (loss_real + loss_fake) loss_d.backward() opt_D.step() return loss_g.item(), loss_d.item()为了让 Jupyter 单元格执行代码时不卡死,数据加载器里要设置num_workers=2,并在训练循环前加上torch.backends.cudnn.benchmark = True。如果卡住,八成是线程阻塞在 DataLoader 里,Linux 下把 workers 减到 0,Windows 下固定为 1,就能把问题隔离开。
4.3 关键训练参数参考表
下表是一组适合单卡显存 8GB 的初始值,可以直接抄第一次实验:
| 参数名 | 初始值 | 调参窗口 | 说明 |
|---|---|---|---|
| batch_size | 16 | 8~64 | 超过 32 后 LSGAN 训练速度反而变慢 |
| seq_len | 128 | 64~512 | 太长会让时代标签失去局部约束 |
| z_dim | 64 | 32~256 | 太低容易模式崩溃,太高导致音程不稳定 |
| lr_G | 0.0002 | 5e-5~2e-4 | 生成器学习率通常高于判别器 |
| lr_D | 0.0002 | 1e-5~2e-4 | 判别器太强时优先降低这里 |
| style_dim | 3 | 3~8 | 三个时代时不宜超过 8 |
在第 1000 步左右把seq_len调成 64 重跑,你会发现生成器 loss 变低。这是正常现象,短序列更容易被卷积核覆盖,长距离呼应暂时学不到,后续由 Bi-LSTM 的时间特征再补偿回来。
4.4 用损失曲线判断哪一侧过强
losses_g.append(loss_g) losses_d.append(loss_d) if (epoch + 1) % 5 == 0: ax.clear() ax.plot(losses_g, label='G') ax.plot(losses_d, label='D') ax.set_yscale('symlog') display(fig)如果 D 的 loss 一直在 0.1 以下而 G 在 1 以上,说明判别器太强,先把lr_D降到1e-5再继续。反过来 D 在 1.5 附近大幅度震荡,则要检查生成器里 padding 模式是否统一,卷积输出尺寸不匹配会在反向传播时累积错误。每 100 次迭代把假样本保存成fake_N.npy,方便回放。
5. 调参、排错并用不同时代条件生成完整乐谱
5.1 三个最能拉开风格差异的旋钮
第一个旋钮是条件向量注入位置。只把 style embedding 和 z 拼接,训练后半程模型会渐渐忽略它;改成 AdaIN 后,在生成器中间层同时调整均值和方差,巴洛克的音符密度与浪漫主义长线条会产生明显差异。第二个旋钮是时间分辨率。相同曲子用ticks_per_step=4时装饰音更接近原谱,但判别器关注短时噪声;用 16 时模型更愿意学习大尺度旋律走向。发现作品“好记但不清澈”,可以把步长从 8 改到 16。第三个旋钮是 Bi-LSTM 的序列步长,一般取 8、16、32,步长越大生成时间越长,但不代表结构信息更多。
5.2 用固定噪声反向对比时代
训练结束后,用同一段 z 依次换成不同style_label生成样本,再对比音高分布。巴洛克片段如果几乎不在低音区出现,说明训练数据里混入了太多只有右手声部的 midi 文件,需要回预处理阶段补充左手声部。用下面的代码把生成矩阵写回 MIDI:
import pretty_midi pm = pretty_midi.PrettyMIDI() inst = pretty_midi.Instrument(program=0) for t, row in enumerate(note_grid[:128]): onset = t * 0.125 for pitch, active in enumerate(row): if active > 0.5: n = pretty_midi.Note(velocity=80, pitch=pitch, start=onset, end=onset + 0.2) inst.notes.append(n) pm.instruments.append(inst) pm.write('romantic_sample.mid')其中note_grid是生成器输出经过阈值后的二值矩阵。end=onset + 0.2会给每个音符一个固定短时值,听起来像机械断奏;更自然的做法是在时间方向判断连续发声区域,把同一音高跨多个时间步的片段合并成一个音符。
5.3 代码下载后怎么二次修改
把 notebook 转成普通脚本时,用jupyter nbconvert --to script generate.ipynb会保留所有代码并去掉说明文字。下载下来的代码通常有三处需要改:数据读取的root路径、style_map的作曲家编号,以及ckpt.pt保存路径。建议先跑一次 5 个 epoch 的冒烟测试,看到图形输出后再覆盖成正式训练用的batch_size,这样能少浪费几轮 kernel 重启时间。
本文还有配套的精品资源,点击获取