
简介基于RNN循环神经网络的音乐生成AI项目提供完整Python源码与预训练模型文件面向Python初学者、机器学习爱好者以及需要完成课程设计、期末大作业的高校学生帮助理解循环神经网络在音乐这类序列数据上的建模与生成方式。压缩包共138个文件总大小约49.38MB主要包含14个Python脚本覆盖数据预处理、RNN模型构建、训练与采样生成等环节13个pth模型权重文件与50组data/index模型检查点对应不同训练阶段的模型状态7个mid示例音乐和1个mscz工程文件可直接播放和编辑生成的乐谱。目前已有269人学习下载代码附有详细注释新手也能较快上手下载解压后简单配置即可运行使用。资源内置多个训练周期的检查点便于对比不同迭代次数下的生成效果系统功能完整、目录清晰、管理便捷作为课程设计或期末大作业的高分参考具有较高的实际应用价值。1. 音乐生成项目为什么选RNN这是序列问题不是图像问题这个标题对应的项目本质是“让RNN学会续写旋律”。Python基于RNN循环神经网络的音乐生成AI项目输入一批MIDI文件训练一个循环神经网络输出能跟着调的“下一个音符的概率分布”项目源码里包含数据解析、模型搭建、训练和推理四部分模型文件则存着训练好的权重和字典。为什么音乐生成偏爱RNN图片是二维矩阵卷积网络处理它很自然旋律是一维事件流每一个音符都依赖前面的音高、时值与节奏语境这正是循环神经网络最擅长的“带记忆的序列建模”。如果你正为课程设计或毕业设计找方向或者想把序列模型从理论上落到可听的产出这个项目是回报率很高的一步数据集免费易得训练在CPU上也能跑完翻车点集中且可排查。2. 从MIDI到训练样本解析音符、量化时值、构造滑动窗口2.1 为什么用MIDI而不是波形音频先做减法这个项目不从MP3/WAV学。音频波形采样率通常是22050或44100每秒几万个浮点直接喂RNN计算量爆炸而且模型学到的只是“声波到声波”的映射很难抽象出音符、和弦、节奏这些音乐概念。MIDI文件记录的是音符事件本身哪个音、什么时候按下、什么时候松开、力度多大信息密度高是音乐生成最常用的一种中间表示。很多开源音乐生成项目的数据处理链路都是从MIDI起步的。常见的做法是收集一批风格统一的MIDI文件比如巴赫众赞歌、民谣单旋律然后用Mido解析。Mido是Python里最轻量的MIDI读写库不需要安装额外的系统依赖pip install mido就能用。解析时最有用的消息类型是note_on和note_offnote_on表示音符按下note_off表示松开note字段是MIDI音高编号0到12760是中央C。解析代码要特别注意tick的算法。Mido迭代文件时每条消息的time字段是相对上一条消息的delta时间而不是绝对时间所以要自己维护一个累加器才能算出某个音符从按下到松开共经过了多久。我一般会这样写from mido import MidiFile def midi_to_notes(midi_path, max_notes500): mid MidiFile(midi_path) tick 0 note_on_tick {} notes [] for msg in mid: tick msg.time if msg.type note_on and msg.velocity 0: note_on_tick[msg.note] tick elif msg.type note_off or (msg.type note_on and msg.velocity 0): if msg.note in note_on_tick: start_tick note_on_tick.pop(msg.note) duration tick - start_tick notes.append((msg.note, duration)) if len(notes) max_notes: break return notes这段代码的逻辑是遇到note_on就把音符编号和当前tick存进字典遇到note_off或velocity为0的note_on就把对应音符取出用当前tick减去起始tick得到时值。Mido的MidiFile在直接迭代时会按绝对时间顺序把多条音轨合在一起所以旋律轨和伴奏轨会自然融合你不需要自己写复杂的轨道合并逻辑。注意两个细节。第一如果同一个音高同时按了两次叠音、同音反复后一个note_on会覆盖前一个的起始时间前一个音符就丢了单旋律项目里可接受钢琴曲项目里建议改用“音符序号加音高”的双层栈处理。第二max_notes用来截断过长的乐曲避免训练样本长度差异过大通常500个音符足够覆盖一首短曲的高潮部分。2.2 量化时值把MIDI tick变成字典里的词拿到(raw_notes)还不是训练样本。RNN学的是离散token序列如果你直接拿(音高, tick)做回归模型会去拟合那些带微差的时间值实际听感毫无意义还浪费模型容量。主流做法是把时值量化到固定步长比如一拍是480 tick量化到16分音符就是120 tick一档。超过32档约8拍的长音统一截断这样谱面上最常见的节奏型都能覆盖。def quantize_to_tokens(notes, ticks_per_beat480, steps_per_beat4): step_ticks ticks_per_beat // steps_per_beat # 120 tokens [] for note, duration_ticks in notes: steps round(duration_ticks / step_ticks) steps min(max(steps, 1), 32) if steps 0: continue tokens.append(f{note}:{steps}) return tokens这个函数把每个音符编码成“音高:时值步数”的字符串token比如60:4表示中央C持续一个四分音符4个16分音符步长。为什么把音高和时值拼成一个token而不是分成两列分开预测等于让模型同时学两个输出头训练时两个任务可能互相干扰拼在一起实现简单模型也能直接学到“音高和时值共同构成一个事件单位”的联合分布。量化步数这个参数是音乐生成项目里性价比最高的旋钮。steps_per_beat4是最常见的保守选择相当于16分音符分辨率如果你处理的是快节奏电子乐可以提到832分音符但字典会变大、训练样本里的长时值会更稀疏。另一个常用技巧是做移调数据增强把同一首曲子平移半音把note加减一个数可以一次膨胀十几倍样本量代价是失去原曲的调性中心——对小规模项目来说划得来。2.3 构建词典和滑动窗口音符字典要控制规模。把全部token直接拿来做词表会得到几千甚至上万个不同的token原因是MIDI文件里的力度、微调音高都会有细微变化。稀疏样本太多模型学不到统计规律。常见做法是统计频次保留频次前300到500的token其余映射到UNK。这样既保留了常用节奏型又能挡住那些一首曲子里只出现一次的怪音。from collections import Counter def build_vocab(token_sequences, top_k400): counter Counter() for seq in token_sequences: counter.update(seq) common_tokens [t for t, _ in counter.most_common(top_k)] vocab {t: idx for idx, t in enumerate(common_tokens)} vocab[UNK] len(vocab) return vocab拿到vocab之后把每个token序列映射成id序列按窗口滑窗切样本。窗口长度决定模型“一眼”能看到多远的历史太短8到16生成的旋律会频繁跳调太长128以上训练变慢且短数据集里样本数量骤减。我一般先设32起步batch_size给64到128样本量不够时把滑窗的stride从1提到8来减少重叠。import torch from torch.utils.data import Dataset class MelodyDataset(Dataset): def __init__(self, token_ids, vocab_size, window32, stride8): self.x_all [] self.y_all [] for seq in token_ids: for i in range(0, len(seq) - window, stride): x seq[i:i window] y seq[i 1:i window 1] self.x_all.append(torch.tensor(x, dtypetorch.long)) self.y_all.append(torch.tensor(y, dtypetorch.long)) def __len__(self): return len(self.x_all) def __getitem__(self, idx): return self.x_all[idx], self.y_all[idx]这里有个关键设计y并没有错位成一个单独的“下一个音符常数”而是和x长度相同的、右移一位的序列。这意味着训练时每个时间步都在预测“下一个token”模型在窗口内的每一步都和真实历史对齐。推理时则是“预测一步、把真实历史替换成模型自己的输出、再预测下一步”——训练和推理在这里天然对齐这也是字符级RNN在小数据集上比seq2seq好训练的原因之一。3. 用PyTorch搭一个LSTM音乐生成网结构与超参数怎么定3.1 字符级建模把“音高加时值”拼成一个token上面我们已经把每个事件编码为60:4这样的字符串token。这是字符级做法。用LSTM训练音乐本质和训练诗歌生成、代码生成一致唯一差别在于token词典是音符事件集而不是字符集。RNN循环神经网络的“循环”二字在这里就体现在隐状态沿着音符序列一步一步滚动把前文的语境压缩成一个向量。RNN家族里PyTorch可直接用的有RNN、GRU、LSTM。vanilla RNN梯度消失严重序列超过20步基本学不动GRU参数少、训练快LSTM带三个门能在数百步内保留长期上下文。音乐是有明显长程结构的32步窗口末尾的动机往往要到第60步才再现所以多数项目选LSTM代价是推理速度比GRU慢但在本项目这种token序列长度下感知不到。还有一个倾向值得一提最近也有用Transformer做音乐生成的但Transformer需要几百万量级的训练数据课程设计级别的数据集几十到几百首MIDI喂进去容易“死记加胡编”LSTM参数少几百首曲子就能学到像样的统计规律。这也是本项目选RNN而非Transformer的实际理由数据量和算力都更适配。3.2 MusicRNN网络结构Embedding加LSTM加Linear网络结构有三层Embedding层把离散token id变成稠密向量LSTM层做序列建模全连接层把隐状态映射回词典大小的logits。用PyTorch写非常直白import torch.nn as nn class MusicRNN(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, num_layers2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout, ) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, embed_dim) out, hidden self.lstm(emb, hidden) # out: (batch, seq_len, hidden_dim) logits self.fc(out) # (batch, seq_len, vocab_size) return logits, hidden为什么不直接用one-hot向量one-hot矩阵巨大且“音高60”和“音高61”两个向量是正交的模型学不到“它们只差半音”这个基础音乐知识。Embedding层会在训练中自动把相邻音高的向量在语义空间里拉近这是模型能听出旋律轮廓的一个底层原因。LSTM设置batch_firstTrue后输入输出都保持“batch在前”的布局少写很多transpose。num_layers2表示堆叠两层LSTM第一层输出隐序列作为第二层输入第二层最后一个隐状态再接Linear。dropout参数只在多层LSTM的内部层之间生效单层时PyTorch会忽略它这里设0.3是写给多层时用的。forward里把hidden也返回了这是训练和推理复用同一个函数的关键。训练时hidden传None让LSTM自己初始化全零状态推理时你把上一轮采样得到的(hn, cn)传回来隐状态就能跨token持续流动模型“记住”了它自己刚生成的音符。这部分就是循环神经网络的“记忆”所在。3.3 网络参数选择hidden_dim、层数与dropout的边界参数怎么定这是新人最爱问、又最不容易给出唯一答案的部分。我给出的是在几百首MIDI、单GPU或CPU上能稳定工作的默认值embed_dim 128hidden_dim 256num_layers 2dropout 0.3。hidden_dim的影响256是序列建模里非常常见的容量甜点。128会显得旋律呆板乐句衔接生硬512在小数据集上很容易过拟合训练loss降到很低生成的曲子开头像模像样到第20个音符就开始“背原曲”。num_layers1层只能建模短程的局部连贯性段落感和主题重复不明显3层在小数据集上训练慢很多收益却很小。dropout低于0.2防不了过拟合高于0.5会让模型“学不动”loss下降极其缓慢。如果你手里只有几十首曲子我宁愿把dropout提到0.4并把窗口降到24。还有个容易被忽略的参量是优化器的weight_decay。给LSTM加轻微L2正则1e-5到1e-4配合dropout比单纯堆dropout效果更稳。训练时我习惯把learning rate设1e-3每10个epoch衰减到原来的0.5这样前期学得快、后期收得稳。下面训练章会一起给出完整配置。4. 训练与生成loss曲线、温度采样与模型文件4.1 训练循环损失函数、优化器与梯度裁剪训练循环的核心是逐时间步的交叉熵。每个位置都在判断“真实的下一个token是哪个”所有位置的平均loss作为整条序列的Loss。这里有个重要细节数据集中如果引入了填充位损失计算时要用ignore_index把填充位排除否则模型会去学“预测占位符”这种假任务。我们的窗口滑切不产生填充但如果你的数据是定长补零的请务必设置CrossEntropyLoss的ignore_index0。优化器选Adam初始学习率1e-3这是RNN项目最稳的起手配置基本不需要预热。如果发现loss跳得厉害或干脆不动先把学习率降到3e-4。梯度裁剪是RNN训练的另一条保命线LSTM缓解了梯度消失但梯度爆炸仍然高频发生clip_grad_norm_按L2范数截断到5.0能有效防止一次异常样本把权重推向NaN。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for x, y in loader: x, y x.to(device), y.to(device) # x/y: (batch, seq_len) optimizer.zero_grad() logits, _ model(x) # logits: (batch, seq_len, vocab_size) loss criterion(logits.reshape(-1, logits.size(-1)), y.reshape(-1)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset)reshape那句是PyTorch训练序列模型最常见的写法把batch和seq_len两个维度合并成一个维度vocab维保持和同样被拉平的目标y对齐。注意clip_grad_norm_要写在backward()之后、step()之前顺序反了就裁不到本轮梯度。训练时我会把每个epoch的loss打印出来并结合验证集loss做early stopping。一个可以接受的配置是epochs 60、batch_size 64、lr 1e-3每10个epoch把lr衰减到原来的0.5。验证集怎么划是第五节避坑的重灾区这里先按下不表。4.2 温度采样生成旋律0.7、0.9、1.1的区别训练完成后进入推理。模型输出的是词典大小的logits直接argmax会得到概率最高的token但音乐是“有随机性才动听”的所以要按概率分布采样并引入温度系数调节随机性。温度的做法是把logits除以temperature再做softmax温度越低分布越尖采样越保守温度越高分布越平采样越跳脱。def generate(model, vocab, start_seq, length64, temperature0.9, devicecpu, seed0): model.eval() id_to_token {v: k for k, v in vocab.items()} unk vocab[UNK] ids [vocab.get(t, unk) for t in start_seq] input_tensor torch.tensor([ids], devicedevice) hidden None generated list(ids) torch.manual_seed(seed) for _ in range(length): with torch.no_grad(): logits, hidden model(input_tensor, hidden) logits logits[0, -1, :] / temperature probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, 1).item() generated.append(next_id) input_tensor torch.tensor([[next_id]], devicedevice) return [id_to_token[i] for i in generated]注意两点。第一hidden每一轮都要传回模型模型才能延续自己刚刚生成的语境如果hidden传None每一轮都在冷启动生成的音符彼此无关联听起来就是乱码式音阶。第二input_tensor每轮只由上一轮输出的next_id构造形状始终是(1,1)避免把整段生成历史重新灌进模型那样会越来越慢。temperature的取值经验0.7出来的旋律最稳适合复现原曲风格但略保守0.9是通用值兼顾可控性和惊喜1.1以上输出开始频繁跳调、节奏散乱适合找灵感而不是直接出成品。想让旋律更连贯可以固定0.85并配合重复惩罚如果某个token已经连续出现很多次采样前把它的logits乘一个惩罚系数。4.3 模型文件怎么存才够state_dict、vocab与config一起保存模型文件这步看似简单实际是重灾区。新手只torch.save(model.state_dict())到推理时才发现vocab字典和网络配置没存换台机器完全无法重建网络和词典。我保存模型文件时永远把三样东西合成一个字典写入同一个.pt文件state_dict、vocab、configembed_dim、hidden_dim、num_layers、window、steps_per_beat。推理脚本加载后先用config重建模型结构再把state_dict装载进去最后用vocab把输出token解码回音符。torch.save({ model_state: model.state_dict(), vocab: vocab, config: { vocab_size: len(vocab), embed_dim: 128, hidden_dim: 256, num_layers: 2, window: 32, steps_per_beat: 4, }, }, music_rnn.pt)加载端用torch.load后从字典里取config和vocab模板化重建MusicRNN再load_state_dict。config里的vocab_size必须和实际词典一致如果你训练时加了UNK保存vocab时它一定在内加载后也不要再改。这样模型文件才是一个自包含的产物交给队友或换机器推理都不会“少了关键文件”。我还会顺手在每个epoch结尾用固定seed生成几段旋律把MIDI直接导出到磁盘。这是所有机器学习项目里最好用也最便宜的回滚手段——loss曲线看不出的问题听一听生成的MIDI立刻暴露。5. 音乐生成项目避坑清单5个让训练翻车的细节5.1 全部输出UNK字典过滤太狠现象训练正常loss也降了但生成出来的旋律几乎全是UNK转回音符后一片空白或乱跳。原因top_k取得太小比如200导致许多训练中出现的token在词典阈值之外。滑窗数据里大量样本的目标token是UNK模型反而学会了“预测UNK也不吃亏”生成时就往UNK集中。解决把top_k提到400或500观察token频次分布后再设置阈值。更稳的做法是统计训练集里UNK占比如果超过2%说明top_k过小或原始token太零散。生成时遇到UNK直接重新采样一次而不是输出它。5.2 训练三五轮loss不降学习率和数据量不匹配现象loss在初始值附近震荡或前几个epoch缓慢下降后立刻进入平台期。原因两个常见原因。一是lr太高导致在尖锐的loss面上振荡二是数据量太小模型直接记住全部样本验证集loss从第5个epoch就开始上升训练loss看似在降。解决先看训练和验证两条loss曲线。如果训练下降、验证回升是过拟合调lr没救要加dropout、数据增强或early stopping。如果两个都不动把lr从1e-3降到3e-4并确认数据集的tensor格式没有出错。我踩过“x和y没有错位、模型在学恒等映射”的坑当时的loss曲线就是完美水平线。5.3 旋律在单音高附近抖动低频垃圾音符没清干净现象生成旋律听起来像半速的单音反复一个音高反复抖动毫无旋律感。原因MIDI文件里常有duration极短的音符比如1个tick量化后变成1步的噪声如果这类噪声在数据里占比高模型学到的局部模式就成了“快速反复敲同一个音”。解决在量化函数里加过滤steps小于2的音符直接丢弃或者把它们和前后音符合并成连音。另一个清洗手段是按长度过滤整首MIDI只有几十个音符的片段往往是不完整的扒谱直接丢弃比让它们污染统计规律更划算。5.4 采样陷入死循环重复惩罚与温度回升现象生成进行到十几个音符后模型开始循环输出同一个token长度越长越明显听感像坏掉的唱片。原因RNN的隐状态在某个吸引子附近循环最常见是temperature过低、采样太确定导致模型每次的选择都被自己锁死。解决最直接的是把temperature从0.7上调到0.9让采样多一点随机性更强硬的手段是在采样循环里加重复计数同一个token连续出现3次以上就把它的logits乘0.1再采样。另一个技巧是当检测到输出的4-gram在生成历史里已经出现过两次时强制从剩余token里重采样。5.5 背答案式生成文件级划分训练验证集现象验证loss很低但生成旋律却像在复述某首原曲而不是泛化出风格。原因最常见的是把同一首MIDI的所有滑窗样本散进了训练集和验证集。滑窗高度重叠验证集里全是训练样本的近亲验证loss虚低模型在背答案而不是学会音乐规律。解决划分数据时按文件粒度一首MIDI的样本只能进训练集或验证集不能两边都有。如果你只有一首长MIDI应按时间切分成互不重叠的段落再划分。验证的方式也应该是听感优先的每个epoch生成两到三段新旋律与训练样本做结构对比而不是只看loss。6. 让生成结果更像作品和弦约束、重复控制与后处理到了这步模型已经能产出可听的旋律但要像作品还得加三道工序。第一道是音阶约束。在temperature采样之前把logits中不属于当前调式比如C大调的七个音的token掩码成一个极小的概率可以立刻消除那些“钢琴弹错键”式的离调音。做法是准备好一个合法的note集合在采样循环里把非集合内的token logits设成极小值。第二道是重复控制。音乐的美感大量来自“有变化的重复”。你可以在生成时维护一个滑动窗口的n-gram计数保证每8个音符里允许一次动机重现但连续完全相同的两小节直接否决。第三道是MIDI后处理。我一般量化到16分音符后再对时值做连奏合并相邻同音高且间隔为0的note合并成一个长音避免MIDI里常见的一堆幽灵音符。验证生成质量我常用的客观指标有三个音高直方图应当和训练集接近相邻音符间隔分布能检查跳跃是否过大自相关重复周期能看出有没有稳定的结构回环。这三个数字一出来模型输出的“音乐性”就从玄学变成了可比较的统计量。最后说一个我自己的习惯每次调完参数我把同一个seed的生成结果和上一次对比着听而不是听随机结果。固定seed才能让你判断“这次改动是变好了还是变坏了”否则你只是在验证随机性的好坏。早期我翻车最狠的一次就是seed没固定调了三天参数以为模型进步了其实只是采样噪音。现在哪怕只是改一个learning rate我也会保留当时的生成MIDI、写一句改动说明。希望帮到你。本文还有配套的精品资源点击获取