LSTM驱动的MIDI旋律生成:从数据预处理到采样实践
2026/9/15 1:58:36 网站建设 项目流程

简介:一个基于LSTM(长短期记忆网络)的AI音乐生成器项目,使用Python实现,面向对深度学习、音乐生成感兴趣的学习者或开发者,也适合作为入门序列建模的动手实践。资源包共56个文件,体量仅723KB,包含4个Python脚本(模型定义、训练、生成与工具函数)、46个MIDI音乐样本、5张图片(乐理知识、网络结构、中间结果等)及1个说明文档,结构清晰,便于快速上手。已有181人学习下载,可以搭配文档边读边跑代码,项目自带数据样本,即使没有额外语料也能直接运行演示。通过该项目,读者能了解如何将MIDI数据预处理为音符序列,借助Keras或PyTorch构建LSTM网络并训练,最终生成风格化的旋律;项目中的样本文件与结构图示也能辅助理解音符表示与模型组织方式,对独立复现和二次开发都很有帮助。整体代码量不大,适合作为课程设计或毕业设计的参考资料,帮助快速掌握循环神经网络的实践用法。

1. 从MIDI到旋律:为什么LSTM是AI作曲的实用路径

想先把旋律续写跑通,最合适的起点不是海量音频,而是MIDI。MIDI记录的是音高、时值、力度这类离散事件,一首曲子天然就是符号序列,这正好喂得进为序列建模设计的LSTM。ai_music-master 就是走通这条方案的工程实现:40多首MIDI做语料,Python脚本完成解析、训练、生成三段流程,最终能输出一段新旋律。它不追求复杂配器,不涉及波形信号,适合正在学RNN/LSTM的开发者,也适合刚接触音乐数据、想弄清楚MIDI如何对接神经网络的入门者。项目骨架半小时能读完,但预处理、采样、超参数这些问题,和工业级序列生成任务完全同源。

2. 数据预处理:把MIDI拆成LSTM能读的音符序列

2.1 为什么MIDI比音频更适合旋律建模

做音乐生成,第一个岔路口是选音频还是选MIDI。音频在44.1kHz采样率下,一分钟就有260多万个采样点,模型要在密集波形上同时完成音高识别和旋律预测,这在小数据集上几乎无法收敛。MIDI则是事件序列,音符的开始时刻、音高、时值、力度都以明文记录,等于把旋律天然压缩成了token。LSTM处理的是有序离散符号,MIDI和它的适配度远高于波形。

这个项目的语料是midi/目录下的40多个mid文件。utils.py要做的事,说白了就是扫描每个文件里的音符,把它们拼成一条长序列,再做编号映射。序列里的元素是C4F#5这样的音名,或者E4.G4.B4这种用点分隔的和弦块。MIDI里其实有节拍信息,但项目把它忽略了,模型只关心音符的先后顺序,不关心每个音实际持续多久。这个取舍决定了生成结果重在旋律轮廓,节奏只能从音符密度间接体现。

2.2 音符提取与编号映射

解析MIDI最常用的库是 music21,它自带乐理建模,比直接用mido解析原始事件更省事。核心做法是递归遍历乐谱对象,遇到单音记录音名,遇到和弦把多个音名拼成一个token。

# utils.py 核心逻辑:MIDI 文件 -> 音符字符串列表 from music21 import converter, note, chord def midi_to_notes(filepath): score = converter.parse(filepath) notes = [] for element in score.recurse(): # Note 代表一个单音,nameWithOctave 输出类似 C4、F#5 if isinstance(element, note.Note): notes.append(element.pitch.nameWithOctave) # Chord 代表多个音同时发声,用点分隔防止 token 冲突 elif isinstance(element, chord.Chord): notes.append('.'.join( p.nameWithOctave for p in element.pitches )) return notes

这里的关键是nameWithOctave保留了八度信息。C4C5在词表里是两个不同token,模型才能分辨旋律走向。和弦被拼成E4.G4.B4这样的字符串,对模型来说它只是一个特殊token,输出层预测时会把整个和弦当作一个事件来生成。这样处理省去了复杂的时长对齐逻辑,缺点是声部被压扁成单序列,多条旋律线同时进行的结构会丢失,属于项目明确取舍掉的部分。

