LSTM作曲实战:从MIDI解析到AI生成旋律的完整流水线
发布时间:2026/10/1 13:14:55来源:尧图网络
简介基于LSTM的音乐生成器Python项目面向对深度学习与音乐创作交叉领域感兴趣的开发者以及想入门序列建模的机器学习学习者。项目涵盖从MIDI数据预处理、LSTM模型构建到训练与生成完整流程可帮助读者快速上手用循环神经网络创作旋律。压缩包共56个文件以46个mid格式音乐样本为主辅以4个Python脚本含训练、生成、工具与网络定义、5张网络结构与乐理说明图及1份说明文档整体大小约723KB目录结构清晰便于对应学习。目前已有181人学习下载。通过该项目可系统了解音乐数据如何转化为模型输入、LSTM如何捕捉音符间时序依赖还能直接运行完整源码复现训练并生成新的MIDI旋律同时借助可视化图表辅助理解网络结构是音乐生成实践与深度学习入门相结合的高性价比参考。1. 用 LSTM 写曲子这项目不是玩具是能跑的 AI 作曲流水线把几十个 MIDI 文件扔给 LSTM让它自己学会写旋律听起来像玄学但这套 ai_music-master 项目确实把整条流水线跑通了。它用 Python 把 MIDI 音符序列化成训练样本搭一个 LSTM 网络学习音符之间的时序依赖训练完再用采样方式生成全新的 output.mid。核心逻辑不难读 MIDI → 解析成音符序列 → 滑窗切片 → 训练分类器预测下一个音符→ 反向还原成 MIDI。对想入门深度学习序列建模、又不想只跑 MNIST 的开发者来说这是很合适的练手素材。对熟手而言这个项目也能当基线拿来改造成和弦生成、旋律风格迁移都顺手。下面我直接把预处理、模型、训练、生成和踩坑记录拆开讲照着做就能跑通。2. 把 MIDI 变成矩阵训练前必须搞定的音符序列化很多人卡在第一步MIDI 文件又不是图片怎么喂给神经网络答案是把 MIDI 事件流转成离散音符序列。这个项目里 MIDI 是唯一的数据来源所以预处理脚本的质量直接决定模型能不能学到东西。这章我会把音符提取、映射表构建、滑窗采样三件事讲透。2.1 音符在 MIDI 里是怎么被机器记住的note_on、note_off 与时长MIDI 文件本质是一串带时间戳的事件不是一个音频波形。最常见的两个事件是note_on按下某个键和note_off松开某个键每个事件都带着音符编号note比如 60 代表中央 C和力度velocity。一个音符的持续时间就是同一个键的note_on到note_off之间经过的时间差。我用mido库做解析这是 Python 生态里比较轻量的 MIDI 处理库。下面这段代码做的事情是遍历所有音轨把note_on和note_off配对得到(pitch, velocity, duration)三元组序列。import mido from mido import MidiFile def midi_to_notes(midi_path): mid MidiFile(midi_path) notes [] active {} # 记录尚未释放的按键 for msg in mid: # 处理 note_on 且力度大于 0音符开始 if msg.type note_on and msg.velocity 0: key (msg.channel, msg.note) active[key] { pitch: msg.note, velocity: msg.velocity, start: msg.time } # 处理 note_off或力度为 0 的 note_on等价于 note_off elif msg.type note_off or (msg.type note_on and msg.velocity 0): key (msg.channel, msg.note) if key in active: start active[key][start] duration msg.time - start notes.append((active[key][pitch], active[key][velocity], duration)) del active[key] return notes这段代码在项目里通常会封装进utils.py上面是核心逻辑。要特别说明两点第一msg.time在mido里是增量时间delta time表示距离上一条消息的时间间隔所以严格做法是累加绝对时间再相减上面的写法是简化示意实际项目里要先累计current_time msg.time。第二很多 MIDI 文件会用note_on力度为 0 来表示音符结束所以我在条件里同时判断了velocity 0的情况这个细节不处理音长就会全部算错。2.2 核心预处理把音符序列转成 LSTM 能吃的样本拿到音符列表之后下一步是决定模型到底学什么。这个项目最简化的做法是只让模型预测下一个音符的音高也就是把(pitch, duration)中的pitch单独抽出来组成一个序列。更进阶一点的版本会把时值也编码进去我后面避坑章节会专门讲。这里先按最常见的方案走把 pitch 序列映射成整数索引。构建映射表需要两步先找出所有出现过的音符给每个音符分配一个整数 ID再保留一个反向表生成时把预测出的整数 ID 还原成音符。项目里的notesinvert.png对应的就是这个反向映射思想。def build_vocab(note_list): # note_list 是所有音符的 pitch 序列例如 [60, 62, 64, 65, ...] vocab sorted(set(note_list)) note_to_idx {n: i for i, n in enumerate(vocab)} idx_to_note {i: n for n, i in note_to_idx.items()} return note_to_idx, idx_to_note, len(vocab)def make_sequences(note_list, note_to_idx, seq_len32, stride1): X, y [], [] idx_seq [note_to_idx[n] for n in note_list if n in note_to_idx] for i in range(0, len(idx_seq) - seq_len, stride): window idx_seq[i:i seq_len] X.append(window[:-1]) # 前 seq_len-1 个音符作为输入 y.append(window[-1]) # 最后一个音符作为标签 return X, y逻辑说明make_sequences用滑窗把一条长音符序列切成一堆短样本。每个样本的输入是连续seq_len - 1个音符的 ID输出是紧跟其后的第seq_len个音符的 ID。这样模型的任务就变成了给我前面 31 个音符预测第 32 个是什么。参数说明seq_len决定模型能看到的上下文长度常见取值是 32 或 64对应差不多一到两小节旋律。stride是滑窗步长步长越大样本越少、训练越快但会漏掉部分位置关系步长设为 1 时样本量最大序列之间的重叠也最多能让模型把相邻音符的关系学得更细。我的经验是起步用seq_len32, stride2数据量不够再降 stride。2.3 三个关键参数seq_len、stride 与 vocab 大小这三个参数直接影响训练效果我逐个说过。vocab_size是训练集中出现过的不同音符数量。对单音轨旋律来说一般落在 30 到 80 之间也就是横跨几个八度。vocab 太大不是好事会让 softmax 输出维度变大、训练变难vocab 太小说明音域太窄学出来的旋律会很乏味。建议先跑一次数据统计把音符范围画出来看看。seq_len决定模型能记住多长的旋律上下文。LSTM 虽然号称能处理长序列但实际训练中太长会导致样本数骤减、训练变慢。我用 32 起步如果生成出来的旋律明显缺少前后呼应再往 64 调。注意调整seq_len后网络输入维度也要跟着改这是新手最容易漏的。stride的意义在于样本数量与信息覆盖的平衡。举个例子一段 400 个音符的旋律seq_len32, stride1能切出 369 个样本stride16只能切出 24 个样本。样本太少 LSTM 很容易过拟合生成时就会重复同一个片段。所以我在样本量允许的情况下一定用最小步长。这个预处理阶段还有一件事容易被忽略把所有 MIDI 的音符序列合并时要不要保留不同曲目之间的边界我一般会在每首曲子之间插入一个分隔符比如用vocab_size位置的特殊 ID 表示曲目结束这样模型能学到「一首曲子会结束、下一首重新开始」的结构。不带分隔符的话模型会把上一首结尾和下一首开头强行连在一起生成时会听到莫名其妙的跳变。3. LSTM 网络结构为什么选它以及 network.py 里怎么搭预处理做完数据变成了「输入一串音符 ID输出下一个音符 ID」的分类问题。接下来要回答两个问题为什么用 LSTM 而不是普通全连接网络网络结构具体怎么搭这章我会把选型理由和network.py里的实现一起说清楚。3.1 为什么是 LSTMRNN 的梯度问题与音乐的时序依赖音乐是最典型的序列数据当前音符的选择强烈依赖前面若干音符甚至隔了十几拍的前后呼应也很常见。如果用普通全连接网络输入是固定窗口模型看不到窗口之外的信息也无法在参数中建立跨时间的记忆。RNN循环神经网络天然为序列设计它在每个时间步用同一个隐藏状态更新单元理论上能把前面的信息沿着时间步传递下去。但传统 RNN 在训练时存在梯度消失问题反向传播经过多个时间步后梯度被反复相乘早时刻的权重几乎收不到有效更新。这意味着它「记不住」太久之前的信息。LSTM 通过门控机制输入门、遗忘门、输出门提供了一条让梯度更稳定流动的路径细胞状态像一条传送带门决定哪些信息写入、哪些丢弃。放在音乐场景里LSTM 能捕捉到类似「前 8 拍建立了某个动机后 8 拍在展开它」这种中等跨度的结构关系。之前在时间序列预测里大家也常用 LSTM套路基本相同都是把历史窗口映射到未来一个点。区别在于音乐生成的输出是离散音符 ID用的是分类损失而连续值预测比如股票价格是多层 LSTM 加 Dense 输出单个实数回归损失。这个项目用的是分类路线所以后面模型输出层是softmax。3.2 network.py 的模型骨架Embedding 双层 LSTM Softmax项目里的network.py文件负责定义模型。我按照常见做法搭了四层结构Embedding 层把离散音符 ID 转成稠密向量两层 LSTM 逐层提取时序特征最后接 Dense Softmax 输出所有音符的概率分布。import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout, Embedding def build_model(vocab_size, seq_len, embed_dim32, lstm_units256, dropout0.3): model Sequential() model.add(Embedding(vocab_size, embed_dim, input_lengthseq_len - 1)) model.add(LSTM(lstm_units, return_sequencesTrue)) model.add(Dropout(dropout)) model.add(LSTM(lstm_units)) model.add(Dropout(dropout)) model.add(Dense(vocab_size, activationsoftmax)) model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] ) return model逻辑说明Embedding层的输入是音符 ID整数vocab_size是音符总数embed_dim是每个音符被映射成的向量维度。音符 ID 本身没有数值含义60 和 61 不是「差 1 倍」的关系Embedding 的作用是把离散 ID 变成模型可学习的密集向量让相近的音符在向量空间里位置靠近。input_lengthseq_len - 1要和预处理时的输入长度严格对齐。参数说明lstm_units256是 LSTM 隐藏单元数。单元数越大模型容量越大但训练更慢、更容易过拟合样本量只有几千时 128 通常比 256 稳。第一层 LSTM 设置了return_sequencesTrue因为它要输出每个时间步的隐藏状态给第二层第二层不需要只输出最后一个时间步的状态。sparse_categorical_crossentropy对应的标签是整数索引不需要做 one-hot 编码省内存也省事。3.3 超参设定与训练目标loss 函数和优化器怎么选网络结构定下来后真正影响生成质量的是超参。先给一张我常用的起始配置表经验不足时照抄不会出大问题。超参取值影响seq_len32上下文长度太小旋律碎太大训练慢embed_dim32音符向量维度16 也能跑但表现力弱lstm_units128 ~ 256模型容量数据少时优先 128dropout0.3防止 LSTM 层的过拟合batch_size64 ~ 128大 batch 训练稳小 batch 更易跳出局部极小optimizerAdam默认 lr0.001比 SGD 稳得多epochs100 ~ 300配合早停不要硬跑到最后我用EarlyStopping监控验证集 losspatience设为 10 到 15 个 epoch连续不下降就停。模型保存用ModelCheckpoint每轮只覆盖最佳权重防止学到后期过拟合反而把模型写坏。一个小提醒不要只看 accuracy。音乐生成数据里相邻音符往往高度相关比如常见音程重复出现模型学个「平均音符」也能得到不低的准确率但生成出来非常无聊。我在训练时习惯同时观察验证集 loss 和生成试听效果loss 降得慢不一定代表生成差loss 降得快也不代表生成好听这点只有亲手跑过才会有体感。4. 训练与生成从 train.py 到 generate.py 的完整链路这章沿着代码执行顺序走一遍。训练脚本和生成脚本是这个项目真正的主干我分别拆开讲最后说怎么确认输出是一个正常能听的 MIDI。4.1 train.py 流程从加载数据到 Checkpointtrain.py的职责是串起完整流程扫描 MIDI 目录 → 解析所有曲子 → 合并音符序列 → 构建映射表和滑窗样本 → 划分训练/验证集 → 训练并保存最优模型。import os from utils import midi_to_notes, build_vocab, make_sequences from network import build_model from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint midi_dir midi all_notes [] for fname in os.listdir(midi_dir): if fname.endswith(.mid) or fname.endswith(.midi): notes midi_to_notes(os.path.join(midi_dir, fname)) pitches [n[0] for n in notes] # 只取音高 all_notes.extend(pitches) note_to_idx, idx_to_note, vocab_size build_vocab(all_notes) X, y make_sequences(all_notes, note_to_idx, seq_len32, stride1) split int(len(X) * 0.9) X_train, y_train X[:split], y[:split] X_val, y_val X[split:], y[split:] import numpy as np X_train np.array(X_train).reshape(-1, 31) y_train np.array(y_train) X_val np.array(X_val).reshape(-1, 31) y_val np.array(y_val) model build_model(vocab_size, seq_len32) model.fit( X_train, y_train, batch_size128, epochs200, validation_data(X_val, y_val), callbacks[ EarlyStopping(patience15, restore_best_weightsTrue), ModelCheckpoint(best_model.h5, save_best_onlyTrue) ] )逻辑说明这里把「训练集 / 验证集」按时间顺序切分而不是随机打乱因为音乐序列相邻样本有重叠随机打乱会让验证集泄题表现为 loss 很低但生成效果很差。seq_len32时输入是前 31 个音符所以make_sequences里window取seq_len个音符、输入用[:-1]对应到build_model的input_length31两处必须一致。参数说明单个 midi 文件解析出的音符序列通常只有几百个音符如果 45 个文件合起来才一两万音符切出的样本也就差不多这个量级。batch_size128在这个量级下比较稳妥每轮迭代次数不多epochs给 200 是为了配合早停实际会在 50 到 100 轮左右停住。4.2 generate.py 流程温度采样与音符还原训练完的模型是一个概率预测器给定前 31 个音符它输出第 32 个音符的概率分布。如果每次都取概率最大的音符生成会陷入重复正确的做法是从分布里「按概率采一个」这就是 temperature 采样的作用。import numpy as np def sample_from_logits(logits, temperature1.0): # logits 是模型输出的概率向量 logits np.asarray(logits).astype(float64) logits np.log(logits 1e-8) / temperature exp_logits np.exp(logits) probs exp_logits / exp_logits.sum() return np.random.choice(len(probs), pprobs) def generate_melody(model, seed_notes, idx_to_note, seq_len, length200, temperature1.0): current list(seed_notes) generated [] for _ in range(length): input_seq np.array(current[-seq_len1:]).reshape(1, -1) pred_probs model.predict(input_seq, verbose0)[0] next_idx sample_from_logits(pred_probs, temperaturetemperature) generated.append(idx_to_note[next_idx]) current.append(next_idx) return generated逻辑说明temperature控制随机性。温度等于 1 就是原始分布大于 1 让概率分布变得更平坦低概率音符被选中的机会增加生成更有变化但风险是乱小于 1 让分布更尖锐输出更保守、更接近训练集里的常见片段。起始的seed_notes可以随机选训练集里的一段也可以手工给几个音后续完全由模型自己续写。参数说明current[-seq_len1:]每次取最近 31 个音符作为输入生成一个音符后再把它追加进去窗口持续滚动。length200大约是 200 个音符按每分钟 120 拍、每拍 2 个音符估算能生成 1 到 2 分钟的单旋律。生成结束后把generated里的音高配上固定时值比如每个音符 0.25 秒、固定力度转成 MIDI 事件写盘就是output.mid。4.3 跑起来的第一件事看 loss 曲线和试听 output.mid训练完成后我建议先打开终端看一下 loss 曲线的整体形态。正常情况是训练 loss 和验证 loss 一起下降然后验证 loss 先平台期如果训练 loss 一路走低、验证 loss 止步不前甚至上升就是过拟合了。这个项目里最容易误导人的地方在于验证 loss 不算很低也可能生成出像样的旋律因为 loss 计算的是逐音符预测的交叉熵和「旋律好不好听」不完全等价。然后试听这一步有个效率技巧别训练完才生成。我在训练脚本里每 10 个 epoch 就自动生成一次短 melody 保存成preview_10.mid、preview_20.mid这样能直观听到模型从「乱弹」到「有点意思」的变化过程。具体做法是在回调里加一个自定义Callback训练轮次间隔触发时调用generate_melody并落盘。这比盯着 loss 数字判断快得多也是我用来跟朋友炫耀训练进度的小手段。如果你在 Windows 上环境还没配好注意python和vscode python 环境配置本身也是新手容易翻车的地方建议直接用 Anaconda 建独立环境pip install mido tensorflow两步装完再跑避免把系统 Python 搞乱。5. LSTM 音乐生成避坑指南五个让模型翻车的典型问题训练个把小时出一堆乱七八糟的 MIDI是这类项目最常见的结局。这里我挑了五个几乎人人会踩的坑按「现象 → 原因 → 解决」的方式写清楚都是我实际调试中遇到过的。5.1 loss 很低但旋律一直在原地打转过拟合与重复生成现象训练到后期 loss 已经很低但生成的 200 个音符翻来覆去就是同一个 8 音小片段稍微变几个音又回到老路。原因数据量太小、模型容量太大加上temperature设得接近 0模型把训练集里的某一个片段当成「标准答案」每次采样都收敛到概率最高那几条路径。另一个常见原因是验证集没有切分好模型把验证片段也背了下来。解决先把temperature提到 0.8 到 1.0 试一圈如果还是重复再依次做三件事降低lstm_units到 128、提高dropout到 0.4、检查数据集里是不是某些曲子占比过高。我遇到一次重复的原因是 45 个 MIDI 里有一首特别长、音符特别多占了总数据一半模型被它带偏了。5.2 生成的曲子全是休止符事件解析里的两个隐藏坑现象模型能正常训练loss 也在降但生成的旋律大段大段空白或者只有零星几个音听起来像故障设备。原因MIDI 解析时把note_off事件算错导致大部分音符的时长为 0 或负数也可能把鼓轨通常在 9 频道也解析进来了打击乐的音符变成一堆无意义的短音。解决先打印解析出的音符数量做个 sanity check正常情况每首曲子应该至少有几十个音符。然后过滤掉channel 9的轨道并把时长为 0 或负数的音符直接丢弃。代码层面mido的msg.time是增量时间必须在循环里累计current_time msg.time再用note_off的累计绝对时间减去note_on的累计绝对时间否则几乎一定会算错时长。这个坑坑了我整整一个下午。5.3 音符时值千篇一律只预测 pitch 忽略了 duration现象生成的旋律音高变化挺自然但从头到尾每个音符都是同样长度像机器在匀速敲键盘。原因预处理时只把pitch提取出来做序列duration信息被完全丢弃。模型只学了「下一个音是什么」没学「这个音该持续多久」。生成时给所有音符配了固定时值听起来当然死板。解决把序列从「音高序列」扩展成「事件序列」——每个位置同时包含音高和时值比如把(pitch, duration_quantized)编码成一个 token或者用双通道输入分别喂音高和时值。简单做法是先对时值做离散化例如分成 0.25、0.5、1、2 拍几档和音高拼成一个复合 ID 再送进 Embedding 层。这样模型能同时学音高和节奏的关联生成结果立刻「活」不少。5.4 样本量小、学不到大跨度结构窗口与步长的调整现象生成的旋律单看局部还行但如果拉到十几秒的长度会发现没有主题发展前后段落之间连接得很生硬。原因seq_len32覆盖的时间跨度太短模型只学了「相邻几个音符怎么接」没学到「乐句怎么呼应」。样本量偏少时这个问题更明显因为模型连局部关系都没吃透。解决先把stride改成 1把能用的样本全部榨出来然后尝试seq_len64或128让 LSTM 看到更长的上下文。注意seq_len变大后训练时间会拉长我在 45 个 MIDI 的小数据量下用seq_len64, stride1仍然能在几分钟内收敛不会慢到无法接受。如果还不行就增加 MIDI 素材这类数据规模基本是多多益善。5.5 环境级的坑MIDI 多音轨与中文路径解析报错现象代码在本机能跑换个目录或者换台电脑就报错常见错误包括MidiFile读取失败、KeyError、UnicodeDecodeError等。原因一部分 MIDI 文件是标准 MIDI 格式 0但很多 DAW 导出的是格式 1多音轨不同库对多轨支持程度不一样。另一个高频原因是路径带中文或空格mido底层调用文件接口时编码处理不好直接抛异常。解决读文件前先用os.path.abspath规范化路径然后过滤音轨时只看track.name包含melody、lead、vocal的轨道或者干脆取包含音符事件最多的轨道。遇到解析异常用try / except跳过坏文件同时打印文件名训练前先确认所有文件都被成功解析。项目自带的midi目录里的素材一般没问题但你自己新增数据时最容易踩到这条。6. 进阶技巧让生成结果更像人写的曲子模型跑通之后提升空间在采样策略和后处理。我常用三个技巧都能直接套在这个项目上。第一个技巧是动态温度。固定temperature1.0从头到尾旋律开头容易散。我会让前 20 个音符用 0.8 的保守温度确保起调稳定中间逐步升到 1.1 创造变化最后 30 个音符再降回 0.9 让收尾别太放飞。def adaptive_temperature(step, total, start0.8, mid1.1, end0.9): if step total * 0.25: return start elif step total * 0.75: return start (mid - start) * ((step - total * 0.25) / (total * 0.5)) else: return mid (end - mid) * ((step - total * 0.75) / (total * 0.25))第二个技巧是后处理时合并过短音符。模型有时会生成几十毫秒的碎音直接听会显得毛躁。我一般写一个循环把时值小于两个最小单位的短音合并到前一个音上保持音高不变、时值累加很快就能让旋律干净一圈。第三个技巧我拿来自检生成 100 段旋律统计音高分布直方图和训练集对比。如果生成集的音域集中在中央 C 附近而训练集明显分布在两个八度说明模型没吃透训练数据需要回看预处理。这个习惯让我避免了好几次「以为模型很好、其实是歪打正着」的误判。从那以后我每次训练音乐模型都会先跑一遍音高分布对比希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网