ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Bi-LSTM与CNN-GAN古典音乐生成实战:MIDI预处理到风格控制

Bi-LSTM与CNN-GAN古典音乐生成实战:MIDI预处理到风格控制 简介这份资源面向对AI作曲、深度学习与音乐生成感兴趣的学习者和研究者提供用Bi-LSTM与CNN-GAN生成古典音乐的完整Python实现。项目基于字节跳动GiantMIDI-Piano数据集针对巴洛克、古典、浪漫和现代主义四个音乐时代分别训练与测试并通过音高直方图、FID分数、最近邻和主观调查等指标评估生成效果适合作为课程设计、毕业项目或生成模型进阶练习的参考。压缩包共1516个文件约62.49MB其中1494个mid文件为各时代音乐样本4个py脚本与3个ipynb笔记本覆盖数据预处理、FID计算和最近邻评估等流程另有4个md说明文档、2个pdf资料及若干笔记文件辅助理解。已有481人学习下载。读者可据此复现双模型对比实验掌握MIDI预处理、生成模型搭建与多维度评价方法并借助MuseScore播放和转换生成的MIDI文件快速上手AI作曲研究。1. 从零搭一套能跑通的古典音乐生成流水线Bi-LSTM 与 CNN-GAN 到底怎么分工很多人第一次听到「用 Bi-LSTM 和 CNN-GAN 创作不同时代的古典音乐」脑子里冒出来的第一个问题是这俩模型是不是要拼在一起其实不是。真实做法是两条独立的路子各自负责不同的音乐生成任务最后用同一套 MIDI 处理管线串起来。Bi-LSTM 擅长捕捉音符序列里的长距离依赖适合做旋律续写CNN-GAN 擅长在二维 pianoroll 上做「图像生成」适合做整段乐曲的结构化生成。你要做的不是二选一而是搞清楚什么场景用哪个。这套方案适合谁有 Python 基础、装过 numpy、能在 Jupyter Notebook 里跑通一个简单神经网络的人。不需要你懂乐理但需要你愿意花时间处理 MIDI 数据——这一步比调模型参数麻烦得多。整条流水线在普通笔记本上就能跑不需要 GPU 也能出结果只是训练慢一些。下面从数据准备开始一步步把两个模型都跑通。2. 古典音乐 MIDI 数据集的获取与预处理从原始文件到可训练张量2.1 为什么 MIDI 预处理是整条流水线最容易翻车的地方古典音乐生成和图像生成最大的区别在于图像是固定尺寸的像素网格音乐是变长的时间序列。一首巴赫的赋格可能有几千个音符一首肖邦夜曲可能只有几百个。直接把这些变长序列喂给模型梯度会炸。所以第一步必须做「分片 对齐」。常见做法是按固定时间窗口比如 4 小节切分 MIDI 文件每个窗口内把所有音符转成 pianoroll 矩阵。pianoroll 的行是音高通常 128 个 MIDI 音高列是时间步比如每 0.125 秒一列。这样一个窗口就变成一个 128×N 的二维矩阵CNN-GAN 可以直接当图像处理Bi-LSTM 则把矩阵按时间轴展平成序列。我一般会按作曲家分文件夹存放 MIDI 文件比如data/bach/、data/mozart/、data/chopin/。这样后面做「不同时代」的条件生成时可以直接用文件夹名当标签。注意不要混入非古典音乐的 MIDI网上很多「古典合集」里混了流行钢琴曲训练出来的东西会串味。2.2 用 music21 和 pretty_midi 把 MIDI 转成 numpy 数组import os import numpy as np import pretty_midi from music21 import converter def midi_to_pianoroll(midi_path, fs8, seq_len128): 将 MIDI 文件转为 pianoroll 矩阵 fs: 每秒采样帧数8 表示每 0.125 秒一帧 seq_len: 时间轴长度128 帧约等于 16 秒 pm pretty_midi.PrettyMIDI(midi_path) # 获取 pianoroll形状为 (128, time_steps) piano_roll pm.get_piano_roll(fsfs) # 转置为 (time_steps, 128)方便按时间切片 piano_roll piano_roll.T # 二值化有音符为 1无为 0 piano_roll (piano_roll 0).astype(np.int8) # 按 seq_len 切分丢弃不足一个窗口的尾部 segments [] for i in range(0, len(piano_roll) - seq_len 1, seq_len): segments.append(piano_roll[i:iseq_len]) return np.array(segments) # 批量处理 all_segments [] for root, dirs, files in os.walk(data/): for f in files: if f.endswith(.mid) or f.endswith(.midi): path os.path.join(root, f) try: segs midi_to_pianoroll(path) all_segments.append(segs) except Exception as e: print(f跳过 {path}: {e}) data np.concatenate(all_segments, axis0) np.save(pianoroll_data.npy, data) print(f总样本数: {data.shape[0]}, 每个样本形状: {data.shape[1:]})这段代码的核心逻辑是用pretty_midi读取 MIDI调用get_piano_roll得到原始 pianoroll然后转置、二值化、按固定长度切分。fs8意味着每秒 8 帧对于古典音乐来说足够捕捉大多数音符时值再高会增加计算量但收益不大。seq_len128对应 16 秒的音乐片段这个长度能覆盖一个完整的乐句又不至于让 LSTM 的梯度消失问题太严重。参数怎么改如果你的数据集里有很多快速跑动的音符比如李斯特的练习曲可以把fs提到 16如果显存或内存吃紧把seq_len降到 64。切分时丢弃尾部不足一个窗口的数据是常见做法也可以选择 padding 补齐但 padding 会引入大量静音帧模型容易学会「生成静音」。2.3 数据归一化和训练集划分的注意点pianoroll 二值化之后是 0/1 矩阵不需要再做归一化。但如果你用的是连续值比如音符力度就要做 min-max 归一化。训练集和验证集按 8:2 划分注意要按「曲目」划分而不是按「片段」划分——同一首曲子的不同片段如果同时出现在训练集和验证集里验证 loss 会虚低这是血泪经验。from sklearn.model_selection import train_test_split # 假设 data 形状为 (N, 128, 128) N data.shape[0] indices np.arange(N) train_idx, val_idx train_test_split(indices, test_size0.2, random_state42) X_train data[train_idx] X_val data[val_idx] print(f训练集: {X_train.shape}, 验证集: {X_val.shape})3. Bi-LSTM 旋律续写模型从序列建模到音符生成3.1 为什么用双向 LSTM 而不是单向单向 LSTM 只能看到当前音符之前的信息但音乐有个特点一个乐句的结尾往往呼应开头。双向 LSTM 同时看前后文能更好地捕捉这种对称结构。具体到实现上Bi-LSTM 的输出是前向和后向隐藏状态的拼接维度翻倍但参数量增加不多。在旋律续写任务里我一般把 pianoroll 按时间轴展平成序列每个时间步是一个 128 维的向量对应 128 个音高。Bi-LSTM 的输入维度是 128隐藏层设 256堆两层。输出层用全连接映射回 128 维加 sigmoid 激活表示每个音高在该时间步是否发声。3.2 用 PyTorch 搭一个可训练的 Bi-LSTMimport torch import torch.nn as nn class BiLSTMMusic(nn.Module): def __init__(self, input_dim128, hidden_dim256, num_layers2): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue # 关键双向 ) # 双向输出维度是 hidden_dim * 2 self.fc nn.Linear(hidden_dim * 2, input_dim) self.sigmoid nn.Sigmoid() def forward(self, x): # x: (batch, seq_len, 128) lstm_out, _ self.lstm(x) # lstm_out: (batch, seq_len, hidden_dim*2) out self.fc(lstm_out) return self.sigmoid(out) # 训练循环 device torch.device(cuda if torch.cuda.is_available() else cpu) model BiLSTMMusic().to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.BCELoss() # 假设 X_train 形状为 (N, 128, 128)转成 (N, 128, 128) 的序列 X_tensor torch.tensor(X_train, dtypetorch.float32).to(device) # 输入是前 127 步目标是后 127 步自回归 for epoch in range(50): model.train() optimizer.zero_grad() inputs X_tensor[:, :-1, :] # (N, 127, 128) targets X_tensor[:, 1:, :] # (N, 127, 128) outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() if epoch % 10 0: print(fEpoch {epoch}, Loss: {loss.item():.4f})这段代码的关键点bidirectionalTrue让 LSTM 同时处理正反两个方向输入输出错开一位实现自回归训练BCELoss 适合二值化的 pianoroll。训练时注意inputs和targets的错位这是序列生成任务的标准做法。参数怎么调hidden_dim从 256 提到 512 能提升生成质量但训练时间翻倍num_layers超过 3 层后收益递减还容易过拟合。学习率 1e-3 是 Adam 的常用起点如果 loss 震荡就降到 5e-4。batch size 我一般设 32 或 64取决于内存。3.3 生成阶段温度采样和避免重复音符训练完之后生成旋律不能直接取 argmax那样会得到很死板的输出。常见做法是「温度采样」把 sigmoid 输出除以温度系数 T再按概率采样。T 越大越随机T 越小越保守。我一般用 T0.8 到 1.0 之间。def generate(model, seed, length128, temperature0.9): model.eval() generated seed.clone().to(device) # seed: (1, seq_len, 128) with torch.no_grad(): for _ in range(length): input_seq generated[:, -128:, :] # 取最后 128 步 output model(input_seq) next_note output[:, -1, :] / temperature next_note torch.bernoulli(torch.sigmoid(next_note)) generated torch.cat([generated, next_note.unsqueeze(1)], dim1) return generated注意torch.bernoulli的用法它按概率采样 0 或 1比直接阈值化更自然。生成时如果发现某个音高连续很多帧都是 1说明模型陷入了重复可以手动加一个「惩罚项」——对已经持续发声超过 4 帧的音高降低其概率。4. CNN-GAN 整段生成把 pianoroll 当图像来画4.1 GAN 生成音乐和生成图像的本质区别图像 GAN 的判别器看的是「这张图真不真」音乐 GAN 的判别器看的是「这段 pianoroll 像不像真实乐曲的片段」。区别在于图像的像素值连续pianoroll 是二值的图像的空间局部性很强音乐的时间局部性同样强但还有和声约束——同时发声的音符要符合乐理。所以 CNN-GAN 的生成器不能随便堆转置卷积要在最后一层加一个「和声约束」对同时发声的音高做过滤去掉不和谐的音程比如小二度。这个约束不用可微直接在生成后处理即可。4.2 生成器和判别器的网络结构class Generator(nn.Module): def __init__(self, latent_dim100, output_shape(128, 128)): super().__init__() self.output_shape output_shape self.fc nn.Linear(latent_dim, 256 * 8 * 8) self.deconv nn.Sequential( nn.ConvTranspose2d(256, 128, 4, 2, 1), # 8x8 - 16x16 nn.BatchNorm2d(128), nn.ReLU(), nn.ConvTranspose2d(128, 64, 4, 2, 1), # 16x16 - 32x32 nn.BatchNorm2d(64), nn.ReLU(), nn.ConvTranspose2d(64, 32, 4, 2, 1), # 32x32 - 64x64 nn.BatchNorm2d(32), nn.ReLU(), nn.ConvTranspose2d(32, 1, 4, 2, 1), # 64x64 - 128x128 nn.Sigmoid() ) def forward(self, z): x self.fc(z).view(-1, 256, 8, 8) return self.deconv(x) class Discriminator(nn.Module): def __init__(self, input_shape(128, 128)): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, 32, 4, 2, 1), # 128x128 - 64x64 nn.LeakyReLU(0.2), nn.Conv2d(32, 64, 4, 2, 1), # 64x64 - 32x32 nn.BatchNorm2d(64), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, 4, 2, 1), # 32x32 - 16x16 nn.BatchNorm2d(128), nn.LeakyReLU(0.2), nn.Conv2d(128, 1, 4, 1, 0), # 16x16 - 13x13 nn.Sigmoid() ) def forward(self, x): return self.conv(x).view(-1, 1)生成器从 100 维噪声出发经过全连接和四层转置卷积输出 128×128 的 pianoroll。判别器是标准卷积堆叠最后输出一个标量表示真假。注意生成器最后一层用 Sigmoid 把值压到 0-1判别器也用 Sigmoid 输出概率。参数怎么改latent_dim从 100 提到 256 能增加生成多样性但训练不稳定转置卷积的通道数可以按 256→128→64→32→1 递减也可以整体翻倍。判别器的 LeakyReLU 斜率 0.2 是 WGAN 论文里的推荐值实测比 0.1 稳定。4.3 训练 GAN 的坑模式崩溃和梯度消失GAN 训练最怕模式崩溃——生成器只输出几种固定模式判别器随便就能分辨。在音乐生成里模式崩溃表现为「所有生成的曲子听起来都差不多」。解决办法有几个一是用 WGAN-GP 的梯度惩罚替代原始 GAN 的交叉熵损失二是每训练一次判别器就训练两次生成器三是在判别器里加 Dropout。# WGAN-GP 的梯度惩罚 def gradient_penalty(discriminator, real, fake, device): alpha torch.rand(real.size(0), 1, 1, 1).to(device) interpolated alpha * real (1 - alpha) * fake interpolated.requires_grad_(True) pred discriminator(interpolated) grad torch.autograd.grad( outputspred, inputsinterpolated, grad_outputstorch.ones_like(pred), create_graphTrue, retain_graphTrue )[0] grad_norm grad.view(grad.size(0), -1).norm(2, dim1) return ((grad_norm - 1) ** 2).mean()这段梯度惩罚代码是 WGAN-GP 的核心alpha是随机插值系数interpolated是真实样本和生成样本之间的插值点惩罚项让判别器在这些点上的梯度范数接近 1。加上这个之后训练稳定性明显提升但每步计算量增加约 30%。5. 避坑与排查MIDI 生成流水线里最常见的 5 个翻车现场5.1 生成的 MIDI 播放出来全是杂音现象用模型生成 pianoroll 后转成 MIDI用播放器打开全是刺耳的噪音。原因pianoroll 转 MIDI 时没有设置正确的 tempo 和乐器通道或者音符时值全是 1 帧导致每个音符只有几毫秒。解决用pretty_midi创建 MIDI 对象时显式设置tempo120并且把连续为 1 的帧合并成一个音符时值至少 0.1 秒。5.2 训练 loss 不下降一直卡在 0.69 左右现象Bi-LSTM 的 BCELoss 从 0.69 开始几乎不动。原因pianoroll 里 0 的数量远多于 1稀疏模型学会了全部预测 0 就能拿到 0.69 的 loss。解决在 BCELoss 里加pos_weight参数给正样本更高权重或者改用 Focal Loss。我一般设pos_weight5到10之间。5.3 CNN-GAN 的判别器 loss 迅速降到 0现象判别器 loss 几轮之后就接近 0生成器 loss 飙升。原因判别器太强生成器梯度消失。解决降低判别器的学习率比如生成器 1e-3判别器 1e-4或者给判别器加 Dropout或者改用 WGAN-GP。另一个常见原因是判别器的参数量远大于生成器把判别器通道数减半试试。5.4 Jupyter Notebook 跑着跑着内存爆了现象处理几百个 MIDI 文件后Notebook 内核崩溃。原因all_segments列表把所有片段都存在内存里古典音乐 MIDI 文件虽然小但转成 pianoroll 后每个片段是 128×128 的 int8 矩阵几千个片段就是几百 MB。解决分批处理每处理 100 个文件就np.save一次然后用del释放内存。或者直接用np.memmap做内存映射。5.5 生成的音乐「听起来像古典但就是不对味」现象模型生成的片段单独听还行连续听几段就发现和声进行很怪。原因模型只学了音符的局部统计规律没学和声功能进行。解决在生成后处理里加一个简单的和声过滤器——检查每个时间步同时发声的音高如果出现小二度或三全音就随机去掉一个音。这个规则很粗糙但能过滤掉大部分「刺耳」的片段。6. 让生成结果更像「那个时代」条件注入与风格验证的实操技巧前面讲的 Bi-LSTM 和 CNN-GAN 都是无条件生成——模型不知道自己在写巴赫还是肖邦。要让生成结果带上时代风格最直接的办法是「条件注入」在模型输入里加一个风格标签。Bi-LSTM 可以在每个时间步的输入向量后面拼接一个 one-hot 风格向量CNN-GAN 可以把风格向量拼到噪声 z 后面。这样训练时模型会学到「标签 A 对应这种和声进行标签 B 对应那种旋律走向」。具体操作上我一般把风格标签设成 4 类巴洛克、古典、浪漫、印象派。每个作曲家文件夹对应一个标签。训练时用nn.Embedding把标签转成 16 维向量然后和 pianoroll 拼接。生成时指定标签就能控制输出风格。验证生成结果是否「像那个时代」不能只靠耳朵。我常用的一个笨办法是统计生成片段的音程分布和真实数据做对比。比如巴洛克音乐里纯四度、纯五度音程占比高浪漫派里大跳和半音进行更多。用music21的interval模块可以批量计算音程然后画直方图对比。如果生成数据的音程分布和真实数据偏差超过 20%说明模型没学好需要调参或加数据。还有一个技巧是「种子旋律续写」给模型一段真实的巴赫主题让它续写 8 小节然后看续写部分的和声是否遵循了主题的调性。如果续写部分频繁转调或出现不和谐音说明模型的长期依赖建模不够可以把 Bi-LSTM 的hidden_dim从 256 提到 512或者增加num_layers。最后说一个我自己的习惯每次训练完模型先不急着生成完整曲子而是生成 20 个短片段每个 4 小节快速听一遍把明显崩坏的片段挑出来看看它们有什么共同点——是都集中在某个音高区间还是都出现在某个时间位置。这个排查过程比盯着 loss 曲线有用得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表