简介:这份资源面向对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(f'data/{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_sequences=False,或者取序列后接全连接。注意,双向结构在推理阶段需要完整序列,不能像单向 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_sequences=True), 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(loss='categorical_crossentropy', optimizer=RMSprop(learning_rate=0.001))第一层return_sequences=True是为了把序列传给第二层,第二层不返回序列,直接输出固定长度向量。RMSprop比 Adam 更适合 RNN 类模型,学习率 0.001 是稳妥起点。如果你发现 loss 震荡,先检查数据里有没有空序列或异常长的音符。
3.2 训练时的 batch_size、epoch 和早停策略
batch_size 用 128,epoch 先跑 100,但一定要加EarlyStopping。古典音乐生成任务里,loss 降到 2.0 以下后改善很慢,再跑下去就是过拟合。我一般监控val_loss,patience 设 10,即连续 10 个 epoch 没改善就停。另外,ModelCheckpoint只保存val_loss最好的权重,别省这一步,后面生成时你会感谢自己。
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks = [ EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True), ModelCheckpoint('best_bi_lstm.keras', monitor='val_loss', save_best_only=True) ] history = model.fit(X, y, epochs=100, batch_size=128, validation_split=0.1, callbacks=callbacks)validation_split=0.1是从训练集里切 10% 做验证,不是单独的文件。如果你的数据集已经按时代分好,建议手动切分,确保每个时代在验证集里都有样本,否则模型会偏向样本多的时代。
3.3 用训练好的 Bi-LSTM 生成旋律的两种方式
第一种是“种子序列 + 逐音符预测”,但双向模型不能直接流式生成。常见做法是:取一段真实序列作为种子,每次预测下一个音符后,把预测结果追加到序列末尾,再截取最后sequence_length个音符作为新输入。这样虽然用了双向结构,但推理时只依赖历史,相当于把双向当单向用。第二种是“整段生成”,即一次性输入一个随机噪声序列,让模型输出整段旋律,但效果通常不如第一种。我一般用第一种,种子序列从目标时代的作品中随机截取。
import random def generate_notes(model, seed_notes, n_generate=200): """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, verbose=0)[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 之后还要加 GAN
Bi-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_length=seq_len)) model.add(Conv1D(128, 3, activation='relu', padding='same')) model.add(MaxPooling1D(2)) model.add(Conv1D(128, 5, activation='relu', padding='same')) model.add(MaxPooling1D(2)) model.add(Conv1D(128, 7, activation='relu', padding='same')) model.add(GlobalAveragePooling1D()) model.add(Dropout(0.3)) model.add(Dense(n_eras, activation='softmax')) model.compile(loss='categorical_crossentropy', optimizer='adam', 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, epochs=50, batch_size=64): 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, verbose=0) 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, verbose=0), real_labels) print(f'Epoch {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并指定dtype=np.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,如果某个概率接近 0,log 会爆炸。解决:在生成器输出后加tf.clip_by_value,或者用from_logits=True让损失函数内部处理。另外,检查学习率,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,然后按概率采样。T=1.0 是原始分布,T<1 更保守,T>1 更随机。我一般从 1.0 开始试,如果重复太多就调到 1.2,如果太乱就降到 0.8。
def sample_with_temperature(preds, temperature=1.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), p=preds)验证生成质量不能只靠耳朵。我一般用两个指标:一是“时代分类准确率”,把生成旋律送进训练好的判别器,看它被判成目标时代的比例;二是“音程分布相似度”,计算生成旋律和真实旋律的音程直方图,用余弦相似度比较。这两个指标比 loss 更直观。
最后说个血泪教训:别在 Jupyter 里一次性跑完所有训练。把数据预处理、模型训练、生成、评估分成四个 notebook,每个 notebook 只做一件事。训练好的权重存成.keras文件,生成时直接加载。这样即使 Jupyter 内核崩了,也不用从头再来。希望帮到你。
本文还有配套的精品资源,点击获取