拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RNN音乐生成实战:从MIDI预处理到PyTorch训练的完整指南

RNN音乐生成实战:从MIDI预处理到PyTorch训练的完整指南 简介Python 基于 RNN 循环神经网络的音乐生成 AI 项目是一份可直接运行的完整工程面向需要完成期末大作业、课程设计或入门深度学习音乐生成的开发者。工程内含 Python 源码、预训练模型权重及 MIDI 样例代码配有注释部署门槛较低适合新手参考。压缩包共 138 个文件以 .py 源码、.pth 参数、.ckpt 系列 checkpoint 和 .mid 音乐样例为主另有 .mscz 谱面、.gitignore 等项目辅助文件整体约 49.38MB。模型 checkpoint 保留多个迭代版本可对比不同训练步数的生成效果也便于继续微调或复现实验。目前已有 269 人学习/下载该资源。获取后既可以运行完整项目也能借助注释源码与示例输出梳理 RNN 生成音乐的流程对课程设计、期末大作业演示及进一步拓展音乐 AI 方向均有实际帮助。1. 用RNN在Python里生成音乐这类“高分项目”真正在解决什么问题MIDI音乐生成是RNN循环神经网络最经典的序列生成应用之一核心是把乐谱拆成一串有序的音符事件再靠隐状态记住“前面发生了什么”逐个预测下一个音。这类Python项目通常随源码和模型文件一起交付源码负责数据解析、训练与推理模型文件让你不重训也能直接听到生成效果。它适合两类人一是要做课程设计、比赛demo需要快速跑通一个完整AI生成链路的Python开发者二是想入门序列建模理解在大模型流行之前纯RNN怎么在几十首MIDI曲子上做出一台“会作曲的小机器”。短期价值是拿回一个能复现的基线长期价值是为后续换Transformer打底序列生成的那套采样与解码思路基本不变。2. 为什么音乐生成先选RNN而不是直接上大模型这个问题的答案藏在数据量里。你手里可能只有几十首MIDI曲目几百KB的文本级数据Transformer和大模型在这种量级下基本学不出音乐句法而RNN的参数量小、归纳偏置强在小语料上反而能学到旋律走向。再加上MIDI天然是时间序列RNN的隐状态就是为这种“读完前文预测下文”设计的。下面把选型逻辑拆成三个层次讲清楚。2.1 把MIDI谱面看成序列RNN建模音乐的三个前提音乐生成不能直接拿wav音频做16kHz以上的采样率配合几千秒的时长会让序列长度爆炸显存和训练时间都扛不住。常见做法是退一步处理MIDI每个音符有pitch、start、end、velocity四个属性丢给模型时不需要连续波形只需要离散事件。RNN能对上号靠三个前提。第一音乐具备局部条件性下一个音大概率由前几个音和当前和声走向决定这正是“语言模型式”的建模目标。第二一首歌转化成事件序列后通常只有几百到几千步GRU/LSTM的隐状态容量足够装下这些上下文而不像长篇小说那样需要注意力机制做远距离召回。第三生成任务允许误差即使某个音预测错听感只是“怪”不会像代码生成那样直接编译失败。后两条决定了RNN在音乐生成任务里一直没被淘汰。公式层面你不需要手写BPTTPyTorch的torch.nn.GRU已经把循环展开和反向传播封装好。理解到“隐状态随时间步更新、每个时间步输出一个预测分布”就够了落地时更关键的是数据怎么变成token、采样怎么控制随机性。2.2 LSTM与GRU怎么选参数量、训练速度与记忆长度看到rnn标签的项目第一反应是确认它底层用LSTM还是GRU。两者本质都是带门控的循环单元差别在门上。LSTM有输入门、遗忘门、输出门隐状态和细胞状态分开维护能记住更长远的依赖GRU只有更新门和重置门参数更少收敛更快。维度LSTMGRU每层参数量约4倍权重矩阵约3倍权重矩阵长期记忆能力强适合长旋律线中等适合短乐句小数据过拟合风险高低训练速度慢10%到20%更快可解释性门控多难调结构简单好改我的建议是如果这个项目只有几十首MIDI用GRU起步训练快、不容易过拟合如果扩充到几百首多风格曲目再切回LSTM记忆旋律线会更连贯。很多所谓“高分项目”源码里给的是LSTM你接手后可以不改模型结构直接训练但如果loss降不下去把nn.LSTM换成nn.GRU往往立竿见影。2.3 源码与模型文件的分工拿到项目后先跑推理还是先看训练这类项目目录通常分四块data/放原始MIDIsrc/放预处理和模型定义checkpoints/放模型文件output/放生成结果。模型文件常见.pt或.pth后缀里面是state_dict只含权重不含模型结构。所以加载前必须先按源码里的参数重建模型再执行load_state_dict。有个常见的翻车点跟“comfyui下载模型文件失败”的路径很像模型文件下载到一半、大小对不上加载时直接报EOFError或者有人在GPU机器上保存的权重你拿到CPU机器上加载报RuntimeError: Attempting to deserialize object on a CUDA device。处理方式是加载时强制指定map_locationcpu并在训练前确认模型文件字节数与源码里标注一致。拿到这类项目我建议先跑推理再碰训练。原因是训练脚本里的数据预处理可能有隐藏bug但推理脚本只要模型文件完整跑通后你马上能听到生成效果建立“这个项目是真的能出曲子”的体感后面debug训练时才有参照物。3. 用Python把MIDI解析成token序列数据预处理与滑窗切分预处理是这类项目最脏的环节也是决定生成质量的第一道关卡。RNN不认识MIDI文件只认整数ID所以要把MIDI翻译成有序的事件序列再映射成词表。整个过程分三步抽音符、转事件token、滑窗切样本。每一步都有细节走错一步后面训练全白费。3.1 用pretty_midi读入MIDI并抽取音符事件pretty_midi是把MIDI读成Python对象的常用库内部已经解析好音轨、乐器、音符起止时间。我这里只抽旋律轨鼓轨和低音轨先跳过避免打击乐的无调性音符污染旋律模型。import pretty_midi def midi_to_notes(midi_path: str) - list: 把一首midi里的所有音符抽出来按开始时间排序跳过鼓轨 midi pretty_midi.PrettyMIDI(midi_path) notes [] for inst in midi.instruments: # 鼓轨的is_drumTrue音高是打击乐映射不是旋律直接跳过 if inst.is_drum: continue for note in inst.notes: notes.append((note.start, note.pitch, note.velocity, note.end)) notes.sort(keylambda x: x[0]) # 按起始时间排序保证时间顺序不乱 return notes逻辑说明note.start和note.end单位是秒pretty_midi已经帮我们处理了MIDI tick到秒的换算is_drum判断在GM标准里对应第10轨跳过它才能保证pitch范围始终在0到127的旋律音域内。参数上不需要额外调整唯一要留意的是如果你的MIDI文件有多轨且各自记谱先把所有非鼓轨合并再排序顺序错乱会产生“前一个音符没结束、后一个已开始”的交叉后续token化会崩。3.2 事件到token的映射note_on、note_off与time_shift的组合直接把pitch数字当token是新手最常见的错误。纯pitch序列丢失了“音长”和“休止”两类信息RNN最后只能学到音高走向永远学不出节奏。正确做法是参考Magenta等项目的event sequence思路把时间也变成事件组合出一套包含TIME_、ON_、LEN_、OFF的token集合。STEP 0.2 # 时间量化粒度单位秒0.2秒相当于八分音符在100BPM下的长度 def notes_to_tokens(notes: list, step: float STEP) - list: tokens [] prev_end None for start, pitch, velocity, end in notes: # 前一个音符还没结束当前音符已开始先补一个OFF事件 if prev_end is not None and start - prev_end 1e-6: tokens.append(OFF) # 计算当前音符与上一个音符结束点之间的时间空隙量化成TIME_k if prev_end is None: gap round(start / step) else: gap round((start - prev_end) / step) k min(max(int(gap), 1), 32) # 时间差限制在1到32步之间防止出现极端休止符 tokens.append(fTIME_{k}) tokens.append(fON_{pitch}) tokens.append(fLEN_{max(int(round((end - start) / step)), 1)}) prev_end end if prev_end is not None: tokens.append(OFF) return tokens逻辑说明TIME_{k}编码休止或音符起始的相对时间差ON_{pitch}标记哪个音开始LEN_{n}标记持续时长OFF标记乐句结束。把gap限制在32步是为了控制词表大小否则时间差无限细分会让词表膨胀到几千RNN学不过来。STEP是关键参数调大到0.3序列更短、训练更快但会丢失十六分音符的细节调小到0.1节奏表达精细但一首歌的token数翻倍训练时间明显拉长。我的经验是第一版用0.2跑通确认能出歌后再按数据集风格微调。词表构建就是遍历所有曲目收集全部token并排序编号all_tokens [] for midi_path in midi_files: all_tokens.extend(notes_to_tokens(midi_to_notes(midi_path))) vocab sorted(set(all_tokens)) token2id {t: i for i, t in enumerate(vocab)} id2token {i: t for i, t in enumerate(vocab)}这一步的坑在于训练集和验证集必须共用同一份token2id不能在验证集上重新set(all_tokens)否则验证集里出现的生僻token会打破词表一致性推理时直接KeyError。3.3 滑窗采样与数据集划分序列长度100的样本长什么样RNN训练时不能把整首歌一次性塞进去歌太长会导致BPTT展开步数过多、显存爆炸。常见做法是滑窗切分固定窗口长度seq_len以固定步长stride滑动窗口内前seq_len个token是输入最后一个token的下一个token是预测目标。import torch def build_dataset(token_list: list, seq_len: int 100, stride: int 10): X, y [], [] for i in range(0, len(token_list) - seq_len, stride): X.append(token_list[i:i seq_len]) y.append(token_list[i seq_len]) # 目标永远是窗口外的下一个token return torch.LongTensor(X), torch.LongTensor(y) # 切分训练集和验证集注意用的是token_id token_ids [token2id[t] for t in all_tokens] X_all, y_all build_dataset(token_ids, seq_len100, stride10) n_train int(len(X_all) * 0.9) X_train, y_train X_all[:n_train], y_all[:n_train] X_val, y_val X_all[n_train:], y_all[n_train:]逻辑说明stride10意味着相邻两个样本之间有90个token重叠这相当于数据增强让模型在不同位置都能看到完整乐句但如果overlap过大训练集样本高度相似验证集loss会虚低所以stride一般取seq_len的十分之一到五分之一。窗口长度100大约能覆盖一首歌的4到8个小节足够让GRU学到乐句级依赖。这里有个细节X是torch.LongTensor因为nn.Embedding不接受浮点索引报错时先检查数据类型。4. RNN训练PyTorch里的GRU搭建与超参数怎么定数据准备好后训练环节反而是最“模板化”的。模型骨架永远是Embedding加循环层加全连接训练循环永远是交叉熵加梯度裁剪。可调的关键参数集中在hidden_size、dropout和学习率上。这一章直接给可复制的训练代码和一套能收敛的默认参数。4.1 嵌入层GRU全连接的模型骨架词表里每个token是一个离散ID不能直接喂给循环层要先过Embedding转成稠密向量。GRU负责在这些向量上滚动维护隐状态最后用全连接层把隐状态映射回词表大小得到下一个token的概率分布。import torch.nn as nn class MusicRNN(nn.Module): def __init__(self, vocab_size: int, embed_dim: int 128, hidden_size: int 256, num_layers: int 2, dropout: float 0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) # batch_firstTrue让输入形状为(batch, seq_len)更直观 self.rnn nn.GRU(embed_dim, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, embed_dim) out, hidden self.rnn(emb, hidden) # out: (batch, seq_len, hidden) logits self.fc(out[:, -1, :]) # 只用最后一个时间步的输出 return logits, hidden逻辑说明out[:, -1, :]取序列最后一个位置是因为我们是在seq_len窗口的末尾做预测前面的隐状态都浓缩在这个输出里。hidden可以传None表示从头开始训练时每batch都要重置否则上一个batch的隐状态串进来导致loss震荡。embed_dim128在词表几百个token时已经够用再大只增加参数量和过拟合风险。整套模型的参数量大约在130万上下CPU训练也能接受单卡GPU几十秒跑一个epoch。4.2 训练循环teacher forcing与交叉熵loss各自的作用训练时喂给模型的是真实历史token而不是模型自己生成的token这叫teacher forcing。它的作用类似“老师拿着标准答案一步步带”让模型在每一步都基于正确上文做预测loss下降稳定。推理时没有标准答案模型拿自己的预测当下一个输入误差会累积所以训练loss和生成质量之间不完全等价。import torch.nn.functional as F def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0 for x, y in dataloader: x, y x.to(device), y.to(device) hidden None # 每个batch重置隐状态防止跨batch泄漏 optimizer.zero_grad() logits, hidden model(x, hidden) # y形状是(batch,)logits是(batch, vocab_size) loss F.cross_entropy(logits, y) loss.backward() # 梯度裁剪RNN在长序列上BPTT容易梯度爆炸不裁loss直接变NaN nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)逻辑说明clip_grad_norm_的max_norm设成5.0是一个比较稳的经验值。梯度爆炸在RNN里比CNN常见得多根源是反传路径长度等于序列长度100梯度连乘容易指数级放大。如果你发现loss在某个epoch后突然跳成inf第一件事就是检查有没有梯度裁剪。交叉熵loss在这里纯粹是“下一个token分类”的负对数似然不需要额外加权除非你发现TIME类token占比过高导致模型偷懒。4.3 学习率、batch、序列长度与dropout一套能收敛的默认参数很多拿到源码的朋友第一反应是调大模型结果训练半天不收敛。这类MIDI音乐生成项目并不吃模型规模吃的是数据质量和超参数匹配度。下面是我调过多个类似项目后认为最稳的默认组合。参数推荐值说明seq_len100太短学不到乐句太长显存压力大batch_size648G显存GPU无压力CPU也能跑embed_dim128词表几百个单位时128已经足够hidden_size2562层GRU下总参数约130万num_layers21层欠拟合3层小数据过拟合dropout0.3放在GRU层之间和fc前防死记硬背学习率1e-3Adam优化器超过1e-3容易不收敛epochs30到80以验证集loss早停为准训练时一定要做早停保存验证集loss最小的权重而不是最后一个epoch的权重。RNN训练到后期会过拟合训练集生成结果变成“复读机”验证集loss能诚实反映这一点听感也能验证。optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_val float(inf) for epoch in range(80): train_loss train_one_epoch(model, train_loader, optimizer, device) val_loss evaluate(model, val_loader, device) if val_loss best_val: best_val val_loss torch.save(model.state_dict(), music_rnn_best.pt) print(fepoch {epoch}: saved, val_loss{val_loss:.4f})一个玄学经验训练loss曲线平滑下降不代表生成好听因为听感是主观的、而且采样策略的影响远大于训练精度的微小差异。我通常以“val_loss最小”的权重为基线再对比几个temperature值挑听感。5. 推理生成与5个高频踩坑从tokens回到能听的MIDI推理阶段决定了你最终能不能交差。训练只是把权重文件炼出来推理时怎么采样、怎么还原MIDI、怎么处理各种边界情况才是这类项目真正值钱的部分。同时也是踩坑最密集的区域下面从采样策略讲到还原代码再给5条血泪经验。5.1 temperature采样为什么argmax生成的音乐呆板用argmax取概率最高的token会让每次生成都选“最安全”的音结果就是同一段旋律反复出现像卡了带的复读机。常见做法是引入temperature参数先对logits除以温度再做softmax然后按概率分布抽样。def sample_token(logits, temperature0.9): # temperature越小分布越尖锐越大越均匀 logits logits / temperature probs torch.softmax(logits, dim-1) # multinomial按概率抽样避免永远选最大概率项 idx torch.multinomial(probs, num_samples1).item() return idx参数说明temperature0.7时生成旋律保守但结构稳定temperature1.0时随机性增加偶尔会出现惊艳但更多时候是跑调temperature1.2以上基本就是无调性实验音乐。我的做法是以0.8为起点以0.05为步长在这个区间做几次生成试听。注意multinomial返回的是LongTensor记得.item()取成Python int才能做索引。5.2 把token序列还原成MIDI注意时间刻度与音轨合并生成完的token_ids要解码回MIDI文件。这里有两个高频坑一是MIDI的时间单位是tick不是秒二是多轨合并时要保证所有音符共用同一个track号。from midiutil import MIDIFile def tokens_to_midi(token_ids: list, id2token: dict, out_path: str): midi MIDIFile(1) # 单轨足够多轨反而造成播放器音色冲突 track 0 midi.addTempo(track, 0, 100) # 100BPM与预处理时的时间基准对应 t 0.0 for tid in token_ids: tok id2token[tid] if tok.startswith(TIME_): t int(tok.split(_)[1]) * 0.2 # STEP必须和预处理一致 elif tok.startswith(ON_): pitch int(tok.split(_)[1]) # 在t位置下加一个音持续0.8秒力度80 midi.addNote(track, 0, pitch, t, 0.8, 80) with open(out_path, wb) as f: midi.writeFile(f)逻辑说明这个简化版没有处理LEN_和OFF固定音长0.8秒好处是代码短、容易debug缺点是节奏感被压平。要带音符长度需要维护一个{起始时间: 音高}列表遇到LEN_时再补全addNote的duration参数。留意STEP0.2这个值必须和3.2节里预处理时完全一致否则生成出来的曲子节奏会比原曲快几倍或慢几倍听感全崩。5.3 避坑记录5个高频踩坑现象、原因与解决1. 训练loss迟迟不降现象loss在3左右抖动50个epoch也不见明显下降。原因多半是数据预处理阶段token拼错比如ON_事件里混入了鼓轨pitch或者TIME_的间隔计算成负值被max(1)钳掉导致模型学到大量重复的TIME_1。解决训练前随机打印token_ids前50个值对照原始MIDI人工核对一遍先用CPU单batch跑通一次forwardbackward确认没有NaN和shape异常再上GPU。2. 生成结果变成复读机同一个音高无限重复现象temperature调大也没用生成的pitch集中在两三个值。原因数据集里TIME类token占比过高模型发现预测TIME的loss最小于是疯狂生成TIME_跳过有效音符或者dropout太低导致模型记住了训练集的低频段而不敢换pitch。解决把dropout提到0.4在采样时限制连续出现TIME_的次数比如连续超过16个TIME就强制采样ON_类token。3. 模型文件加载失败报KeyError或EOFError现象torch.load(music_rnn_best.pt)直接抛异常或者加载后输出全是0。原因模型文件下载不完整或者训练时保存的是GPU权重推理机器只有CPU还有可能是model.load_state_dict前没按相同参数重建模型。解决加载时写torch.load(path, map_locationcpu)检查文件大小是否和非空用源码里的MusicRNN(vocab_size, ...)同参数实例化后再load。4. 推理到一半显存/内存爆掉现象生成到200个token后程序卡死或OOM。原因循环生成时把整个序列都保存进了计算图没有用torch.no_grad()或者每次迭代都在累积hidden状态shape越来越大。解决推理循环整体包在with torch.no_grad():里hidden每次迭代只保留最新状态不保存历史列表。5. 生成的MIDI打不开或全是乱音现象MIDI在播放器里是一堆尖锐噪音或者整个文件损坏打不开。原因token序列里混进了鼓轨的pitch值也可能TIME_还原时步长和预处理不一致导致音符时间坐标出现负数或超大值。解决预处理阶段严格跳过is_drum轨还原前检查生成token里是否存在pitch127的非法值把输出MIDI拿到DAW或VLC里试听不要只在代码里看波形。6. 让生成结果更像作品的进阶和弦约束与多样性验证当基础流程跑通后提升听感的关键往往不在加深网络而在给模型加一点结构先验。一个低成本做法是往token序列里插入和弦根音标签在每个小节起始处根据原始MIDI的低音轨或手工标好的和弦级数插入一个CHORD_{root}token。模型会把这当成“当前乐段的和声背景”生成旋律时会自动避开与和声冲突的音。对RNN来说这只是一个额外的词表条目不需要改网络结构但对生成结果的调性稳定很有帮助。另一个习惯是用固定seed做对照实验。每训练完一个epoch用同一个torch.manual_seed(42)生成一小段听这20秒里旋律变化的趋势比盯着loss曲线诚实得多。为了量化验证生成多样性我一般统计三件事生成序列中连续相同pitch的比例pitch直方图与训练集的KL距离以及每32个token里TIME_的占比。如果TIME_占比超过70%说明模型在逃避音符生成需要回到采样策略上调整。最后把temperature在0.6到1.2之间以0.1为间隔各生成三首人工试听选一首最顺耳的作为基线成品。这个步骤做下来外人再听你的项目输出就不会只评价“能响”而是会问“这是怎么写的曲子”。我个人的教训是这类RNN音乐生成项目里训练代码反而是最好写的部分真正花时间的永远是数据处理和采样调试。如果你准备复现这个方向先把推理脚本写好再去碰训练这样每个epoch结束都能立刻试听反馈。希望帮到你。本文还有配套的精品资源点击获取
返回列表