☰
RNN音乐生成实战:从MIDI预处理到PyTorch训练的完整指南
2026/9/28 13:57:48 网站建设 项目流程

简介: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在音乐生成任务里一直没被淘汰。

公式层面你不需要手写BPTT,PyTorch的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/放原始MIDI,src/放预处理和模型定义,checkpoints/放模型文件,output/放生成结果。模型文件常见.pt或.pth后缀,里面是state_dict,只含权重,不含模型结构。所以加载前必须先按源码里的参数重建模型,再执行load_state_dict。

有个常见的翻车点,跟“comfyui下载模型文件失败”的路径很像:模型文件下载到一半、大小对不上,加载时直接报EOFError;或者有人在GPU机器上保存的权重,你拿到CPU机器上加载,报RuntimeError: Attempting to deserialize object on a CUDA device。处理方式是加载时强制指定map_location='cpu',并在训练前确认模型文件字节数与源码里标注一致。

拿到这类项目我建议先跑推理再碰训练。原因是训练脚本里的数据预处理可能有隐藏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_drum=True,音高是打击乐映射,不是旋律,直接跳过 if inst.is_drum: continue for note in inst.notes: notes.append((note.start, note.pitch, note.velocity, note.end)) notes.sort(key=lambda 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(f"TIME_{k}") tokens.append(f"ON_{pitch}") tokens.append(f"LEN_{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_len=100, stride=10) 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:]

逻辑说明:stride=10意味着相邻两个样本之间有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_first=True让输入形状为(batch, seq_len),更直观 self.rnn = nn.GRU(embed_dim, hidden_size, num_layers, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden=None): 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_dim=128在词表几百个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(), lr=1e-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(f"epoch {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, temperature=0.9): # temperature越小分布越尖锐,越大越均匀 logits = logits / temperature probs = torch.softmax(logits, dim=-1) # multinomial按概率抽样,避免永远选最大概率项 idx = torch.multinomial(probs, num_samples=1).item() return idx

参数说明:temperature=0.7时生成旋律保守但结构稳定;temperature=1.0时随机性增加,偶尔会出现惊艳但更多时候是跑调;temperature=1.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参数。留意STEP=0.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跑通一次forward+backward,确认没有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_location='cpu');检查文件大小是否和非空;用源码里的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里是否存在pitch>127的非法值;把输出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结束都能立刻试听反馈。希望帮到你。

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

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

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

立即咨询