解析完成后,建立音名到整数的映射表。词表大小由语料中出现的不同音符个数决定,大约几十个。映射字典在训练和生成阶段必须共用,所以生成脚本里要重新加载同一个字典,不能靠模型内部反推编号。

2.3 定长窗口切分与训练样本构造

得到整条音符序列后,下一步按滑动窗口切样本。给定前100个音符,预测第101个音符,和语言模型处理文本的方式完全一致。

def prepare_sequences(notes, sequence_len=100): # 建立音名 -> 整数 的双向映射 pitch_names = sorted(set(notes)) note_to_int = dict((p, i) for i, p in enumerate(pitch_names)) inputs, targets = [], [] for i in range(len(notes) - sequence_len): seq_in = notes[i:i + sequence_len] seq_out = notes[i + sequence_len] inputs.append([note_to_int[n] for n in seq_in]) targets.append(note_to_int[seq_out]) return inputs, targets, note_to_int

滑窗结束后,每个样本输入是长度sequence_len的整数数组,标签是该窗口后一个音符的编号。输入实际传进网络时是二维张量(batch, sequence_len),标签是一维整数数组。

序列长度是这里最重要的超参数,直接影响样本数量和上下文长度。

sequence_len样本数量上下文能力适用场景
60多,训练快只能覆盖 1-2 小节语料稀疏或试验性训练
100适中覆盖 4-6 小节的句式本项目默认值
200明显减少更长乐句但容易跨文件拼接语料充足且风格统一

多个MIDI文件拼成一条长序列时,窗口如果横跨两首曲子,模型会把一首曲子的尾音和下一首曲子的开头混在同一上下文里。我一般会在每首曲子开头插入一个START token来切分边界,或者取窗口时限制在同一文件内取值,这样生成的乐句不会在两首曲子的风格之间漂移。

提示:调整sequence_len后要重新执行完整预处理,不能只改训练脚本里的一个数字,否则输入维度对不上。

3. 网络构建:Embedding加双层LSTM的设计

3.1 LSTM的门控机制在旋律上的体现

LSTM给RNN增加了一条贯穿时间步的单元状态线,信息在其中流动时只经过少量线性变换,梯度消失问题被大幅缓解。具体到每个时刻,遗忘门决定上一时刻状态保留多少,输入门决定新音符的候选状态写入多少,输出门控制暴露给下一层的信息量。这些门在旋律上的对应关系很直观:连续重复的小节会让遗忘门打开以保留动机,出现大跳音程时输入门会记下有特征变化的片段。这也是为什么在几十首曲子的训练量上,双层LSTM仍然能生成调性相对明确、有明显重复动机的旋律。

那为什么不直接换GRU?GRU结构更少、参数更小,在极小语料下训练更稳定,但门控细节不如LSTM丰富。实际工程里的判断标准是:如果只做快速验证,GRU够用;如果后续要在这个基础上加入编曲、多声部等特征,LSTM的边界会更清晰。两类代码几乎只差一层参数,替换成本很低。

3.2 network.py 的模型结构与参数量

network.py里的模型用Keras的Sequential搭建,结构为Embedding、两层LSTM、一个Dense输出层。

# network.py - LSTM 音乐生成模型 from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dropout, Dense def build_model(vocab_size, sequence_len, embed_dim=128, lstm_units=256): model = Sequential([ # 把音符编号映射成稠密向量 Embedding(vocab_size, embed_dim, input_length=sequence_len), # 第一层 LSTM 输出每个时刻的隐藏状态 LSTM(lstm_units, return_sequences=True), Dropout(0.3), # 第二层 LSTM 只保留最后时刻的输出 LSTM(lstm_units), # 输出每个候选音符的概率 Dense(vocab_size, activation='softmax') ]) model.compile( loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy'] ) return model

