ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LSTM驱动的MIDI旋律生成:从数据预处理到采样实践

LSTM驱动的MIDI旋律生成:从数据预处理到采样实践 简介一个基于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要做的事说白了就是扫描每个文件里的音符把它们拼成一条长序列再做编号映射。序列里的元素是C4、F#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保留了八度信息。C4和C5在词表里是两个不同token模型才能分辨旋律走向。和弦被拼成E4.G4.B4这样的字符串对模型来说它只是一个特殊token输出层预测时会把整个和弦当作一个事件来生成。这样处理省去了复杂的时长对齐逻辑缺点是声部被压扁成单序列多条旋律线同时进行的结构会丢失属于项目明确取舍掉的部分。解析完成后建立音名到整数的映射表。词表大小由语料中出现的不同音符个数决定大约几十个。映射字典在训练和生成阶段必须共用所以生成脚本里要重新加载同一个字典不能靠模型内部反推编号。2.3 定长窗口切分与训练样本构造得到整条音符序列后下一步按滑动窗口切样本。给定前100个音符预测第101个音符和语言模型处理文本的方式完全一致。def prepare_sequences(notes, sequence_len100): # 建立音名 - 整数 的双向映射 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仍然能生成调性相对明确、有明显重复动机的旋律。那为什么不直接换GRUGRU结构更少、参数更小在极小语料下训练更稳定但门控细节不如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_dim128, lstm_units256): model Sequential([ # 把音符编号映射成稠密向量 Embedding(vocab_size, embed_dim, input_lengthsequence_len), # 第一层 LSTM 输出每个时刻的隐藏状态 LSTM(lstm_units, return_sequencesTrue), Dropout(0.3), # 第二层 LSTM 只保留最后时刻的输出 LSTM(lstm_units), # 输出每个候选音符的概率 Dense(vocab_size, activationsoftmax) ]) model.compile( losssparse_categorical_crossentropy, optimizeradam, metrics[accuracy] ) return model第一层LSTM必须设置return_sequencesTrue因为第二层LSTM需要接收每个时间步的输出只看最后时刻会把大量中间信息直接丢弃。Embedding层把音符编号映射成128维向量训练后相邻音高在向量空间里也会靠近模型能学到C4和C#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_size50粗算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_len100) X np.array(X, dtypenp.float32) y np.array(y, dtypenp.int32) # 3. 构建模型并训练 model build_model(vocab_sizelen(note_to_int), sequence_len100) history model.fit( X, y, batch_size64, epochs50, validation_split0.1 ) model.save(music_lstm.h5)validation_split0.1表示从序列尾部切出10%作为验证集。这个切法默认不打乱数据所以验证集对应语料里最后几首曲子可以看成对未见过片段的模拟预测。如果想让验证集更有代表性可以在切分前用np.random.shuffle打乱样本但要注意打乱会破坏曲目边界训练出的模型在长乐句生成上可能变差。4.2 超参数调试顺序与典型现象超参数之间互相牵制调参时建议一次只动一个变量按表格里的顺序走。超参数保守起步值出现什么现象时调整调整方向sequence_len100生成的乐句前后不连贯加到160同时观察样本量batch_size64损失震荡或显存不足降到32learning_rate1e-3损失不降或跳动用学习率衰减每5轮减半lstm_units256训练损失低但验证损失高降到128dropout0.3过拟合特征明显升到0.4-0.5epochs5030轮后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, inPlaceTrue) 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, length200, temperature1.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, verbose0)[0] # temperature 缩放越接近 0 越保守越大越随机 logits np.log(pred 1e-7) / temperature pred np.exp(logits) / np.sum(logits) idx np.random.choice(len(pred), ppred) note int_to_note[idx] result.append(note) seq.append(idx) seq seq[-100:] # 滑动窗口前移 return resulttemperature 小于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无关。本文还有配套的精品资源点击获取
返回列表