简介:基于RNN循环神经网络的音乐生成AI项目,是一份面向机器学习初学者与课程设计场景的完整源码包。项目以Python实现,包含训练好的循环神经网络模型权重文件,可完成从音符序列到旋律片段的生成任务,代码中附有详细注释,适合期末大作业、课程设计等实践环节直接部署使用。压缩包共138个文件,总大小约49.38MB,主要包含14个Python脚本、13个模型权重文件(.pth)、50组检查点数据(含index与data文件)、7个MIDI示例音乐,以及工程配置等文件,目录结构清晰,便于对照学习训练与生成逻辑。目前已有269人学习下载,该资源覆盖数据预处理、模型训练、生成与可视化等环节,尤其适合希望快速上手RNN音乐生成、并需要可直接运行参考项目的高校学生。通过查看模型检查点与示例MIDI,可直观理解每一步训练效果,减少调试弯路。
1. 把音乐生成拆成“下一个音符预测”:RNN 音乐生成项目上手前的直觉
音乐生成听起来玄,拆开就一句话:给模型一段历史音符序列,让它预测下一个音符;预测完把输出接到序列尾巴上,再预测下一个。这套 Python 项目就是按这个思路把流程完整落地了,源码、模型文件和 checkpoint 一应俱全,checkpoint 从 ckpt_3 一路存到 ckpt_48,随便挑一个轮次都能加载出当时的训练结果。它不追求“写出贝多芬”,而是让你用一套标准的 RNN 循环神经网络流程,在本地把旋律生成实实在在跑通。期末大作业、课程设计、或者刚接触 AI 生成想找一份能直接部署的代码,都适合从它下手。别把它当黑匣子,输入是音符 token 序列,输出是概率分布,往下看就知道每一步怎么走。
2. 从音符到张量:预处理、词表与 LSTM 模型结构
做音乐生成,第一件事不是搭模型,而是把 MIDI 变成能喂给 RNN 的数字序列。不少新手直接拿波形数据训练,那是音频生成的路子,复杂度高得多;这个项目走的是符号音乐路线,输入是“音符事件”,输出也是“音符事件”。这一步想清楚,后面所有代码都好理解。
2.1 选型理由:为什么是 RNN/LSTM,而不是 CNN
RNN 循环神经网络处理的是序列依赖关系。音乐的“意义”在上下文里:一个音高是否顺耳,取决于它前面几个音符的走向,甚至更远小节的旋律呼应。CNN 擅长在空间上做局部模式提取,拿来处理音符序列不是不行,但感受野受限,对长距离的旋律呼应处理起来很别扭。RNN 天然按时间步展开,每看到一个音符就把信息压缩到隐藏状态里传递下去,这跟音乐“读到哪想到哪”的过程一致。
LSTM 加了三道门,本质是给循环单元增加了一个可学习的记忆通道。遗忘门决定保留多少过去的旋律信息,输入门决定写入多少当前音符,输出门决定传递多少给下一步。这样 16 小节前的主题旋律还能通过网络传到当前时刻,不至于被后续音符“冲淡”。这个项目把 LSTM 当主力单元是合理的,你代码里把 LSTM 换成 GRU 也行,但注意 checkpoint 不通用——换结构就必须重新从零训练,这一点在避坑章会细说。
2.2 预处理:把 MIDI 变成 token 序列
第一步是读 MIDI 文件,提取每个音符的起始时间、结束时间和音高。项目里常见做法是用pretty_midi,接口比mido友好得多。时间分辨率用 fs 表示,单位是 tick/秒,fs=100 表示每 0.01 秒一个 tick。每个音符用一个 (pitch, duration) 二元组表示,pitch 范围通常是 21~108(钢琴 88 键范围),duration 量化成整数 tick。然后把二元组映射成整数 token,构造词表,RNN 的输入就变成一个整数序列,词表大小一般在 200~500 之间。
# midi_utils.py import pretty_midi def midi_to_notes(midi_file, fs=100): """把 MIDI 文件解析成 (pitch, duration) 音符二元组列表。 fs 是时间分辨率,100 表示每 0.01 秒一个 tick。 过滤打击乐轨:打击乐没有稳定音高,不适合当前模型。""" midi = pretty_midi.PrettyMIDI(midi_file) notes = [] for inst in midi.instruments: if inst.is_drum: # 跳过打击乐轨道 continue for note in inst.notes: start = int(note.start * fs) end = int(note.end * fs) dur = end - start if dur <= 0: # 时长为 0 的无效音符直接丢弃 continue notes.append((note.pitch, dur)) return notes def build_vocab_and_tokens(notes): """把 (pitch, dur) 二元组压缩成唯一 token, 返回 token 序列和正向/反向映射。""" vocab, tokens = {}, [] for pitch, dur in notes: tok = vocab.setdefault((pitch, dur), len(vocab)) tokens.append(tok) inverse_vocab = {v: k for k, v in vocab.items()} return tokens, vocab, inverse_vocab这里 fs 是第一个要调的超参数。fs 调小,时间粒度变粗,训练数据量变小,时长信息丢失;fs 调大如 200,音节时长更精细,但序列更长、训练更慢。我一般用 50~100,在分辨率和训练成本之间取平衡。midi_to_notes 输出的二元组是模型真正感知的“音符事件”,pitch 决定音高,dur 决定时值,两个一起进词表,模型才能学会“这个音拖多长”。
词表建好后,还要把 token 序列切成“输入-目标”对:用连续的 32 个 token 当输入,预测第 33 个 token。这一步决定了模型能看到的上下文长度。
def make_sequences(tokens, seq_len=32): """把 token 序列切成训练样本。 每个样本:前 seq_len 个 token 作为输入,下一个 token 作为目标。""" X, Y = [], [] for i in range(len(tokens) - seq_len - 1): X.append(tokens[i:i + seq_len]) # 输入:最近 32 个音符 Y.append(tokens[i + seq_len]) # 目标:第 33 个音符 return X, Yseq_len=32 是第二个关键超参数,它决定“模型看多远”。32 个音符在 4/4 拍、每拍两个音符的曲子里大约覆盖 4 小节,已经能学到乐句结构。想学到完整 8 小节乐段,可以把 seq_len 提到 64;代价是训练样本数量下降、收敛变慢。做课程设计的话,32 足够出效果,提到 64 之前先想想训练集够不够大。
2.3 模型结构:Embedding、LSTM、全连接输出
模型结构用 Keras 写最直白,而且项目里的 checkpoint 是 TensorFlow 检查点格式,.index配.data-00000-of-00001成对出现,用 Keras 加载最省事。
import tensorflow as tf from tensorflow.keras import layers, models def build_rnn_model(vocab_size, seq_len, embed_dim=64, hidden_dim=256): """标准 RNN 音乐生成模型: Embedding -> LSTM -> Dense(softmax)""" inputs = layers.Input(shape=(seq_len,), dtype=tf.int32) # 形状 (batch, 32) x = layers.Embedding(vocab_size, embed_dim)(inputs) # (batch, 32, 64) x = layers.LSTM(hidden_dim, return_sequences=False)(x) # (batch, 256) outputs = layers.Dense(vocab_size, activation='softmax')(x) return models.Model(inputs, outputs)为什么不直接用 one-hot?词表如果是 400,one-hot 每个音符变成 400 维向量,且任意两个音符的距离都一样,模型学不到“C4 和 C#4 相邻、C4 和 C7 差很远”这种音高关系。Embedding 把每个音符映射成一个可学习的 64 维向量,相似的音符在向量空间里位置也更接近,等于给模型内置了一套音高相似度先验。
LSTM 的 hidden_dim=256 是隐藏状态维度,决定模型“记住旋律”的容量,对几十上百首 MIDI 训练足够了;训练集上千首可以加到 512。最后 Dense 层从 256 维隐藏状态映射到词表大小的概率分布,每个值表示“下一个音符是词表里第 i 个 token 的概率”。这条链路整体就是:整数 token → Embedding 向量 → LSTM 上下文状态 → 全连接输出概率。不用 attention 不是因为 attention 不好,而是对音乐生成课程设计这个数据规模,它带来的收益被实现复杂度抵消了,LSTM 是性价比最稳的选择。
2.4 拿到源码包后先核对的文件结构
下载包解压后不要急着跑,先列一下目录,确认关键文件都在。
ls -lh # 期望看到 train.py、generate.py、midi_utils.py # checkpoint/ 目录下是 ckpt_10.ckpt.index、ckpt_10.ckpt.data-00000-of-00001 这类文件 # 也可能直接叫 ckpt_10.data-00000-of-00001checkpoint 命名里的数字是保存时的 epoch 轮次。项目里同时存在 ckpt_3、ckpt_9、ckpt_10、ckpt_17、ckpt_28、ckpt_36、ckpt_41、ckpt_48,说明作者训练时几乎每轮都在保存权重。这些文件必须目录配套完整,单独拷一个 data 文件过去是加载不了的。确认完文件,再进训练或生成流程。
3. 训练与断点续训:checkpoint 怎么选、怎么加载
理解了预处理和模型结构,训练这一步已经没什么神秘的了。这里要重点讲两件事:训练循环里的数据怎么流动,以及那一堆 checkpoint 到底该用哪个。
3.1 训练循环:输入、目标、loss 怎么算
训练数据已经切成 (X, Y) 对:X 是 (batch, 32) 的整数序列,Y 是对应的下一个音符 token。目标不是 one-hot 向量,所以损失函数用sparse_categorical_crossentropy,它内部会先做 one-hot 再计算交叉熵。
from tensorflow.keras.losses import SparseCategoricalCrossentropy from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import ModelCheckpoint model = build_rnn_model(vocab_size=len(vocab), seq_len=32) model.compile( optimizer=Adam(learning_rate=1e-3), loss=SparseCategoricalCrossentropy(), metrics=['accuracy'] ) history = model.fit( X_train, Y_train, batch_size=64, epochs=50, validation_data=(X_val, Y_val), callbacks=[ ModelCheckpoint( filepath='checkpoint/ckpt_{epoch:02d}.ckpt', save_weights_only=True ) ] )ModelCheckpoint 默认每个 epoch 保存一次,所以下载包里才有 ckpt_3、ckpt_9、ckpt_17 这种按轮次递增的文件。batch_size=64 表示每批 64 个长度为 32 的样本,显存不够就减半。Adam 的 learning_rate=1e-3 是通用起始值,如果 loss 震荡剧烈,降到 5e-4 再试。
训练集只有十来首 MIDI 的话,50 个 epoch 足够把 loss 压到很低,但这时候大概率已经过拟合——模型把训练集旋律背下来了,生成时只会复读那几首曲子。判断标准不是训练 loss,而是生成出来听。所以下载包里放这么多 checkpoint 不是装饰,是为了让你在不同训练阶段之间切换生成效果。
提示:模型每轮保存一次,50 轮就是 50 个 ckpt 文件,磁盘占用不小。项目里只保留了 3、9、10、17、28、36、41、48 这些轮次,说明作者训练后手动清了一轮,保留典型阶段。你也可以这么做,省空间还方便对比。
3.2 checkpoint 不是越往后越好
以这个项目的文件为线索,各轮次大致有这样的听感规律:
- ckpt_3:训练刚起步,基本在乱弹,旋律碎片化,只有零散的音程关系,没有结构。
- ckpt_17 附近:开始能听出乐句重复,但句子之间连接生硬,有时候会突然跳到一个完全不搭的音。
- ckpt_28 前后:结构最完整,旋律重复度和变化量通常比较平衡,是交作业的首选。
- ckpt_36、ckpt_41 往后:生成结果容易复读训练集片段,风险是过拟合,loss 低但音乐性反而下降。
这其实是 RNN 音乐生成的经典现象:loss 越低,模型越“确定”,生成样本的熵越低,旋律越容易陷入反复循环。所以选 checkpoint 的原则是“以听感为准,不以 loss 为准”。我自己的固定习惯是每 5 个 epoch 生成一段 20 秒旋律存成 MIDI,全部生成完毕后再按听感回选。这个习惯帮我省下了大量反复重训的时间。
3.3 加载已有 checkpoint 生成第一首旋律
生成过程叫自回归采样:给模型一个种子序列,它预测下一个音符,把预测结果接到序列末尾,再拿新序列预测下一个。
model.load_weights('checkpoint/ckpt_28.ckpt') seed = X_val[:1] # 取一段真实音符序列当“引子” generated = list(seed[0]) for _ in range(200): input_seq = np.array([generated[-32:]], dtype=np.int32) pred = model.predict(input_seq, verbose=0)[0] next_tok = sample_with_temperature(pred, temperature=1.0) # 具体实现在 4.2 generated.append(next_tok)load_weights加载的是权重文件,模型结构必须和训练时完全一致,否则报错。种子序列的质量直接决定全曲走向,所以一般从训练集开头或中间乐段截取,而不是随机生成一串音符当种子。200 次循环大约生成 200 个音符,足够听出旋律走向。这里的sample_with_temperature是生成阶段的核心函数,下一章展开讲。
4. 采样不再是玄学:temperature、top-k 与 MIDI 导出的完整链路
模型训练完,生成阶段真正影响听感的不是模型本身,而是采样策略。这一章把 temperature 和 top-k 讲透,让你知道每个参数在手感上对应什么效果。
4.1 temperature 到底改的是啥
模型最后输出的是下一个音符的概率分布。如果每次都直接选概率最大的那个(argmax),生成的旋律会非常“确定”,但序列是一步步滚动的,任何一个小概率偏差都会累积,结果就是前 8 个音符正常,后面开始无限重复同一个音型。temperature 的作用是把概率分布“压扁”或“变尖”,它不改模型权重,只改采样时的概率计算。
| temperature | 效果 | 适用场景 |
|---|---|---|
| 0.4 ~ 0.6 | 旋律稳定但容易循环复读 | 快速听模型学到了什么 |
| 0.8 ~ 1.2 | 旋律有变化又不至于乱 | 默认生成、作品雏形 |
| 1.3 以上 | 随机性强、结构松散 | 找灵感、做数据增强 |
我在项目里常用的起点是 T=1.0。低于 0.6 的生成结果基本是灾难,听起来就像留声机卡带;高于 1.3 则完全是随机弹,乐句感消失。这个参数只影响生成阶段,所以你可以不用重训模型,直接拿同一个 checkpoint 反复调。
4.2 采样函数实现:temperature 和 top-k 配合
只调 temperature 还不够,还要加 top-k 截断,防止那些概率很低却很刺耳的音符突然冒出来。下面这个采样函数是两个机制的完整实现:
def sample_with_temperature(logits, temperature=1.0, top_k=5): """从模型输出的 logits 中采样一个 token。 temperature 控制分布尖锐程度,top_k 限制候选范围。""" logits = logits / temperature # 大于 1 压扁分布,小于 1 变尖 probs = tf.nn.softmax(logits) if top_k > 0: # 只保留概率最高的 k 个候选,其余置零后重新归一化 top_k_probs, _ = tf.math.top_k(probs, k=top_k) mask = probs < top_k_probs[-1] probs = tf.where(mask, tf.zeros_like(probs), probs) probs = probs / tf.reduce_sum(probs) return tf.random.categorical(tf.math.log(probs)[None, :], 1)[0, 0].numpy()temperature 和 top_k 是两个独立的控制旋钮:temperature 控制多样性,top_k 控制安全区大小。配合使用时,我一般 T=1.0、top_k=5 起步。如果旋律还是太平庸,把 top_k 调到 8~10,让模型有更多候选音符可选;如果出现莫名其妙的刺耳音,说明 top_k 太大或 temperature 太高,把 temperature 降到 0.9 看看。要注意tf.random.categorical接收的是 log 概率,所以传入前先对 probs 取 log,这是个容易写错的细节。
4.3 把 token 序列还原成 MIDI 文件
生成结果是整数 token 序列,必须反向映射成 (pitch, duration) 才能写 MIDI。这里最常见的错误是忘记把 duration 的 tick 单位换算回秒,导致导出文件要么没声音,要么时长全是几十上百的乱码。
import pretty_midi def tokens_to_midi(tokens, inverse_vocab, fs=100, output='generated.mid'): """token 序列转 MIDI。 fs 必须和预处理时的 fs 一致,否则时长全错。""" midi = pretty_midi.PrettyMIDI() inst = pretty_midi.Instrument(program=0) # 0 号音色是钢琴 current_time = 0.0 for token in tokens: pitch, dur_ticks = inverse_vocab[token] dur_sec = dur_ticks / fs # tick -> 秒 if dur_sec <= 0: continue inst.notes.append(pretty_midi.Note( velocity=100, pitch=pitch, start=current_time, end=current_time + dur_sec )) current_time += dur_sec midi.instruments.append(inst) midi.write(output)velocity=100 是固定力度,想要更自然可以按乐句起伏随机调整,比如前 8 小节用 90,后面用 110。program=0 是钢琴音色,导出后用任意播放器试听。如果打完编排后觉得“生硬”,第一步永远先检查 duration 有没有正确还原,这个坑在避坑章会重点说。
5. 避坑指南:RNN 音乐生成最常见的五个翻车点
训练和生成跑通不难,但中间有几个坑属于“不看血泪经验就一定会踩”的类型。下面按现象、原因、解决的顺序写清楚。
5.1 loss 卡在平台期彻底不降
现象:训练到第 20 轮后 loss 从 2.0 降到 1.1 左右,之后无论怎么调学习率都不动。
原因:大概率是模型容量不够,或者序列窗口太短导致上下文不足;还有可能是词表太大、训练数据太少,模型学不到规律。
解决:先把 hidden_dim 从 256 提到 512 做短实验;同时检查 seq_len,32 的效果一般比 16 明显更好。两项都没效果就扩充数据集,多找几十首旋律风格接近的 MIDI,比盲目加轮数有用得多。我一般会在 10 个 epoch 内先做小实验,loss 不降就先止损,别让电脑通宵空转。
5.2 生成的旋律几十个音符后开始无限重复
现象:生成到第 30~50 个音符,旋律进入死循环,反复弹同一个音型或者同一个音。
原因:temperature 太低或 top_k 太小,概率分布被压缩到单个峰上;模型自身也可能过拟合了训练集中的高频片段。
解决:先把 temperature 拉到 1.0 以上,top_k 放到 8 左右,看循环是否缓解。如果还是循环,回到更早的 checkpoint,比如把 ckpt_48 换成 ckpt_28 重新生成。这个操作不花多少时间,但很多人不知道直接换检查点就是最有效的干预手段。
5.3 加载 checkpoint 报维度不匹配
现象:load_weights 时直接报 shape mismatch,或者 key 对不上。
原因:常见两种。一是用 PyTorch 的 load 去读 TensorFlow 的.ckpt.index、.ckpt.data-00000-of-00001文件,跨框架硬来;二是改过模型结构(比如 hidden_dim 从 256 改成 512)后,仍然加载旧 checkpoint。
解决:先确认框架一致,TensorFlow 的 checkpoint 必须用 TensorFlow 的 load_weights。再确认模型结构参数和原训练时完全一致,改一个数字都不行。如果确实想换框架,得先把权重导出成通用格式(比如 numpy),再按新框架的键名重建,课程设计阶段不建议做这个工作量。
5.4 导出 MIDI 后播放器没声音或时长全乱
现象:文件能打开,但要么一点声音都没有,要么音符时长全是一拍半拍混乱。
原因:duration 在预处理时被量化成 tick,导出时忘了除以 fs 换算成秒,所有 dur 都是几十几百的“大数字”;或者词表把时长为 0 的音符也保留了,导出了一批零时长音符。
解决:导出前先打印几个 token 的 inverse_vocab 结果,确认 dur_ticks 和 pitch 都在合理范围,pitch 应在 21~108,dur_ticks 换算成秒后应在 0.05~4 之间。我现在的固定做法是在 tokens_to_midi 函数开头加 assert 检查,把问题挡在生成之前。
5.5 Python 环境和 TensorFlow 版本互相打架
现象:按照一些所谓的“一条龙 python 安装教程”装完,import tensorflow 就报 DLL load failed 或者 cuDNN 初始化失败。
原因:TensorFlow 2.10 之后 Windows 原生支持的最高 Python 版本是 3.11,如果装了 Python 3.12 再倒推装旧版 TF,版本识别会出各种奇怪问题;CUDA 和 cuDNN 版本对不上也是常见诱因。
解决:项目部署前先锁定一组已验证的环境组合。我用得最多的是 Python 3.9 + TensorFlow 2.10 + CUDA 11.8,兼容性最稳。如果只是跑课程设计,不追求 GPU 加速,直接pip install tensorflow-cpu把流程先跑通,再考虑显卡的事。第一次跑项目不要一上来就搞 GPU 版,CPU 版能把开发环境变量的问题隔离掉一大半。
6. 让生成结果更像音乐:三个可以直接抄的采样技巧
模型能跑通只是第一步,交作业时真正拉开分数的是生成结果的音乐性。下面三个技巧都是从采样层面优化的,不动训练流程,几分钟就能看到效果。
第一个技巧是温度扫描。固定同一段种子,把 temperature 从 0.6 到 1.4 每隔 0.1 生成一段 20 秒旋律,存成 9 个 MIDI 文件,一分钟内听完就能确定这个 checkpoint 的“甜点区间”。我每次拿到一个新 checkpoint 都先做这一步,比反复改代码试错快得多,也算是个人比较偷懒但有效的习惯。
第二个技巧是给音程跳变加惩罚。人耳对超过一个八度的突然跳变很敏感,容易觉得刺耳。在采样时对超过 12 个半音的跳变乘一个小于 1 的系数,旋律立刻顺耳很多:
def sample_with_interval_penalty(logits, prev_pitch, temperature=1.0, penalty=0.85): """限制大跨度音程跳变的采样函数。 penalty 越小,大跨度跳变被压制得越狠。""" logits = logits / temperature probs = tf.nn.softmax(logits) for tok, p in enumerate(probs.numpy()): pitch, _ = inverse_vocab[tok] interval = abs(pitch - prev_pitch) if interval > 12: probs[tok] *= penalty # 超过一个八度的跳变被降权 probs = probs / tf.reduce_sum(probs) return tf.random.categorical(tf.math.log(probs)[None, :], 1)[0, 0].numpy()penalty=0.85 表示大跳变概率每次衰减 15%,想更自由就调到 0.95,想更平缓就调到 0.7。这里注意,probabilities里每一项都对应词表中的一个 token,而 token 反查回来是 (pitch, duration),所以能拿到具体音高算音程。这个系数只影响采样,不影响训练,可以放心调。
第三个技巧是手动垫低音轨。RNN 出来的是一条主旋律线,直接听是“单旋律哼唱”。每 2 个小节取当前旋律的最低音作为根音,生成一个四分音符时长的低音音符,单独写成第二轨,层次感立刻不一样。不需要懂太多乐理,光是根音铺垫就能让作品从“单声道 demo”变成“有伴奏的编曲”。
从做课程设计的经验看,真正拉开分数的不是模型复杂度,而是你愿不愿意在采样的细节上多花十分钟。从那以后,我每次拿到新的音乐生成项目,第一件事都是先做一遍温度扫描、把各个 checkpoint 轮流生成一小段 MIDI 记录听感,再决定用哪个。这个习惯帮我绕开了无数次“训练完发现生成一团糟”的返工,也让我对采样参数不再靠猜。希望帮到你。
本文还有配套的精品资源,点击获取