第一层LSTM必须设置return_sequences=True,因为第二层LSTM需要接收每个时间步的输出,只看最后时刻会把大量中间信息直接丢弃。Embedding层把音符编号映射成128维向量,训练后相邻音高在向量空间里也会靠近,模型能学到C4C#4的相关性,这是one-hot编码做不到的。

各层的输出形状和定位如下。

输出形状主要作用
Embedding(batch, 100, 128)编号转稠密向量,保留音符间相似性
LSTM(256)(batch, 100, 256)捕捉短距离旋律模式
Dropout(0.3)(batch, 100, 256)防止小语料过拟合
LSTM(256)(batch, 256)汇聚成单一上下文向量
Dense(vocab_size)(batch, vocab_size)输出下一个音符的概率分布

按照vocab_size=50粗算:Embedding参数是6400,第一个LSTM约39万,第二个LSTM约52万,Dense约1.3万,合计不到100万参数量。这个规模在不带GPU的CPU机器上也能完成训练,单轮时间不会太长。

3.3 损失函数选型与输出层意义

输出层接softmax,得到词表上每个音符的预测概率。损失函数用sparse_categorical_crossentropy,标签直接是整数索引,不需要额外做one-hot展开。在小词表场景下,这种写法和categorical_crossentropy效果几乎一样,但省去了构建softmax标签矩阵的内存开销。

一个容易被忽略的细节是语料构成对生成风格的直接影响。如果训练数据里和弦token出现频率高,模型就会更倾向生成和弦;反之,如果和弦只存在于个别文件,模型最终学到的更偏向单音旋律。词表里每个token的频率分布,实际上就是模型输出概率的隐含先验。

4. 训练与调参:把序列数据喂进模型

4.1 train.py 训练流程

训练入口train.py的流程很直接:扫描所有mid文件,解析成音符序列,切成窗口,建模型,调用fit

# train.py 训练入口 import glob import numpy as np from utils import midi_to_notes, prepare_sequences from network import build_model # 1. 扫描 midi 目录下所有文件并拼接音符序列 notes = [] for filepath in glob.glob('midi/*.mid'): notes.extend(midi_to_notes(filepath)) # 2. 切分输入与标签 X, y, note_to_int = prepare_sequences(notes, sequence_len=100) X = np.array(X, dtype=np.float32) y = np.array(y, dtype=np.int32) # 3. 构建模型并训练 model = build_model(vocab_size=len(note_to_int), sequence_len=100) history = model.fit( X, y, batch_size=64, epochs=50, validation_split=0.1 ) model.save('music_lstm.h5')

validation_split=0.1表示从序列尾部切出10%作为验证集。这个切法默认不打乱数据,所以验证集对应语料里最后几首曲子,可以看成对未见过片段的模拟预测。如果想让验证集更有代表性,可以在切分前用np.random.shuffle打乱样本,但要注意打乱会破坏曲目边界,训练出的模型在长乐句生成上可能变差。

4.2 超参数调试顺序与典型现象

超参数之间互相牵制,调参时建议一次只动一个变量,按表格里的顺序走。

超参数保守起步值出现什么现象时调整调整方向
sequence_len100生成的乐句前后不连贯加到160,同时观察样本量
batch_size64损失震荡或显存不足降到32
learning_rate1e-3损失不降或跳动用学习率衰减,每5轮减半
lstm_units256训练损失低但验证损失高降到128
dropout0.3过拟合特征明显升到0.4-0.5
epochs5030轮后val_loss仍缓慢下降取消早停,继续跑

训练中最常见的现象是前几轮loss快速下降,之后进入平台期。这不是模型坏了,而是LSTM已经在把高频音符分布学好,剩下的低频音符转移规则需要更长时间。此时直接把学习率从1e-3改成3e-4往往比增加epoch数更有效。

另一个常见问题是loss曲线在20轮附近开始反弹。这是典型的过拟合信号,语料只有40多首曲子,模型很容易记住训练集里的乐句而不是抽象出风格。看到这种曲线,先加dropout,再做数据增强,不要急着加层数。

