基于LSTM的MIDI音乐生成:从数据准备到旋律采样
2026/9/23 19:24:27 网站建设 项目流程

简介:一份以长短期记忆网络(LSTM)与Python实现的音乐生成器完整工程,面向对深度学习、序列建模及AI音乐创作感兴趣的开发者和学生。压缩包共56个文件,大小仅723KB,包含46个midi格式音乐数据、4个Python脚本、5张模型结构及乐理说明图片和1份README文档,覆盖了音乐数据预处理、LSTM模型定义、训练与旋律生成的全流程。项目基于Python生态的Keras/TensorFlow框架实现,从音符序列中学习音乐的模式与风格,训练后能够生成风格相近的新旋律并输出为midi文件。资源自带可直接使用的MIDI训练集,代码模块划分清晰,便于快速上手,适合用于课堂实验、课程设计或毕业设计参考。目前已有181人学习浏览,体积轻量,内容紧凑,能够帮助初学者直观理解循环神经网络在序列生成任务中的完整应用思路。

1. 把 LSTM 用在音乐生成上:ai_music 到底在解决什么问题

用 Python 搭一个 LSTM 来写旋律,是很多人入门深度学习的第一道"甜点"。市面上 ai_music 这类项目的核心思路很直接:把 MIDI 文件拆成音符序列,像训练语言模型一样让 LSTM 预测下一个音符,训练完再让模型自己往下续写。它解决的问题不是"生成一首完整的歌",而是让你用最少的代码验证循环神经网络对序列数据的建模能力。适合两类人:一是 Python 基础扎实但没碰过深度学习的开发者,想找个比 MNIST 有意思的练手项目;二是音乐科技方向的初学者,想理解算法生成的底层逻辑。下面从数据准备、模型搭建到采样生成,完整走一遍这条链路。

2. 准备训练数据:从 MIDI 到 LSTM 能吃的序列

做 ai_music 这类项目,数据准备决定了七成效果。LSTM 吃的是序列数据,音乐里最适合当序列的是 MIDI——它记录的是音符、音高、时长这类符号信息,不是声波。用 MIDI 训练,模型学的是音符之间的组合规律,规模可以很小,训练速度比直接处理音频快一个数量级。

开始之前先确认环境:Python 3.8 以上,装三个包就够。

pip install music21 tensorflow numpy

music21 负责 MIDI 的解析和写入,tensorflow 提供 LSTM 模型实现,numpy 做数值计算。如果你装的是 CPU 版 TensorFlow,这一行就够;GPU 版安装命令略不同,但后续代码完全一致。

2.1 MIDI 解析:用 music21 把音符和时长拆出来

Python 生态里解析 MIDI 有两个常用库:mido 和 music21。mido 更底层,按事件流给原始消息;music21 把 MIDI 包装成乐谱对象,处理调号、拍号、和弦这些音乐概念更方便。做旋律生成,我一般用 music21,少写很多解析代码。

# 解析 MIDI 文件,提取音符序列 from music21 import converter, note, chord def extract_notes(midi_path): score = converter.parse(midi_path) notes = [] # score.flat 把所有声部按时间拍平成一条线性序列 for element in score.flat.notes: if isinstance(element, note.Note): notes.append(str(element.pitch)) elif isinstance(element, chord.Chord): # 和弦的多个音高用点号连接,当作一个 token notes.append('.'.join(str(p) for p in element.pitches)) return notes seq = extract_notes('bach_sample.mid') print(seq[:20])

这段代码的逻辑:converter.parse 读入 MIDI 返回 Score 对象;score.flat 把多个声部的音符按时间顺序拍平;逐个元素判断,普通音符把音高转成字符串,比如 "C4"、"F#5",和弦把多个音高用点号拼成一个字符串。LSTM 看到的输入就是一行字符串序列,和文本处理没有本质区别。

三个使用要点:

  • 拍平后的顺序按时间排序,对钢琴独奏这类单声部 MIDI 很友好;但多轨管弦乐 MIDI 拍平后会混入大量同时发声的音符,序列乱成一团。新手最容易在选数据这一步翻车:拿了一首交响乐 MIDI,解析出来的根本不是旋律线,模型自然学不出东西。解决办法是先用 music21 或 DAW 把 MIDI 拆成单声部,或者只取主旋律那一轨。
  • 为什么把和弦合并成一个 token?因为一个时间点可能有多个音,合并成 "C4.E4.G4" 是最省事的多声部表达。代价是 vocab 里多了很多低频和弦组合,需要靠截断清理。
  • 训练集最好风格统一。有人拿 20 个 MIDI 文件,混了巴洛克、爵士、流行三种风格四种拍号,模型学出来四不像。选同一风格、相近调性的曲目,比如全是巴赫的赋格,或者全是流行钢琴伴奏,LSTM 学到的模式才稳定。

