ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Bi-LSTM和CNN-GAN的古典音乐生成:MIDI转钢琴卷帘实践

基于Bi-LSTM和CNN-GAN的古典音乐生成:MIDI转钢琴卷帘实践 简介项目集合了 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_step8, max_len512): 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), dtypenp.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_step8意味着每 8 个 tick 合并成一行。运行前用ticks_per_step4和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_dim128, style_dim4, hidden128): 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_firstTrue, bidirectionalTrue) def forward(self, z, style_label): style self.style_emb(style_label) # (B, 16) h self.fc_in(torch.cat([z, style], dim1)) # (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这样卷积核沿音高移动时会把相邻音高混叠造成和弦的“金属声”。正确做法是保持音高维不变只在时间维做stride2的上采样。模块输入形状卷积参数输出形状Deconv_1B×8×1×1ConvT2d(128, 64, 3, 2, 1)B×64×16×1Deconv_2B×64×16×1ConvT2d(64, 32, 3, 2, 1)B×32×32×1Deconv_3B×32×32×1ConvT2d(32, 16, 3, 1, 1)B×16×32×1Deconv_4B×16×32×1ConvT2d(16, 1, 1, 1, 0)B×1×32×1这张表只作步长示意。实际训练时建议把最后一层的输出宽度补成 T128。判别器用 5×5 卷积核对(1, T, 128)输入前两层步长取 2最后一层做 Global Average Pooling 后再接二分类和风格分类。3.4 判别器用 LSGAN 还是 BCE目标函数输出层典型表现BCESigmoid早期快后期容易出现判别器“麻木”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, devicedevice), 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_workers2并在训练循环前加上torch.backends.cudnn.benchmark True。如果卡住八成是线程阻塞在 DataLoader 里Linux 下把 workers 减到 0Windows 下固定为 1就能把问题隔离开。4.3 关键训练参数参考表下表是一组适合单卡显存 8GB 的初始值可以直接抄第一次实验参数名初始值调参窗口说明batch_size168~64超过 32 后 LSGAN 训练速度反而变慢seq_len12864~512太长会让时代标签失去局部约束z_dim6432~256太低容易模式崩溃太高导致音程不稳定lr_G0.00025e-5~2e-4生成器学习率通常高于判别器lr_D0.00021e-5~2e-4判别器太强时优先降低这里style_dim33~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, labelG) ax.plot(losses_d, labelD) 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_step4时装饰音更接近原谱但判别器关注短时噪声用 16 时模型更愿意学习大尺度旋律走向。发现作品“好记但不清澈”可以把步长从 8 改到 16。第三个旋钮是 Bi-LSTM 的序列步长一般取 8、16、32步长越大生成时间越长但不代表结构信息更多。5.2 用固定噪声反向对比时代训练结束后用同一段 z 依次换成不同style_label生成样本再对比音高分布。巴洛克片段如果几乎不在低音区出现说明训练数据里混入了太多只有右手声部的 midi 文件需要回预处理阶段补充左手声部。用下面的代码把生成矩阵写回 MIDIimport pretty_midi pm pretty_midi.PrettyMIDI() inst pretty_midi.Instrument(program0) 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(velocity80, pitchpitch, startonset, endonset 0.2) inst.notes.append(n) pm.instruments.append(inst) pm.write(romantic_sample.mid)其中note_grid是生成器输出经过阈值后的二值矩阵。endonset 0.2会给每个音符一个固定短时值听起来像机械断奏更自然的做法是在时间方向判断连续发声区域把同一音高跨多个时间步的片段合并成一个音符。5.3 代码下载后怎么二次修改把 notebook 转成普通脚本时用jupyter nbconvert --to script generate.ipynb会保留所有代码并去掉说明文字。下载下来的代码通常有三处需要改数据读取的root路径、style_map的作曲家编号以及ckpt.pt保存路径。建议先跑一次 5 个 epoch 的冒烟测试看到图形输出后再覆盖成正式训练用的batch_size这样能少浪费几轮 kernel 重启时间。本文还有配套的精品资源点击获取
返回列表