4.3 小数据过拟合与数据增强

MIDI领域最有效的数据增强是移调,也就是把整首曲子上移或下移几个半音。旋律的相对音程不变,但调性发生变化,语料规模可以成倍扩充。

# 数据增强:整体移调,保留旋律形状 from music21 import note, chord def transpose_notes(notes_seq, semitones): changed = [] for token in notes_seq: # 和弦暂时跳过,避免内部音程逻辑变复杂 if '.' in token: changed.append(token) continue n = note.Note(token) n.transpose(semitones, inPlace=True) changed.append(n.nameWithOctave) return changed # 使用示例:把原语料分别上移 2 和 5 个半音 notes_aug = [] for midi_file in all_files: seq = midi_to_notes(midi_file) for shift in (0, 2, 5): notes_aug.extend(transpose_notes(seq, shift))

移调范围控制在3到5个半音内,避免生成时出现超出原曲音域的高音或低音。数据量扩充后词表也会变大,个别低音区的音出现频率会变得很稀疏,这时候要观察loss曲线的波动幅度,如果验证loss比扩充前更平稳,说明增强有效。

注意:不是所有MIDI都适合直接移调。使用非C调记谱的和声结构在移调后可能产生不自然的音高组合,处理前先随机抽几首生成语料听一遍。

5. 生成与采样:Temperature控制旋律多样性

5.1 generate.py 的采样过程

训练完成后,生成阶段的关键不是模型结构,而是采样策略。generate.py加载保存的模型和映射字典,用一段 seed 音符作为起点,逐帧预测下一个音符。

# generate.py 生成逻辑 import numpy as np def generate_music(model, note_to_int, seed, length=200, temperature=1.0): int_to_note = {i: n for n, i in note_to_int.items()} # seed 长度必须大于等于 sequence_len,不足时用 0 号音符补齐 seq = [note_to_int[n] for n in seed[-100:]] result = seed[-100:] for _ in range(length): x = np.array(seq).reshape(1, 100) pred = model.predict(x, verbose=0)[0] # temperature 缩放:越接近 0 越保守,越大越随机 logits = np.log(pred + 1e-7) / temperature pred = np.exp(logits) / np.sum(logits) idx = np.random.choice(len(pred), p=pred) note = int_to_note[idx] result.append(note) seq.append(idx) seq = seq[-100:] # 滑动窗口前移 return result

temperature 小于1会把概率分布拉尖锐,模型倾向于选择最高概率音符,旋律平稳但容易重复;大于1会把分布拉平,低概率音符也有机会被选中,丰富度提升但跑调风险增大。seed 建议从训练集里取一段连续片段,而不是凭空构造,因为模型只见过训练分布里的上下文。

5.2 生成结果的快速检查

生成完的旋律不要急着播放,先做两个客观检查。第一个是统计相邻音符的音程跨度,如果连续出现超过12个半音的跳跃,说明模型在硬凑调性,此时temperature调高太多,应该从1.2回调到0.9附近。第二个是生成四段seed相同的旋律,比较前16个音符的重复率:四段几乎一样说明temperature太低,输出过于保守;四段差异巨大且明显走音说明temperature偏高。

5.3 时序特征缺失的应对技巧

这个项目没有建模时值和力度,模型只能在音高序列上做预测,生成的节奏信息完全来自训练语料中音符出现的密度。如果生成的旋律听起来节奏单一,可以用 music21 给输出音符统一套上预设时值,或者用midi库在后处理阶段按固定节拍网格量化。想进一步提升生成质量,常见的做法是把sequence_len提高到160,并去掉和弦token让模型专注于单音旋律线,这会显著减少高音区的噪声。

如果在0.8到1.2范围内仍找不到合适的temperature,先检查seed段落里是否包含大量和弦块,替换成一段干净的单音旋律再跑一遍温度梯度测试。多数情况下,问题不在网络结构,而在起始上下文的统计特征——模型被一段少见和弦序列带偏了,和temperature无关。

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

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

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

立即咨询