数据量方面,练手项目 20 到 50 个风格一致的 MIDI 文件就够,每个文件平均几百个音符,合并起来几万条训练样本。这个量级 CPU 就能训得动,不需要先租 GPU 再开工。

2.2 序列化与编码:滑窗切分和音符索引

音乐生成本质上和 LSTM 时间序列预测是同一件事:一串按时间排列的离散值,用过去 N 步预测下一步。把音符换成传感器读数、股票价格,后续代码几乎不用动。这也是为什么很多讲 LSTM 时间序列预测的 Python 教程喜欢拿音乐当入门案例——数据公开、结果一听就知道对不对。

要把字符串音符喂给模型,先转成整数索引:

from collections import Counter import numpy as np def build_encoding(all_notes, max_vocab=200): # 按出现频率排序,只保留前 max_vocab 个 token counter = Counter(all_notes) vocab = [n for n, _ in counter.most_common(max_vocab)] note2idx = {n: i for i, n in enumerate(vocab)} idx2note = {i: n for i, n in enumerate(vocab)} return note2idx, idx2note def create_windows(notes, note2idx, seq_len=32): X, y = [], [] for i in range(len(notes) - seq_len): seq_in = notes[i:i + seq_len] # 过去 32 个音符作为输入窗口 seq_out = notes[i + seq_len] # 下一个音符作为预测目标 X.append([note2idx[n] for n in seq_in]) y.append(note2idx[seq_out]) return np.array(X, dtype=np.int32), np.array(y, dtype=np.int32) note2idx, idx2note = build_encoding(all_notes) X, y = create_windows(all_notes, note2idx, seq_len=32) print(X.shape, y.shape)

build_encoding 用 Counter 统计每个 token 出现次数,按频率排序后截断,把排在 200 名之外的稀有 token 丢掉。这一手对控制训练成本很关键:vocab 从 400 缩到 200,最后一层 softmax 的计算量直接减半,生成结果反而更稳定。如果个别音符不在词典里,create_windows 会直接抛 KeyError,这是一种信号,说明你的数据风格不统一或者截断阈值太低。

create_windows 做滑窗切分:窗口长度为 seq_len,步长为 1,每个窗口对应一个预测目标。两个参数值得细说:

  • seq_len 是模型每次"回头看"多少个音符。16 到 64 是常见范围。太短学不到乐句级别的重复结构;太长,长距离信息被稀释,训练也变慢。
  • 步长默认 1,数据利用率最高。如果内存吃紧或者训练太慢,把循环改成 range(0, len(notes) - seq_len, 2),样本量直接减半,代价是模型看到的数据少一点。

多个 MIDI 文件可以这样合并处理:

import glob all_notes = [] for path in glob.glob('midi_data/*.mid'): all_notes.extend(extract_notes(path))

注意:X 里每个整数对应的音符名用 idx2note 手工核对一遍,错位的话后面所有生成的音符都会跟着偏。

为什么用整数索引而不是 one-hot?one-hot 在 vocab 变大后非常浪费内存:vocab 300、seq_len 32、10 万条样本,one-hot 化的 X 大约占 30GB;同样的数据用 int32 整数存,只有十几 MB。这是下一章模型用 Embedding 层而不是把 one-hot 直接喂进 LSTM 的原因,不是图省事,是内存账差了两个数量级。

3. 搭建音乐 LSTM:模型结构、训练参数与损失函数

LSTM 是 1997 年 Hochreiter 和 Schmidhuber 提出的循环网络变体,核心是用遗忘门、输入门、输出门控制信息的保留和丢弃,以此建模长距离依赖。音乐里的主歌副歌重复、和弦走向,都是跨越几十个音符的结构模式,正好是 LSTM 神经网络擅长处理的类型。

3.1 网络结构选型:Embedding、LSTM 层数与 Dropout

