LSTM 音乐生成项目实战:从 MIDI 预处理到模型训练完整解析

发布时间:2026/10/5 7:19:53
LSTM 音乐生成项目实战:从 MIDI 预处理到模型训练完整解析 简介一项基于LSTM长短期记忆网络的音乐生成器Python项目面向机器学习初学者与音乐科技爱好者展示利用深度学习处理序列数据、完成旋律建模与生成的全流程。压缩包共56个文件以46个MIDI音乐样本、4个Python脚本训练、生成、网络定义与预处理、5张结构示意PNG及1份说明文档组成整体体积仅723KB。MIDI样本涵盖多种旋律作为训练与验证语料Python脚本覆盖数据解析、音符序列转换、LSTM网络构建、模型训练及音乐生成完整链路PNG图直观呈现网络结构与乐理知识降低理解门槛。项目包含可直接运行的训练与生成脚本并附带输出midi结果代码支持调整隐藏单元、学习率等超参数便于尝试不同生成效果。目录按功能拆分从数据准备到模型推理各环节均有注释结构清晰。已有181人学习适合希望通过动手实践掌握深度学习音乐生成原理并进一步探索AI作曲的开发者。1. 用 LSTM 生成音乐这个 Python 项目到底能做什么LSTM 在做音乐生成时有个天然优势——它能记住前面几十个音符之间的关系而你写旋律时靠的恰恰就是这种前文记忆。这个ai_music-master项目就是一条完整的 LSTM 音乐生成流水线拿到手有 45 首 MIDI 格式的曲谱、utils.py负责把 MIDI 拆成模型能读的音符序列network.py搭好 LSTM 神经网络train.py完成训练generate.py负责根据你给的种子音符往下续写旋律最后输出新的output.mid。它是典型的读曲谱、学规律、写新曲的闭环适合有基础 Python 语法的读者拿来入门深度学习与音乐生成的交叉方向。注意别指望装完一运行就产出巴赫级别的赋格它的价值是让你完整走通MIDI 数据 → 序列化 → LSTM 训练 → 音乐生成每一个环节而且代码量不大改起来非常顺手。2. 从 MIDI 到序列数据预处理决定生成质量的下限2.1 MIDI 里到底装了什么先看懂格式再动手MIDI 文件不存音频波形它存的是什么时候、用什么音高、按多久、多大力这四件事的事件流。每个音符对应一个音高编号60 是中央 C往上每加 1 升半音61 就是 #C72 又是一个八度的高音 C。这种离散的数字表示恰好是 LSTM 喜欢的输入形式——这也是音乐生成项目普遍选 MIDI 而不是 WAV 做数据源的原因。WAV 采样率动辄 44100LSTM 根本记不住那么长的上下文而一首歌转换成音符序列通常只有几百到几千个 token这个长度正好落在 LSTM 能有效建模的范围内。预处理的第一步是把 MIDI 事件流还原成音符三元组每个三元组包含音高pitch、起始时间start和时长duration。项目里有一张乐理知识.png我第一次跑的时候没在意后来发现它是在提醒你MIDI 里的音高编号不是五线谱上的唱名别用直觉去理解生成的数字要学会把 60、64、67 这种编号直接对应到 C、E、G 三个音否则后面检查生成结果时会一头雾水。常见做法是用pretty_midi库来解析。它会把 MIDI 按乐器轨道拆开每一条instrument.notes里就是排好序的音符对象。下面这段代码是utils.py的核心逻辑通常只改三处MIDI 路径、乐器过滤规则、要不要合并重叠音符。import pretty_midi def midi_to_notes(midi_path: str): midi pretty_midi.PrettyMIDI(midi_path) notes [] for instrument in midi.instruments: # 跳过打击乐器轨道鼓组的音符编号没有旋律意义 if instrument.is_drum: continue for note in instrument.notes: # 三元组音高、起始时间、时长 notes.append((note.pitch, note.start, note.end - note.start)) # 按起始时间排序保证旋律的时间顺序不乱 notes.sort(keylambda x: x[1]) return notes这里is_drum过滤是必须的很多 MIDI 文件会把鼓组放在第 10 通道不滤掉的话训练数据里会混入大量没有调性关系的打击音符直接拉低生成质量。排序那行容易被忽略但 MIDI 文件的多轨事件在底层不保证按时间全局有序不排序后面切序列时会得到颠倒的旋律片段。2.2 音符序列化与词汇表构建把三元组变成 token拿到音符三元组后下一步是决定模型预测的单位是什么。项目采用的做法通常是只保留音高pitch作为预测目标丢弃时值信息因为同时预测音高和时长会让任务复杂度翻倍而 LSTM 在少量数据下很难同时学好两个维度。你会在项目里看到大量以音符编号直接作为序列元素的写法就是这个原因。先把所有出现过的音高收集起来生成一张音高 → 整数索引的映射表然后遍历每一首歌把音符序列替换为索引序列。这里有个小技巧项目里的notesinvert.png其实对应一种数据增强思路——把音符序列倒序后再切一遍训练样本。一首歌倒着放虽然听感奇怪但音程关系、跳进级进的统计规律不变等于白嫖了一倍训练数据。from collections import defaultdict def notes_to_indices(notes_list, pitch_to_idx): # notes_list 是 midi_to_notes 的返回结果只取 pitch 字段 seq [pitch_to_idx[note[0]] for note in notes_list] return seq def build_vocab(all_notes): # 统计所有音符的音高分布 pitch_counter defaultdict(int) for notes in all_notes: for pitch, _, _ in notes: pitch_counter[pitch] 1 # 过滤出现次数过少的极端音高vocab 保持在 50~80 个左右 valid_pitches [p for p, c in pitch_counter.items() if c 3] vocab sorted(valid_pitches) pitch_to_idx {p: i for i, p in enumerate(vocab)} idx_to_pitch {i: p for p, i in pitch_to_idx.items()} return pitch_to_idx, idx_to_pitch过滤出现次数过少的音高这一步很关键。原始 MIDI 里经常有那种只出现一次的装饰音如果全部保留词汇表会膨胀到一百多个导致每个类别的训练样本过于稀疏模型学不到稳定规律。我一般把阈值设在 3 次以上这样既能保留常见音又不会让softmax输出的类别数太多。2.3 滑动窗口切样本LSTM 看到的上下文序列化完成之后就要把整首歌的索引序列切成输入片段 下一个音符的训练对。LSTM 一般用seq_len个音符预测下一个音符所以滑动窗口的长度就是模型的记忆长度——窗口太短学不到乐句的起承转合太长则在小数据集上过拟合严重而且训练时间成倍上涨。项目默认的seq_len一般在 100 左右也就是让模型参考前面一百个音符来预测第一百零一个这大约相当于流行歌曲 4 到 8 个小节的长度。import numpy as np def create_sequences(seq, seq_len100): X, y [], [] for i in range(len(seq) - seq_len): # 输入是连续的 seq_len 个音符 X.append(seq[i:i seq_len]) # 目标是这串音符之后的那一个音符 y.append(seq[i seq_len]) return np.array(X), np.array(y)这里的X形状是(样本数, seq_len)每个元素都是整数索引。后面喂给模型时会先经过Embedding层做向量化所以这里不需要做 one-hot也尽量不要直接除以vocab_size归一化因为整数索引经过 embedding 查找表的效果远好于把 0.78、0.42 这种浮点数塞给网络。切完所有曲目后把所有X、y分别拼接utils.py的预处理部分就完成了。跑通这步之后建议顺手打几个print检查一下打印一条序列对应的音高编号对照idx_to_pitch确认没有错位。3. 构建与训练 LSTM 网络network.py 与 train.py 的关键参数3.1 模型结构怎么搭双层 LSTM 加 Dropout 的常规组合network.py里定义的模型是整个项目的核心。典型的写法是Embedding → LSTM → Dropout → LSTM → Dropout → Dense。Embedding层把音高索引映射成 128 维稠密向量两个 LSTM 层负责建模序列依赖最后的Dense层用softmax输出每个音高的概率分布。项目里那张构建的神经网络模型.png图画的正是这个结构——你可以只看局部连接的粒度确认哪一层是return_sequencesTrue一眼就能看出有没有接错。第一层 LSTM 必须设置return_sequencesTrue因为它要把完整的隐藏状态序列传给第二层 LSTM第二层则不需要因为只需要最后一个时间步的输出送到Dense层做分类。Dropout 放在两个 LSTM 层之后比例 0.3 左右比较合适太小防不住过拟合太大则模型学不动。这个项目数据量小Dropout 是防止模型死记硬背训练集旋律的关键。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout def build_model(vocab_size, seq_len100, embed_dim128, lstm_units256): model Sequential() # 输入是整数索引必须先经过 Embedding 变成向量 model.add(Embedding(vocab_size, embed_dim, input_lengthseq_len)) # 第一层 LSTM 输出完整序列供第二层继续读取 model.add(LSTM(lstm_units, return_sequencesTrue)) model.add(Dropout(0.3)) # 第二层 LSTM 只输出最后一个时间步 model.add(LSTM(lstm_units)) model.add(Dropout(0.3)) # softmax 输出每个音高的概率类别数 词汇表大小 model.add(Dense(vocab_size, activationsoftmax)) model.compile( losssparse_categorical_crossentropy, optimizeradam, metrics[accuracy] ) return model这里用了sparse_categorical_crossentropy而不是categorical_crossentropy意味着y可以直接是整数索引不需要转成 one-hot 矩阵既省内存又省转换步骤。lstm_units我习惯设 256数据量大可以提到 512但 45 首 MIDI 这种量级 256 已经够用。参数量大概在 70 万左右CPU 也能在半小时内跑完一个 epoch。3.2 训练流程与超参数epochs、batch size、学习率怎么定train.py的流程非常直白加载预处理好的X和y按 9:1 切分验证集然后调用model.fit。但超参数的选择直接影响训练效果这里列几个我在这个项目上调参时的基准值可以按你自己的数据量缩放参数推荐范围说明seq_len80~120小于 60 学不到乐句结构大于 150 在少量数据上过拟合batch_size32~64数据集小时用 32 更稳梯度更新更频繁epochs150~300看 validation loss 是否还在下降没降就早停learning_rate1e-3 起步默认 adam 的 lr 可直接用但 loss 震荡就降到 3e-4train/val 切分9:1用最后 10% 的歌做验证别随机切防止同首歌片段泄漏训练时的模型保存建议用ModelCheckpoint只存最佳的val_loss权重因为音乐生成训练的 loss 曲线波动很大最后一轮的权重往往不是最好的。loss 降到 2.5 以下时模型已经学到音高分布的大致规律降到 2.0 以下时生成结果开始有可听性如果一直卡在 4 以上先别加网络层回头检查预处理。from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping def train_model(model, X, y, val_data, epochs200, batch_size64): X_val, y_val val_data callbacks [ # 只保存验证损失最低的权重 ModelCheckpoint( checkpoints/best_weights.hdf5, monitorval_loss, save_best_onlyTrue, verbose0 ), EarlyStopping( monitorval_loss, patience15, restore_best_weightsTrue ) ] history model.fit( X, y, validation_data(X_val, y_val), epochsepochs, batch_sizebatch_size, callbackscallbacks ) return historyEarlyStopping是省时间的关键patience 设为 15 意味着连续 15 个 epoch 验证 loss 没新低就停避免后期在平台期白烧 GPU 时间。restore_best_weightsTrue这个参数经常被漏掉它会在早停时把权重回滚到验证 loss 最低的那个点否则你拿到的是最后一次迭代的权重通常比最优差不少。3.3 训练产物与 checkpoint模型保存后怎么恢复训练完成后checkpoints目录下会有一个best_weights.hdf5文件这就是生成音乐时要加载的权重。有一个细节只存权重还是连网络结构一起存会影响后续加载方式。ModelCheckpoint默认只存权重加载时必须先重新实例化模型再load_weights。如果你嫌麻烦可以用model.save(model.h5)连结构带权重一起存generate.py用tf.keras.models.load_model一行加载。我建议在这个阶段做一次完整性验证而不是直接冲到生成环节用一个固定种子序列跑一次model.predict看输出维度是否为(seq_len 后的那个位置, vocab_size)同时确认np.argmax(preds)对应的音高编号在idx_to_pitch的范围内。这一步能挡住九成低级 bug——比如形状没对齐、词汇表不匹配导致 index out of range。4. 生成音乐generate.py 的种子输入与采样策略4.1 种子序列怎么选随机截取与整曲开头各有利弊训练完成后generate.py会要求你提供一段种子序列作为旋律的引子。种子序列的长度和训练时的seq_len必须一致否则模型预测时会因为输入形状不匹配直接报错。项目里常见的做法是从训练集里随机抽一首歌截取任意连续的seq_len个音符作为种子——这样生成的旋律延续了原曲的调式风格起步比较稳。另一种做法是完全随机生成一个种子序列适合探索性生成但头几个音符大概率不在任何调式上LSTM 需要跑 10~20 步才能把调性拉回来听感会有一小段混乱。我在跑的时候更喜欢混合策略先用随机种子生成再把开头的 20 个音符丢掉从后面开始取这样既避免了自设种子的偏差又不需要每次都从原曲截取。你可以在generate.py里加一个参数drop_start20来控制这个行为。4.2 温度temperature参数控制随机性的唯一玄学旋钮神经网络输出的不是确定性的下一个音符而是vocab_size个音符各自的概率。取概率最大的那个argmax会让生成结果原地重复而完全按概率随机又会过度跳跃。temperature参数就是干这个的当它小于 1 时概率分布被压得尖锐模型更倾向于选高概率音符输出更保守大于 1 时分布变得平滑低概率音符也有机会被选中输出更跳跃。项目里的默认值通常在 1.0 附近但实际生成时你会反复调这个数——生成结果单调重复就调高到 1.2听起来像乱弹就调低到 0.8这是一个基本靠耳朵做反馈调节的玄学旋钮。import numpy as np def sample_with_temperature(preds, temperature1.0): # 加上极小值防止 log(0) preds np.asarray(preds).astype(np.float64) 1e-8 # 用 log 除以 temperature再还原成合理概率分布 log_preds np.log(preds) / temperature exp_preds np.exp(log_preds) preds exp_preds / np.sum(exp_preds) # 按概率分布随机采样而不是固定选最大 return np.random.choice(len(preds), ppreds)记住两个要点第一preds必须原来是softmax的输出不能是没归一化的 logits第二np.random.choice的p参数必须要所有元素之和为 1浮点误差会导致偶尔报probabilities do not sum to 1。真遇到时用preds / np.sum(preds)强制归一化即可。4.3 生成主循环与 MIDI 文件写回生成就是一个循环把当前序列喂给模型采样得到下一个音符把序列窗口向前滑动一格重复 N 次。每次生成的音符都被追加到结果列表里。这里有一个项目容易忽略的细节生成的时长duration信息在训练时被丢弃了所以写回 MIDI 时每个音符的时值怎么安排常见做法是给所有生成的音符设定一个固定时长比如 0.5 秒或 1 个四分音符的长度。这样做出来的 MIDI 听起来机械感十足像节拍器在赶着走但至少旋律线的走向是模型学出来的。更高阶一点的做法是训练时把时值也序列化成一维并行序列让 LSTM 同时预测音高和时值但这就是另一个量级的工程了不建议在入门项目里做。def generate_music(model, seed_seq, idx_to_pitch, length200, temperature1.0): # seed_seq 是整数索引序列长度必须等于训练时 seq_len current list(seed_seq) generated_indices [] for _ in range(length): # 输入形状(1, seq_len)与训练时 input_length 保持一致 x np.array(current).reshape(1, len(current)) preds model.predict(x, verbose0)[0] next_idx sample_with_temperature(preds, temperature) generated_indices.append(next_idx) # 滑动窗口弹出第一个加入新生成的 current.append(next_idx) current current[1:] # 把索引还原成音高编号 return [idx_to_pitch[i] for i in generated_indices]参数说明length是生成音符的总数200 个音符按每 0.5 秒一个能凑出 100 秒的片段足够听出风格temperature是生成阶段最重要的调节钮。写回 MIDI 时推荐用music21库而不是手写二进制 MIDI 文件——music21的stream对象能直接写入.mid文件代码量小且容错好。注意轨道和乐器号的设置生成结果最好放在通道 0 用钢琴音色program 0这样任何播放器打开都能正常发声。如果写完的 MIDI 播放出来全是静音大概率是音符的start时间或duration没设对music21默认quarterLength为 4手动指定duration0.5是常见修法。from music21 import stream, note, duration def notes_to_midi(notes, output_pathoutput.mid): s stream.Stream() for pitch in notes: # 每个音符固定 0.5 个四分音符的时值 n note.Note(pitch) n.duration duration.Duration(0.5) s.append(n) s.write(midi, fpoutput_path)5. 避坑与常见问题排查跑这个项目时我踩过的五个坑5.1 MIDI 文件解析失败或解析为空序列现象midi_to_notes在部分文件上直接抛异常或者返回的notes列表长度为 0训练数据突然少了好几首歌。原因MIDI 文件格式远比想象中复杂。有些文件用了多轨道且某些轨道没有任何音符有些文件的end时间戳与start相等导致duration0还有一部分文件把打击乐放在非标准通道只靠is_drum过滤不掉。最隐蔽的情况是——整个文件用的是 MIDI 0 格式所有事件挤在一条轨道上instrument.notes属性访问时行为不一致。解决在midi_to_notes里对文件级加try/except解析失败就跳过该文件并打印文件名不要中断整个预处理流程。对duration为 0 的音符直接notes.append((pitch, start, 0.25))兜底。对is_drum过滤后仍然有空序列的文件直接continue。从那以后我每次拿到新 MIDI 数据集都会先跑一遍解析脚本把能成功解析的文件数量打印出来再开始训练。5.2 Loss 居高不下怎么训都降不到 3 以下现象训练跑了 100 个 epochtrain_loss还在 4 以上验证集准确率徘徊在 20% 上下词汇表 60 个音随机猜接近 1.6%。原因八成不是网络结构问题而是数据或输入的编码问题。常见的有三种一是y标签没有对齐X每个样本的预测目标错位了一个位置二是某些音符索引超出了vocab_sizeEmbedding层会在不报错的情况下随机初始化对应向量造成训练信号混乱三是softmax层输出维度与词汇表大小不一致模型压根没法输出正确概率分布。解决在create_sequences之后加一个断言assert y.max() vocab_size越界就直接报错。同时打印第一条X和y对应的音高编号手工对照原 MIDI 旋律确认对位关系。另外把学习率从默认1e-3降到3e-4Adam 在 loss 曲面陡峭时容易震荡降学习率经常立竿见影。5.3 生成的全是重复音符一小节内同一个音出现十几次现象生成 200 个音符超过一半是同一个音高旋律像卡壳的唱片完全谈不上音乐。原因最直接的原因是temperature太低比如设了 0.2。此时概率分布被压得极尖模型每次都选峰值位置的音符自然形成重复。第二个原因是训练数据里本身存在大量同音反复常见于电子舞曲的贝斯线模式而你没有做去重或音程约束。第三个原因是模型过拟合把训练数据里某个高概率音记住了。解决先把temperature提到 1.0 以上做对照测试如果重复率显著下降说明问题在采样策略。如果温度调高仍然重复回头检查预处理——看一下训练数据里重复音高的占比超过 30% 就考虑对连续相同音符做合并或过滤。最后确认sample_with_temperature里用的是np.random.choice而不是argmax这个 bug 我见过不止一次。5.4 生成音符音域乱飞旋律里同时出现低音 C 和极高音现象生成的旋律里音高跨度超过 50 个半音一会儿低沉如大提琴一会儿尖锐刺耳听感完全不像训练集里的任何一首歌。原因预处理时没有过滤音域边界训练集里混入了多轨 MIDI 的伴奏轨、低音贝斯轨甚至装饰音轨导致词汇表里同时包含极低和极高音。模型学到的音高分布是训练集所有轨道的混合分布而不是旋律轨的单一分布。解决在build_vocab前加一道音域过滤器只保留钢琴音域范围MIDI 编号 21 到 108内的音符并且统计各轨道的音符数量后把音符数量过少的轨道视为伴奏而非主旋律计入过滤条件。更进一步的做法是对每首歌单独做主旋律提取——这个操作有点硬核但至少把音域边界卡住是必须的。5.5 训练速度慢到怀疑人生GPU 显存还爆了现象按默认seq_len100、batch_size128跑显存直接溢出报OOM改成 CPU 训练一个 epoch 要跑十几分钟。原因LSTM 天然无法并行处理序列内部的时间步和 Transformer 相比是结构性的劣势所以每一步都是串行计算。batch_size128加seq_len100产生的中间状态矩阵太大而 CPU 训练时被反复调用的model.predict会频繁重建计算图效率极低。解决训练时把batch_size降到 32显存占用立刻掉下来。生成时不要用model.predict循环而是先用选好的种子序列一次性构造 batch或者改用tf.function做图模式推理。另外强烈建议先用 5 到 10 首 MIDI 跑一个微型实验确认整个流程能出结果以后再上全量数据否则调一次参数动辄等一小时时间成本太高了。6. 生成结果怎么验证把 MIDI 放出来听再用指标说话MIDI 文件本身不是音频你得先把它转成能听的格式。我一般用fluidsynth加一个免费的 SoundFont 音色库一行命令完成转换fluidsynth -a alsa -g 1.0 /usr/share/sounds/sf2/FluidR3_GM.sf2 output.mid转出来是实时播放如果要导出成 WAV 文件加-F output.wav参数没有 SoundFont 的话用timidity -Ov output.mid -o output.ogg也可以但音色质感差一些。听完第一遍之后才轮到量化验证——耳朵是最快的筛选器指标是用来确认你没有过度自我感觉良好的。我自己的习惯是给生成结果打三个量化指标音符多样性diversity_score、相邻音符重复率repetition_rate、根音分布与训练集的 KL 散度。三者配合看多样性太低说明模型塌缩重复率过高说明 temperature 偏低KL 散度过大说明生成的调式中心与训练集偏差明显。from collections import Counter import numpy as np def diversity_score(notes): return len(set(notes)) / max(len(notes), 1) def repetition_rate(notes): if len(notes) 2: return 0.0 repeats sum(1 for i in range(1, len(notes)) if notes[i] notes[i - 1]) return repeats / (len(notes) - 1) def pitch_class_hist(notes, num_classes12): # 把音高编号映射到 12 个半音阶忽略八度差异 classes [p % 12 for p in notes] hist np.zeros(num_classes) for c in classes: hist[c] 1 return hist / hist.sum()对照训练集的pitch_class_hist做 KL 散度时小于 0.1 说明调式习惯继承得不错大于 0.5 时基本可以断定模型跑偏了。这三个指标加上耳朵基本能挡住 90% 的听起来还行但实际有问题的生成结果。最后说点个人习惯我后来每次训练完都会强制走一遍固定种子 三个温度点0.7/1.0/1.3 三组指标的流程,再花两分钟听一遍生成的 MIDI确认流程闭环之后才会把模型权重保存归档。手感上来的标志是——不需要看指标曲线,你听开头四个音符就能判断这个 checkpoint 值不值得留。希望这份拆解能帮你把这个项目真正跑通,调出第一段自己满意的旋律。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询