
简介这份资源面向对AI作曲与音乐生成感兴趣的学习者和研究者提供用Bi-LSTM和CNN-GAN两种生成模型创作古典音乐的完整Python实现。项目基于字节跳动GiantMIDI-Piano数据集覆盖巴洛克、古典、浪漫和现代主义四个音乐时代并采用音高直方图、FID分数、最近邻和主观调查等指标分别与集体评估模型表现适合具备一定深度学习基础、希望上手音乐生成实战的读者。压缩包共1516个文件约62.49MB其中1494个mid文件为各时代训练与生成的MIDI样本4个py脚本与3个ipynb笔记本承载数据预处理、FID计算和最近邻评估等核心流程另有4个md说明文档、2个pdf资料及若干笔记文件辅助理解。目前已有481人学习下载。借助配套代码与说明读者可复现完整训练与评估链路理解不同生成模型在古典音乐风格建模上的差异并可用MuseScore播放MIDI、转换mp3直观感受生成效果。1. 从一段 MIDI 到一首“像那个时代”的曲子Bi-LSTM 与 CNN-GAN 到底在做什么你手里有一堆 MIDI 文件巴赫、莫扎特、肖邦各几百首想让模型学完之后吐出一段“听起来像巴洛克”的旋律。这件事的难点不在生成而在“像哪个时代”。古典音乐的时代风格差异藏在和声进行、声部间距、节奏密度这些细节里不是随便一个 LSTM 就能糊弄过去的。Bi-LSTM 负责把音符序列的前后文都吃进去CNN-GAN 负责在局部音型和整体风格之间做对抗式打磨两者配合才能让生成的曲子既有结构感又有时代味。这篇笔记面向已经会写 Python、用过 Jupyter Notebook、想动手跑一遍音乐生成全流程的人从数据预处理一路讲到训练参数和踩坑记录代码可以直接在 Jupyter 里逐块执行。2. 数据准备与特征工程把 MIDI 变成模型能吃的数字2.1 为什么古典音乐生成首选 MIDI 而不是音频音频文件WAV、MP3里混着演奏力度、混响、乐器音色这些和“时代风格”关系不大反而会干扰模型。MIDI 是符号化的乐谱数据只记录音高、时值、力度、乐器编号正好对应作曲层面的信息。常见做法是用music21解析 MIDI提取音符事件序列再转成整数索引。注意不同时代的 MIDI 数据集质量参差巴洛克时期的作品往往有大量装饰音浪漫主义时期则频繁使用自由速度预处理阶段要统一量化到十六分音符网格否则序列长度会爆炸。2.2 用 music21 提取音符序列并构建词表from music21 import converter, note, chord import numpy as np def extract_notes(midi_path): 从 MIDI 文件提取音符/和弦事件返回字符串列表 stream converter.parse(midi_path) notes [] for element in stream.flat.notes: if isinstance(element, note.Note): notes.append(str(element.pitch)) elif isinstance(element, chord.Chord): # 和弦用点号连接各音高保持可逆 notes.append(..join(str(n) for n in element.normalOrder)) return notes # 假设你已按时代分好文件夹baroque/ classical/ romantic/ all_notes [] for era in [baroque, classical, romantic]: import glob for f in glob.glob(fdata/{era}/*.mid): all_notes.extend(extract_notes(f)) # 构建词表音高到整数的映射 pitchnames sorted(set(all_notes)) note_to_int {n: i for i, n in enumerate(pitchnames)} int_to_note {i: n for n, i in note_to_int.items()}这段代码做了三件事解析 MIDI、提取音符与和弦、建立双向映射。stream.flat.notes会把所有声部展平适合单旋律或主调音乐如果你处理的是复调作品建议保留声部信息用stream.parts分别提取。normalOrder返回和弦的标准化音高集合避免转位导致同一和弦被当成不同事件。词表大小通常在 200 到 500 之间取决于数据集覆盖的音高范围。2.3 序列切分与 Bi-LSTM 的输入格式Bi-LSTM 需要固定长度的输入序列。常见做法是滑动窗口切分窗口长度 64 或 128步长 1。每个窗口对应一个标签即窗口后一个音符。这样模型学的是“给定前文预测下一个音符”。但 Bi-LSTM 是双向的训练时不能直接看到未来所以实际做法是用双向层提取特征但只在最后一步输出预测。Keras 里可以用Bidirectional(LSTM(...))然后取return_sequencesFalse或者取序列后接全连接。注意双向结构在推理阶段需要完整序列不能像单向 LSTM 那样逐音符流式生成这是后面生成策略要处理的问题。sequence_length 64 network_input [] network_output [] for i in range(0, len(all_notes) - sequence_length): seq_in all_notes[i:i sequence_length] seq_out all_notes[i sequence_length] network_input.append([note_to_int[n] for n in seq_in]) network_output.append(note_to_int[seq_out]) n_patterns len(network_input) X np.reshape(network_input, (n_patterns, sequence_length, 1)) / float(len(pitchnames)) y np.eye(len(pitchnames))[network_output] # one-hot归一化用float(len(pitchnames))是常见做法把整数索引压到 0~1。y用 one-hot 是因为后面用 categorical_crossentropy。如果你的数据集很大one-hot 会占内存可以改用 sparse_categorical_crossentropy 并保留整数标签。3. Bi-LSTM 模型搭建与训练双向上下文怎么用才不翻车3.1 双向 LSTM 的层数、单元数和 dropout 怎么定我一般用两层 Bi-LSTM每层 256 个单元dropout 设 0.3。层数再多容易过拟合尤其当你的 MIDI 数据集只有几百首时。单元数 256 是经验值能覆盖大多数古典作品的音高和节奏模式。dropout 放在每层 LSTM 之后而不是之前因为输入已经是归一化后的整数序列不需要再丢。注意双向 LSTM 的参数量是单向的两倍训练时显存占用要提前算好。如果显存不够先把 batch_size 降到 64 或 32别急着减单元数。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dropout, Dense, Activation, Bidirectional from tensorflow.keras.optimizers import RMSprop model Sequential() model.add(Bidirectional(LSTM(256, return_sequencesTrue), input_shape(sequence_length, 1))) model.add(Dropout(0.3)) model.add(Bidirectional(LSTM(256))) model.add(Dropout(0.3)) model.add(Dense(len(pitchnames))) model.add(Activation(softmax)) model.compile(losscategorical_crossentropy, optimizerRMSprop(learning_rate0.001))第一层return_sequencesTrue是为了把序列传给第二层第二层不返回序列直接输出固定长度向量。RMSprop比 Adam 更适合 RNN 类模型学习率 0.001 是稳妥起点。如果你发现 loss 震荡先检查数据里有没有空序列或异常长的音符。3.2 训练时的 batch_size、epoch 和早停策略batch_size 用 128epoch 先跑 100但一定要加EarlyStopping。古典音乐生成任务里loss 降到 2.0 以下后改善很慢再跑下去就是过拟合。我一般监控val_losspatience 设 10即连续 10 个 epoch 没改善就停。另外ModelCheckpoint只保存val_loss最好的权重别省这一步后面生成时你会感谢自己。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue), ModelCheckpoint(best_bi_lstm.keras, monitorval_loss, save_best_onlyTrue) ] history model.fit(X, y, epochs100, batch_size128, validation_split0.1, callbackscallbacks)validation_split0.1是从训练集里切 10% 做验证不是单独的文件。如果你的数据集已经按时代分好建议手动切分确保每个时代在验证集里都有样本否则模型会偏向样本多的时代。3.3 用训练好的 Bi-LSTM 生成旋律的两种方式第一种是“种子序列 逐音符预测”但双向模型不能直接流式生成。常见做法是取一段真实序列作为种子每次预测下一个音符后把预测结果追加到序列末尾再截取最后sequence_length个音符作为新输入。这样虽然用了双向结构但推理时只依赖历史相当于把双向当单向用。第二种是“整段生成”即一次性输入一个随机噪声序列让模型输出整段旋律但效果通常不如第一种。我一般用第一种种子序列从目标时代的作品中随机截取。import random def generate_notes(model, seed_notes, n_generate200): seed_notes 是整数索引列表长度等于 sequence_length pattern list(seed_notes) prediction_output [] for _ in range(n_generate): input_seq np.reshape(pattern, (1, len(pattern), 1)) / float(len(pitchnames)) pred model.predict(input_seq, verbose0)[0] index np.argmax(pred) prediction_output.append(int_to_note[index]) pattern.append(index) pattern pattern[1:] # 滑动窗口 return prediction_outputnp.argmax是贪心策略生成的旋律会偏保守。想增加多样性可以用np.random.choice按概率采样但温度参数要调太高会乱太低会重复。4. CNN-GAN 做风格对抗判别器怎么区分时代4.1 为什么在 Bi-LSTM 之后还要加 GANBi-LSTM 学的是“下一个音符是什么”但它不直接优化“像不像某个时代”。CNN-GAN 的思路是生成器可以用 Bi-LSTM 或 CNN产出旋律判别器用 CNN 判断这段旋律属于哪个时代。对抗训练会让生成器逐渐学会时代特有的音型。注意这里的判别器不是二分类“真/假”而是多分类“巴洛克/古典/浪漫”这样生成器才能被推向特定时代。4.2 判别器的 CNN 结构卷积核大小和池化策略判别器输入是音符序列的 one-hot 或嵌入表示。我一般用一维卷积卷积核大小 3、5、7 各一层捕捉不同长度的音型。每层后接MaxPooling1D和Dropout。最后用GlobalAveragePooling1D代替 Flatten减少参数量。输出层用 softmax类别数等于时代数。from tensorflow.keras.layers import Conv1D, MaxPooling1D, GlobalAveragePooling1D, Embedding def build_discriminator(seq_len, vocab_size, n_eras): model Sequential() model.add(Embedding(vocab_size, 64, input_lengthseq_len)) model.add(Conv1D(128, 3, activationrelu, paddingsame)) model.add(MaxPooling1D(2)) model.add(Conv1D(128, 5, activationrelu, paddingsame)) model.add(MaxPooling1D(2)) model.add(Conv1D(128, 7, activationrelu, paddingsame)) model.add(GlobalAveragePooling1D()) model.add(Dropout(0.3)) model.add(Dense(n_eras, activationsoftmax)) model.compile(losscategorical_crossentropy, optimizeradam, metrics[accuracy]) return modelEmbedding把整数索引转成 64 维向量比 one-hot 更省内存。卷积核 3、5、7 分别对应短、中、长音型。GlobalAveragePooling1D对每个特征图取平均保留整体风格信息比 Flatten 更不容易过拟合。4.3 生成器与判别器的交替训练步骤GAN 训练是交替的先固定生成器训练判别器几个 batch再固定判别器训练生成器。生成器的损失是“让判别器把生成样本判成目标时代”。注意生成器的输出是 softmax 概率分布不能直接反向传播需要用 Gumbel-Softmax 或直接取 argmax 后转 one-hot但后者不可导。常见做法是生成器输出概率判别器接受概率向量作为输入这样梯度可以传回去。def train_gan(generator, discriminator, X_train, era_labels, epochs50, batch_size64): for epoch in range(epochs): # 训练判别器 idx np.random.randint(0, X_train.shape[0], batch_size) real_seqs X_train[idx] real_labels era_labels[idx] noise np.random.normal(0, 1, (batch_size, sequence_length, 1)) fake_seqs generator.predict(noise, verbose0) d_loss_real discriminator.train_on_batch(real_seqs, real_labels) d_loss_fake discriminator.train_on_batch(fake_seqs, real_labels) # 注意这里用真实标签让生成器学目标时代 # 训练生成器 noise np.random.normal(0, 1, (batch_size, sequence_length, 1)) g_loss discriminator.train_on_batch(generator.predict(noise, verbose0), real_labels) print(fEpoch {epoch}: D-real {d_loss_real[0]:.4f}, D-fake {d_loss_fake[0]:.4f}, G {g_loss[0]:.4f})这段代码里判别器对生成样本也用真实时代标签这是“条件 GAN”的思路让生成器直接朝目标时代优化。但要注意判别器会逐渐学会区分真假所以生成器的损失会波动这是正常的。如果判别器准确率一直接近 100%说明它太强了要降低判别器的学习率或减少训练次数。5. 避坑与排查从数据到生成这 5 个坑我全踩过5.1 现象生成的旋律全是同一个音或者重复片段原因模型过拟合或者生成时用了贪心策略且温度太低。双向 LSTM 在训练时看到完整序列推理时只能看历史分布不一致导致输出坍缩。解决在生成时加入温度参数用np.random.choice按概率采样温度设 0.8~1.2。另外检查训练数据里有没有大量重复片段如果有先做去重。5.2 现象判别器 loss 不下降准确率始终 50%原因生成器和判别器能力不匹配或者标签有问题。常见的是时代标签没对齐比如把巴洛克 MIDI 标成了古典。解决先单独训练判别器看它在真实数据上能不能达到 80% 以上准确率。如果不行说明 CNN 结构或数据有问题。另外检查era_labels的 one-hot 编码是否正确。5.3 现象Jupyter Notebook 里训练到一半内存溢出原因network_input和network_output用 Python 列表存了几十万条序列每个序列又是列表内存占用是实际数据的几倍。解决改用np.array并指定dtypenp.int16或者用tf.data.Dataset做流式加载。如果还是不够把sequence_length从 128 降到 64。5.4 现象生成的 MIDI 用 music21 写回后播放没声音原因音符时值没设置或者音高超出了乐器范围。int_to_note里存的是音高字符串写回时要创建note.Note对象并设置quarterLength。解决在生成时同时预测时值或者统一用十六分音符。写回代码里加n.quarterLength 0.5。5.5 现象CNN-GAN 训练时生成器 loss 突然变成 NaN原因生成器输出经过 softmax 后判别器用categorical_crossentropy如果某个概率接近 0log 会爆炸。解决在生成器输出后加tf.clip_by_value或者用from_logitsTrue让损失函数内部处理。另外检查学习率GAN 的学习率通常要比普通模型低设 0.0002 试试。6. 进阶技巧用时代嵌入和温度采样让生成更可控如果你已经跑通了基础版本下一步可以试试“时代嵌入”。做法是给生成器额外输入一个时代标签的嵌入向量和噪声拼接后一起送进 LSTM。这样同一个模型可以生成不同时代的旋律不用为每个时代单独训练。代码上把noise从(batch, seq_len, 1)改成(batch, seq_len, 1 era_embed_dim)时代嵌入用Embedding(n_eras, 8)得到然后广播到每个时间步。另一个技巧是温度采样。贪心策略生成的旋律太“安全”温度采样能增加多样性。具体做法是对 softmax 输出取 log 后除以温度 T再 softmax然后按概率采样。T1.0 是原始分布T1 更保守T1 更随机。我一般从 1.0 开始试如果重复太多就调到 1.2如果太乱就降到 0.8。def sample_with_temperature(preds, temperature1.0): preds np.asarray(preds).astype(float64) preds np.log(preds 1e-8) / temperature exp_preds np.exp(preds) preds exp_preds / np.sum(exp_preds) return np.random.choice(len(preds), ppreds)验证生成质量不能只靠耳朵。我一般用两个指标一是“时代分类准确率”把生成旋律送进训练好的判别器看它被判成目标时代的比例二是“音程分布相似度”计算生成旋律和真实旋律的音程直方图用余弦相似度比较。这两个指标比 loss 更直观。最后说个血泪教训别在 Jupyter 里一次性跑完所有训练。把数据预处理、模型训练、生成、评估分成四个 notebook每个 notebook 只做一件事。训练好的权重存成.keras文件生成时直接加载。这样即使 Jupyter 内核崩了也不用从头再来。希望帮到你。本文还有配套的精品资源点击获取