试过几种结构之后,最稳的组合是"Embedding + 双层 LSTM + Dense"。单层 LSTM 学到的乐句太短,三层以上在小数据集上基本过拟合,两层是平衡点。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout def build_music_lstm(vocab_size, embedding_dim=64, lstm_units=128, dropout=0.3): model = Sequential([ Embedding(vocab_size, embedding_dim), # 整数索引 -> 稠密向量 LSTM(lstm_units, return_sequences=True), Dropout(dropout), # 防止过拟合 LSTM(lstm_units), Dense(vocab_size, activation='softmax') # 下一个音符的概率分布 ]) model.compile( loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy'] ) return model model = build_music_lstm(len(note2idx)) model.summary()

各层的作用:

  • Embedding 层把整数索引映射成稠密向量。训练时会把语义相近的音符合并到向量空间的相近位置,比如经常跟在 "C4" 后面的 "E4"、"G4",向量距离会拉近。这是 one-hot 做不到的。
  • 第一个 LSTM 层 return_sequences=True,把每个时间步的隐藏状态都交给第二层;第二层只保留最后一个时间步的输出。两层分别负责短句模式和长程结构。
  • Dropout 放在两个 LSTM 层之间,是防止过拟合最有效的一处改动。几万条样本的规模下,模型很容易把训练集背下来,不加 Dropout 的版本生成结果越来越像训练集的复读机。
  • 最后的 Dense 层用 softmax 输出 vocab_size 个概率,表示下一个音符是每个候选 token 的可能性。

参数经验范围:

参数取值范围说明
embedding_dim32~128vocab 200 左右用 64 够用,超过 500 再上 128
lstm_units128~256128 是最稳起点,256 训练时间翻倍但效果不一定更好
dropout0.2~0.5数据量小往高调,数据量大可以放宽
batch_size64~512CPU 上建议从 128 起步

如果你更习惯 PyTorch,可以对着 PyTorch 的 LSTM 源码把门控计算流程捋一遍,模型结构本身不依赖框架,这套骨架搬过去完全一样。

3.2 训练参数设置:batch size、学习率与早停

训练环节最省心的做法是把模型保存和早停挂在一起:

from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping checkpoint = ModelCheckpoint( 'music_lstm.keras', monitor='val_loss', save_best_only=True, verbose=1 ) early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) history = model.fit( X, y, batch_size=128, epochs=50, validation_split=0.1, callbacks=[checkpoint, early_stop] )

几个容易被忽视的细节:

  • ModelCheckpoint 的 monitor 用 val_loss 而不是 loss。训练 loss 一直在降不代表模型在学有用的东西,可能只是过拟合。save_best_only=True 保证落到磁盘的是验证集上最好的权重,而不是最后一个 epoch 的权重。这是给后面留的后悔药:训练结束发现某个 epoch 后验证集开始变差,还能把好权重捞回来。
  • EarlyStopping 的 patience=5,连续 5 个 epoch 验证集 loss 没有改善就停。音乐数据规模小,30 到 50 个 epoch 一般足够,不需要固定跑满。
  • validation_split=0.1 从 X 里随机抽 10% 做验证。这个做法有个坑:滑窗重叠导致验证集和训练集有大量重叠片段,指标会虚高,严谨的切法在避坑章节细说。

训练过程看两个指标。第一是训练 loss 是否稳定下降,前几个 epoch 就卡住不动,多半是学习率或编码出问题。第二是验证集准确率,音乐生成任务里 40% 到 60% 已经算正常,因为下一个音符本来就不是完全确定的,模型是在学概率分布而不是抄答案。如果看到准确率冲上 80%,先别高兴,大概率是数据里某个音符占比极高,模型学会了无脑输出最高频音符。

4. LSTM 生成旋律:温度采样与 MIDI 还原

训练完的模型拿到的是"下一个音符的概率分布"。生成时给一个种子片段,让模型逐步预测后续音符,每步把预测结果拼回序列再继续,循环几百次就得到一段新旋律。这个环节有个常见坑:直接取概率最大的音符,生成结果会越来越单调,最后变成同一个音反复敲。

4.1 温度采样:让生成结果不那么死板

概率分布越尖锐,生成越保守;越平缓,生成越跳跃。温度采样就是在 softmax 之前把 logits 除以一个温度系数,控制尖锐程度。

import numpy as np def sample_with_temperature(pred_probs, temperature=1.0): # 先取对数再除以温度,等价于对 logits 做温度缩放 logits = np.log(pred_probs + 1e-8) / temperature logits = logits - np.max(logits) # 减去最大值防止 exp 溢出 exp_logits = np.exp(logits) probs = exp_logits / np.sum(exp_logits) return np.random.choice(len(probs), p=probs)

这个函数有两个细节。一是 np.log 之前加 1e-8,避免 log(0) 报错,softmax 输出里有很多接近 0 的概率;二是 exp 之前减去最大值,是防止溢出的标准数值技巧。temperature=1.0 时结果和原始分布一致,小于 1 分布变尖,大于 1 分布变平。

生成循环:

def generate_melody(model, note2idx, idx2note, seed_notes, length=200, temperature=1.0): generated = list(seed_notes) for _ in range(length): # 取最近 32 个音符作为上下文,和训练时的 seq_len 保持一致 seq = generated[-32:] seq_idx = [note2idx[n] for n in seq] pred = model.predict( np.array(seq_idx).reshape(1, -1), verbose=0 )[0] next_idx = sample_with_temperature(pred, temperature) generated.append(idx2note[next_idx]) return generated

参数和注意事项:

  • temperature 的可听区间大致在 0.8 到 1.5。第一次跑通用默认 1.0,效果勉强能听;调到 0.3 之后模型反复输出同一个音,调到 2.5 之后满篇跨八度乱跳。经验表:
温度区间听感特征适用场景
0.3~0.6大量重复,几乎在复述训练数据确认模型结构没问题的调试手段
0.8~1.2重复和变化比较平衡默认起点
1.3~1.8跳跃大、新意多,偶尔有刺耳音找灵感、做实验
  • seed_notes 至少要有 32 个音符才能填满窗口,没有现成乐句就从训练数据里随机截一段。固定种子片段很重要,不同温度、不同版本之间对比才公平。
  • 生成循环逐音符前向推理,每步调用一次 model.predict,生成 200 个音符在 CPU 上大概一两分钟,属正常现象。

4.2 从预测序列还原 MIDI 文件

生成的序列还是字符串列表,还原成能播放的 MIDI:

from music21 import stream, note, chord, tempo def sequence_to_midi(sequence, output_path, bpm=100): s = stream.Stream() s.append(tempo.MetronomeMark(number=bpm)) for token in sequence: if '.' in token: # 含点号的 token 是训练时合并的和弦,拆回多个音 s.append(chord.Chord(token.split('.'))) else: s.append(note.Note(token)) s.write('midi', fp=output_path) sequence_to_midi(generated_melody, 'output.mid', bpm=100)

还原逻辑必须和第 2 章的提取逻辑严格对应:提取时把和弦用点号合并,还原时遇到点号就拆回和弦。前后约定不一致是这个环节最常见的 bug。比如训练时用的是 "E4.G4" 格式,生成时却走了 else 分支,music21 会把 "E4.G4" 当成一个音符名,直接报错或者写出打不开的文件。

另外注意节奏设置。示例里每个音符沿用 music21 默认的四分音符时值,200 个音符就是 200 拍,100bpm 下大概两分钟。想要真实的节奏变化,需要把 duration 也编码成 token 一起训练,或者记录训练数据的时值分布,生成时按分布采样。

5. LSTM 音乐生成避坑与排查指南

下面这几条是我自己反复踩过的坑,也是帮别人排查时出现频率最高的问题。每条按现象、原因、解决三步写,对照着排查会快很多。这类项目不难,翻车点基本集中在数据、采样和权重管理三块。

5.1 现象:loss 正常下降,生成的旋律却只有两三个音在反复

原因有两种。一是采样温度太低,概率分布被压得只剩最大概率的 token。二是数据里某个音符占比过高,模型发现"无脑输出最高频音符"这条捷径就能拿到不错的 loss,于是把其他音符的概率都压得很低。

解决:先把 temperature 提到 1.2 左右。如果还是重复,统计训练数据:用 Counter 看最高频 token 的占比,超过 30% 说明数据不均衡,按风格筛选或者对高频 token 降采样。快速判断办法:把模型预测的 argmax 打出来,看它是否总指向同一个索引。如果是,问题在数据分布而不在采样。

5.2 现象:训练时内存爆掉,或者显存 out of memory

原因:X 用 int32 存,几十万条窗口只有几十 MB,不是瓶颈。真正的内存杀手是 one-hot 化:vocab=300、50 万条样本,one-hot 化之后直接几十 GB。显存爆掉则主要是 lstm_units 拉得太大,或者 batch_size 设得过大。

解决:X 保持整数索引,用 Embedding 层做向量化。显存不够就把 batch_size 从 128 降到 32,或者把 lstm_units 从 256 降到 128。样本量很大时,用 tf.data 构造数据集,别把 X 和 y 一次性全载入内存:

import tensorflow as tf dataset = tf.data.Dataset.from_tensor_slices((X, y)) dataset = dataset.shuffle(10000).batch(128).prefetch(1) model.fit(dataset, epochs=50)

from_tensor_slices 只是建立惰性引用,不会复制数据;prefetch(1) 让 CPU 准备数据和模型计算并行,能省不少训练时间。

5.3 现象:预测时出现 NaN,或者 int() 转换报错

原因:训练过程梯度爆炸,loss 变成 NaN,模型权重已经废了。触发条件通常是学习率在小数据集上偏大,或者输入数据里有异常值,比如某条 MIDI 解析出的 pitch 是空字符串或 None,混进了整数转换。

解决:先检查 X 和 y 里有没有缺值,把包含 None 的样本过滤掉。再把优化器加上梯度裁剪:

from tensorflow.keras.optimizers import Adam model.compile( loss='sparse_categorical_crossentropy', optimizer=Adam(learning_rate=0.001, clipnorm=1.0) )

clipnorm=1.0 把梯度全局范数限制在 1 以内,挡住绝大多数梯度爆炸。如果 NaN 出现在训练后期,直接加载 checkpoint 里保存的最佳权重就行,不用重训。

5.4 现象:训练集 loss 很低,验证集 loss 居高不下

原因有两个,往往同时存在。滑窗重叠导致验证集里大量片段和训练集重叠,验证结果本身不可信;同时模型容量偏大,几万条样本确实容易过拟合。

解决:按文件切分数据而不是按窗口切分。把 MIDI 文件随机分成 90% 训练、10% 验证,每个文件内部的音符序列整体划入其中一个集合,再分别做滑窗。这样验证集和训练集没有任何重叠片段,指标才可信。同时把 Dropout 提到 0.4,或者把双层 LSTM 换成"一维卷积 + LSTM"的混合结构,卷积先提取局部模式,LSTM 再做长程建模,参数量更少,更难背题。

5.5 现象:训练特别慢,一个 epoch 要好几分钟

原因:CPU 上跑双层 256 单元 LSTM 本来就是最慢的组合;如果单层还慢,大概率是 batch_size 太小,Adam 更新频率过高。

解决:优先把 batch_size 提到 256 或 512,通常立竿见影;再不行就把 lstm_units 减半。单层 LSTM 加更大的 embedding_dim,速度能快一倍,效果不一定比双层差。实在没有 GPU,先把数据量减到 20 个 MIDI 文件,把全流程跑通再上全量数据。这是一条血泪教训:一上来就要效果,结果卡在训练速度上消磨热情,项目就烂尾了。

6. 从「能响」到「能听」:验证与调参技巧

6.1 用统计指标做快速体检

好不好听是主观的,但生成结果有没有模型崩溃,可以用统计指标快速判断。最常用的是音级分布(pitch class)对比:

from collections import Counter def pitch_class_distribution(notes): pcs = [] for token in notes: for p in token.split('.'): # 去掉八度数字,C4 -> C,F#5 -> F# pcs.append(p[:-1]) return Counter(pcs) train_dist = pitch_class_distribution(train_notes) gen_dist = pitch_class_distribution(generated_melody) print(train_dist.most_common(5)) print(gen_dist.most_common(5))

把训练集和生成结果的音级分布对比一下:如果生成结果只集中在两三个音级上,说明模型崩溃了;如果分布形状完全对不上,说明温度太高,模型在乱跳。这两种情况都值得在听之前先修正。

6.2 我的固定流程:一个种子、四个温度

每次训练完,我都用同一个种子片段,分别在 0.6、1.0、1.4、1.8 四个温度下各生成一份,存成四个 MIDI 文件,再戴耳机全部听一遍。这不是玄学,温度采样是生成阶段唯一能无成本调风格的旋钮,比重训快得多。四个版本都不满意,说明问题出在模型或数据而不是采样,才值得回去改训练集或网络结构。

这个项目做到这里,链路已经闭环:数据解析、序列建模、温度采样、MIDI 还原。它的边界我也可以直接说——学的是音符到音符的概率,不是编曲也不是配器,别指望它写出完整的作品。想再往前,就从基线上加时长 token、加多轨或者换更强的序列模型,每一步都有明确入口。我自己的习惯是每次改动都用同一套种子生成四份对比,听完再决定下一步,这个习惯帮我少走了很多弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询