RNN音乐生成实战:从LSTM建模到temperature采样调优
简介基于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而不是 CNNRNN 循环神经网络处理的是序列依赖关系。音乐的“意义”在上下文里一个音高是否顺耳取决于它前面几个音符的走向甚至更远小节的旋律呼应。CNN 擅长在空间上做局部模式提取拿来处理音符序列不是不行但感受野受限对长距离的旋律呼应处理起来很别扭。RNN 天然按时间步展开每看到一个音符就把信息压缩到隐藏状态里传递下去这跟音乐“读到哪想到哪”的过程一致。LSTM 加了三道门本质是给循环单元增加了一个可学习的记忆通道。遗忘门决定保留多少过去的旋律信息输入门决定写入多少当前音符输出门决定传递多少给下一步。这样 16 小节前的主题旋律还能通过网络传到当前时刻不至于被后续音符“冲淡”。这个项目把 LSTM 当主力单元是合理的你代码里把 LSTM 换成 GRU 也行但注意 checkpoint 不通用——换结构就必须重新从零训练这一点在避坑章会细说。2.2 预处理把 MIDI 变成 token 序列第一步是读 MIDI 文件提取每个音符的起始时间、结束时间和音高。项目里常见做法是用pretty_midi接口比mido友好得多。时间分辨率用 fs 表示单位是 tick/秒fs100 表示每 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, fs100): 把 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_len32): 把 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_len32 是第二个关键超参数它决定“模型看多远”。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_dim64, hidden_dim256): 标准 RNN 音乐生成模型 Embedding - LSTM - Dense(softmax) inputs layers.Input(shape(seq_len,), dtypetf.int32) # 形状 (batch, 32) x layers.Embedding(vocab_size, embed_dim)(inputs) # (batch, 32, 64) x layers.LSTM(hidden_dim, return_sequencesFalse)(x) # (batch, 256) outputs layers.Dense(vocab_size, activationsoftmax)(x) return models.Model(inputs, outputs)为什么不直接用 one-hot词表如果是 400one-hot 每个音符变成 400 维向量且任意两个音符的距离都一样模型学不到“C4 和 C#4 相邻、C4 和 C7 差很远”这种音高关系。Embedding 把每个音符映射成一个可学习的 64 维向量相似的音符在向量空间里位置也更接近等于给模型内置了一套音高相似度先验。LSTM 的 hidden_dim256 是隐藏状态维度决定模型“记住旋律”的容量对几十上百首 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_sizelen(vocab), seq_len32) model.compile( optimizerAdam(learning_rate1e-3), lossSparseCategoricalCrossentropy(), metrics[accuracy] ) history model.fit( X_train, Y_train, batch_size64, epochs50, validation_data(X_val, Y_val), callbacks[ ModelCheckpoint( filepathcheckpoint/ckpt_{epoch:02d}.ckpt, save_weights_onlyTrue ) ] )ModelCheckpoint 默认每个 epoch 保存一次所以下载包里才有 ckpt_3、ckpt_9、ckpt_17 这种按轮次递增的文件。batch_size64 表示每批 64 个长度为 32 的样本显存不够就减半。Adam 的 learning_rate1e-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:]], dtypenp.int32) pred model.predict(input_seq, verbose0)[0] next_tok sample_with_temperature(pred, temperature1.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 以上随机性强、结构松散找灵感、做数据增强我在项目里常用的起点是 T1.0。低于 0.6 的生成结果基本是灾难听起来就像留声机卡带高于 1.3 则完全是随机弹乐句感消失。这个参数只影响生成阶段所以你可以不用重训模型直接拿同一个 checkpoint 反复调。4.2 采样函数实现temperature 和 top-k 配合只调 temperature 还不够还要加 top-k 截断防止那些概率很低却很刺耳的音符突然冒出来。下面这个采样函数是两个机制的完整实现def sample_with_temperature(logits, temperature1.0, top_k5): 从模型输出的 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, ktop_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 控制安全区大小。配合使用时我一般 T1.0、top_k5 起步。如果旋律还是太平庸把 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, fs100, outputgenerated.mid): token 序列转 MIDI。 fs 必须和预处理时的 fs 一致否则时长全错。 midi pretty_midi.PrettyMIDI() inst pretty_midi.Instrument(program0) # 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( velocity100, pitchpitch, startcurrent_time, endcurrent_time dur_sec )) current_time dur_sec midi.instruments.append(inst) midi.write(output)velocity100 是固定力度想要更自然可以按乐句起伏随机调整比如前 8 小节用 90后面用 110。program0 是钢琴音色导出后用任意播放器试听。如果打完编排后觉得“生硬”第一步永远先检查 duration 有没有正确还原这个坑在避坑章会重点说。5. 避坑指南RNN 音乐生成最常见的五个翻车点训练和生成跑通不难但中间有几个坑属于“不看血泪经验就一定会踩”的类型。下面按现象、原因、解决的顺序写清楚。5.1 loss 卡在平台期彻底不降现象训练到第 20 轮后 loss 从 2.0 降到 1.1 左右之后无论怎么调学习率都不动。原因大概率是模型容量不够或者序列窗口太短导致上下文不足还有可能是词表太大、训练数据太少模型学不到规律。解决先把 hidden_dim 从 256 提到 512 做短实验同时检查 seq_len32 的效果一般比 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~108dur_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, temperature1.0, penalty0.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()penalty0.85 表示大跳变概率每次衰减 15%想更自由就调到 0.95想更平缓就调到 0.7。这里注意probabilities里每一项都对应词表中的一个 token而 token 反查回来是 (pitch, duration)所以能拿到具体音高算音程。这个系数只影响采样不影响训练可以放心调。第三个技巧是手动垫低音轨。RNN 出来的是一条主旋律线直接听是“单旋律哼唱”。每 2 个小节取当前旋律的最低音作为根音生成一个四分音符时长的低音音符单独写成第二轨层次感立刻不一样。不需要懂太多乐理光是根音铺垫就能让作品从“单声道 demo”变成“有伴奏的编曲”。从做课程设计的经验看真正拉开分数的不是模型复杂度而是你愿不愿意在采样的细节上多花十分钟。从那以后我每次拿到新的音乐生成项目第一件事都是先做一遍温度扫描、把各个 checkpoint 轮流生成一小段 MIDI 记录听感再决定用哪个。这个习惯帮我绕开了无数次“训练完发现生成一团糟”的返工也让我对采样参数不再靠猜。希望帮到你。本文还有配套的精品资源点击获取