用Python和RNN-LSTM实现MIDI旋律生成:从数据预处理到温度采样
简介这套基于RNN-LSTM的旋律生成课程设计项目面向机器学习、人工智能及相关专业的在校学生和开发者解决音乐旋律自动生成从数据准备到模型落地的完整实践需求。压缩包共2000个文件包含300余个krn格式音乐数据、Python源码、XML配置、预训练模型以及生成的旋律片段整体仅11.7MB目录规划清晰下设musicset音乐库、dataset处理后的数据集、models模型仓库和generated_musics生成结果便于按阶段对照学习与研究。目前已有186人学习下载。项目附有文档说明与README代码均经过测试运行成功评审均分达96分可作为课程设计、毕业设计或项目初期演示的高分参考还能在现有代码基础上调整LSTM参数、更换训练数据进一步探索不同风格旋律的生成效果。1. 这不仅仅是一次课设RNN-LSTM 如何把音符变成可预测的序列大多数人在机器学习课设里拿到旋律生成题目时第一反应是“让模型学会作曲”。但如果只把 MIDI 文件直接丢给神经网络结果往往是一堆噪音。这个基于 RNN-LSTM 的旋律生成项目实际上是先解决了“如何把音乐变成机器学习能理解的序列”这一关键问题然后用循环神经网络对相邻音符之间的时序依赖建模最后通过采样策略输出新的旋律。仓库里的 musicset 是原始音乐库dataset 是清洗后的训练集models 保存训练好的权值generated_musics 是生成结果。整个流程不依赖外部音乐生成库核心代码用 Python 加深度学习框架即可跑通。适合正在做机器学习课程设计、或者想从零理解 LSTM 在时间序列上如何工作的读者。2. 为什么旋律生成必须用 RNN-LSTM时序建模与数据预处理2.1 从音符序列到训练样本先解决“模型的输入是什么”旋律本质上是一个时间序列每个音符的出现都受前面音符的节奏、音高和和声倾向影响。普通的前馈神经网络不接受变长序列也天然没有“记忆”能力。RNN 的出现就是为了处理这种带先后顺序的数据而 LSTM 进一步解决了 RNN 在长序列上梯度消失的问题能记住几十步之前的音符信息。在旋律生成场景中一个小节通常有 16 个时值单位一首歌的旋律长度往往在 100 到 500 个音符之间LSTM 的记忆细胞正好匹配这种中等长度的依赖关系。项目的第一个核心是把 MIDI 文件里的原始事件转换成模型可以吃的整数序列。我见过不少同学直接把 midi 文件路径作为输入这样模型根本无法训练。正确做法是提取音符的 pitch音高、step时间步数和 duration时值并转为字典映射或整数序列。下面是项目中典型的数据预处理代码常见做法是用mido或music21解析 MIDIimport mido import numpy as np from collections import defaultdict def midi_to_notes(midi_file): mid mido.MidiFile(midi_file) notes [] current_time 0 pitch_map defaultdict(int) # 记录每个音高出现的次数用于后续过滤 for track in mid.tracks: for msg in track: current_time msg.time if msg.type note_on and msg.velocity 0: # 只保留低音轨或主旋律轨这里按实际情况过滤 if msg.channel in (0, 3): notes.append({ pitch: msg.note, step: current_time, duration: msg.time }) return notes这段代码的核心逻辑是遍历 MIDI 的所有轨和消息遇到note_on事件且力度大于 0 时记录一次发音。current_time累积了绝对时间msg.time是相对时间增量这样可以用绝对时间对齐多个轨道。过滤通道的if条件是关键真实 MIDI 中鼓轨、伴奏轨会干扰旋律学习通常只保留主旋律通道。得到原始音符后还需要把连续的音符切成长度固定的输入序列。比如设定SEQ_LEN 32表示用前 32 个音符预测第 33 个音符。这一步决定了 LSTM 能看到多长的历史def create_sequences(notes, seq_len32): pitches [n[pitch] for n in notes] steps [n[step] for n in notes] durations [n[duration] for n in notes] sequences [] targets [] for i in range(len(notes) - seq_len): # 输入pitch 序列输出下一个 pitch seq_in pitches[i:i seq_len] target pitches[i seq_len] sequences.append(seq_in) targets.append(target) return np.array(sequences), np.array(targets)这里把浮点数时值和音高分成三个独立序列会更好。有些课程设计为了简化只预测 pitch忽略 duration生成的旋律节奏会很乱。所以在dataset构建时建议至少把 pitch 和 duration 一起建模。实际项目中数据量不大时可以将 duration 量化为几个离散值比如 0.25、0.5、1.0 拍这样模型的任务就从“回归浮点数”变成了“分类到几个时值选项”训练更稳定。2.2 数据增强与词汇表构建让每一个音符都可被编码模型不能直接吃原始音高数字因为音高数值本身不代表“越大越重要”。LSTM 的输入通常先经过 Embedding 层把每个离散音符 ID 映射到连续向量。构建词汇表时要统计训练集中所有出现过的音符和时值建立pitch_to_int和int_to_pitch的映射。建议过滤掉出现次数少于 5 次的音符避免模型去记忆不常见的噪声音。参数/组件推荐值说明输入序列长度 SEQ_LEN32 或 64对应 2 到 4 个小节太长模型难以收敛词汇表大小128 左右覆盖 MIDI 中常用音高范围21-108Embedding 维度128音高向量维度小数据 64 即可LSTM 层数2单层容易欠拟合三层以上过拟合风险大Dropout0.3加在 LSTM 层之间缓解过拟合Batch Size64过小 loss 震荡过大大显存在真正训练前还需要把音符序列转成 LSTM 需要的三维输入格式(batch, seq_len, features)。如果用 Embedding 层输入就是(batch, seq_len)的整数索引。如果不用 Embedding 而直接用 one-hot那么特征维度等于词汇表大小当词汇表为 128 时输入变成(batch, seq_len, 128)计算量明显增大。所以几乎所有的旋律生成项目都会选择 Embedding这也是为什么代码里绝大部分用的是tf.keras.layers.Embedding或torch.nn.Embedding。3. Python 实现从零搭建 RNN-LSTM 旋律生成模型3.1 网络结构Embedding LSTM Dense模型结构并不复杂但每一层都有明确作用。Embedding 层把音符 ID 映射成稠密向量LSTM 层学习时间依赖Dense 层输出下一个音符的概率分布。下面用 Keras 写一个最常见版本保证代码可复现import tensorflow as tf from tensorflow.keras import layers, models def build_model(vocab_size, seq_len, embedding_dim128): model models.Sequential([ layers.Embedding(input_dimvocab_size, output_dimembedding_dim, input_lengthseq_len), layers.LSTM(units256, return_sequencesTrue, dropout0.3), layers.LSTM(units256, dropout0.3), layers.Dense(units128, activationrelu), layers.Dense(unitsvocab_size, activationsoftmax) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losssparse_categorical_crossentropy, metrics[accuracy] ) return model model build_model(vocab_size128, seq_len32) model.summary()第一层 LSTM 设置return_sequencesTrue是为了让第二层 LSTM 接收到完整的隐藏状态序列而不是最后一个时间步的输出。如果只有一层 LSTM这个参数可以不设。第二个 LSTM 后接全连接层最终 softmax 输出在 128 个音高上的概率分布。注意损失函数使用sparse_categorical_crossentropy因为 targets 是整数索引不是 one-hot 编码。训练时我习惯加一个 EarlyStopping 回调监控验证集 loss防止无效训练浪费时间。项目源码里可能没有现成回调可以自己补上from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) checkpoint ModelCheckpoint(models/best_weights.h5, monitorval_loss, save_best_onlyTrue) history model.fit( train_pitches, train_targets, validation_data(val_pitches, val_targets), epochs200, batch_size64, callbacks[early_stop, checkpoint] )3.2 训练参数与数据划分数据划分上不能随机打乱后直接切分。旋律是序列数据打乱会破坏时间顺序导致验证集“看到”训练集后面的内容造成信息泄漏。正确做法是按歌曲维度划分比如把 80% 的 MIDI 文件作为训练集20% 作为验证集。每条旋律内部保持原有顺序。Epoch 数量设置多少小数据集通常训练 50 到 100 个 epoch 就会过拟合loss 下降很快但生成旋律开始重复。我常用的判断标准是验证集 accuracy 达到 70% 到 80%loss 保持平稳后就可以停止。不要追求 100% 准确率音乐本身有随机性过高的准确率往往意味着模型记住了训练数据而不是学会了生成规律。损失函数的选择上sparse categorical crossentropy 适合整数标签不需要把标签变为 one-hot。如果你发现训练 loss 下降缓慢可以检查学习率。1e-3对 LSTM 通常是合适的但数据量很小的时候5e-4会更稳。每一步 loss 波动大时先降低学习率不要一上来就换模型结构。4. 旋律生成实战温度采样与滑动窗口4.1 为什么不直接取 argmax训练完成后直接取 softmax 概率最大的音高会得到一条“最呆”的旋律每个音符都是当前条件下概率最高的那个结果往往是重复一个音或连续走同一个小音程。因为训练集里常见音符组合出现频率高argmax 会让模型总是选择高频路径。要生成有变化又不完全崩坏的旋律需要对概率分布做温度采样。温度参数temperature用来控制随机性。温度低分布变得尖锐输出更保守温度高分布趋于均匀输出更随机。常见做法是将 logits 除以温度后再做 softmaximport numpy as np def sample_with_temperature(predictions, temperature1.0): predictions np.asarray(predictions).astype(float64) # 防止 log 0加一个小值 predictions np.clip(predictions, 1e-8, 1.0) logits np.log(predictions) / temperature exp_logits np.exp(logits) probs exp_logits / np.sum(exp_logits) # 按概率采样而不是取最大值 return np.random.choice(len(probs), pprobs)温度大于 1 时logits / temperature让原本差异大的概率差距缩小随机性增强温度小于 1 时概率差距被放大输出更确定。0.8 到 1.2 是旋律生成比较常用的范围。低于 0.5 基本等于 argmax高于 1.5 就会开始产生不和谐音程。4.2 滑动窗口生成完整旋律生成一段旋律时初始输入可以是训练集中随机抽取的一段长度为 SEQ_LEN 的序列或者直接用全零填充对应休止符。然后进入循环把当前序列输入模型得到下一个音符的概率分布经过温度采样选出一个音符将这个音符追加到序列末尾同时丢弃序列最开头的音符保持窗口长度不变。下面这段代码就是完整的生成循环def generate_melody(model, start_sequence, num_notes64, temperature1.0): current_seq start_sequence.copy() generated [] for _ in range(num_notes): # 增加 batch 维度并转为 int 类型 input_seq np.array(current_seq).reshape(1, -1) predictions model.predict(input_seq, verbose0)[0] next_note sample_with_temperature(predictions, temperature) generated.append(next_note) # 滑动窗口追加新音符移除最旧的 current_seq.append(next_note) current_seq current_seq[1:] return generatedmodel.predict返回的是形状(1, vocab_size)的概率向量取[0]把它变成一维。采样函数返回整数索引这个索引对应词汇表中的音高 ID最后需要通过int_to_pitch映射回真实的 MIDI 音高再转换成音符对象。这里最容易出错的是忘记将current_seq的元素类型保持一致如果 start_sequence 是 numpy 数组追加 Python int 后类型会混合建议统一用 List[int]。生成的音符序列保存为 MIDI 文件时要注意每条音符的 step 和 duration。如果模型只预测 pitch那么 step 可以设为一个固定时间间隔比如 0.5 拍duration 也设为 0.5 拍。这样生成的旋律只有音高变化没有节奏变化听起来像机器在演奏八分音符。如果想有节奏变化就要把模型输出扩展为同时预测 pitch 和 duration 两个任务。4.3 让生成结果更音乐化的两个技巧第一建议加入重复惩罚。在采样逻辑中如果某个音符刚刚出现过就把它的概率乘一个小于 1 的惩罚系数。这个简单的修改能明显减少旋律中同一个音连续出现三到四次的情况。def sample_with_repetition_penalty(predictions, temperature, penalty0.9, recentNone): if recent: for note in set(recent): predictions[note] * penalty # 重新归一化 predictions predictions / np.sum(predictions) return sample_with_temperature(predictions, temperature)第二生成结束后做个简单的音高统计。如果生成旋律的音高范围小于 6 个半音说明模型陷入了局部重复此时提高温度或换一个起始序列重新生成。这个验证方法非常实用不用听就能大概判断旋律是否有变化。5. 排错与模型调优从 loss 不降到旋律难听5.1 loss 不降或梯度爆炸的处理LSTM 训练中常见的问题是 loss 在一个不小的值附近震荡不上不下。先看数据是否归一化干净如果音符序列里混入了一些异常的 time 值或超范围 pitch比如 0 和 127模型很难学习。其次检查梯度如果 loss 突然变成 NaN大概率是梯度爆炸。解决办法是梯度裁剪optimizer tf.keras.optimizers.Adam(learning_rate1e-3, clipnorm1.0) model.compile(optimizeroptimizer, losssparse_categorical_crossentropy)clipnorm1.0表示当梯度的 L2 范数超过 1 时按比例缩放。这个技巧在训练 RNN 时几乎是标配很多课程设计为了简单会忽略导致训练几个 epoch 后 loss 变成 NaN。对 LSTM 来说梯度爆炸比梯度消失更容易遇到。过拟合的典型表现是训练 loss 持续下降验证 loss 上升。这时不仅需要 Dropout还要减小模型容量。把 LSTM 单元从 256 降到 128或者只保留一层 LSTM往往比增加正则化更有效。另一个容易被忽视的维度是SEQ_LEN如果输入序列长度从 32 增加到 128数据量不变但特征维度变大过拟合概率也随之上升。5.2 生成旋律的验证方法不能只听“好听”或“难听”那太主观。我一般从三个指标判断生成质量指标验证方式合格标准音高分布统计生成音符的直方图与训练集对比两者的分布形状相似没有大量未出现的音高音符重复率计算相邻 4 个音符中重复的比例重复占比低于 20%自相关性计算生成旋律与训练集旋律的平均编辑距离距离适中不是简单复制也不是完全混乱最后一个技巧是在生成多个旋律后选一个“种子序列”固定随机种子用不同的 temperature 值各生成一遍。这样能快速观察温度对结果的影响也能帮你确定这个模型最适合的温度区间。每次生成的旋律保存为独立 MIDI 文件放进generated_musics目录方便一边调参一边对比。这套流程跑通后换任何 MIDI 数据集只需要改词汇表和序列长度就能重新训练出属于你的旋律生成器。本文还有配套的精品资源点击获取