ARTICLE DETAIL

资讯详情

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

用深度学习生成MIDI乐谱:事件序列建模与工程实践

用深度学习生成MIDI乐谱:事件序列建模与工程实践 简介MIDI生成器是一套使用深度神经网络自动生成MIDI音乐的开源代码面向对音乐信息处理与生成式人工智能感兴趣的研究者、数据科学爱好者以及计算机音乐方向的硕博学生。项目源自硕士论文《具有深度概率模型的音乐完成》覆盖了从原始MIDI数据读取、numpy数组构建、超参数贝叶斯优化、模型训练到调用pygame生成乐谱的完整链路支持基于TensorFlow/Keras搭建序列模型并可用music21进行音乐化后处理。资源以zip压缩包发布体积约1.02MB由于上游未提供文件总数与类型明细暂不便具体罗列内部结构。目前已有187人学习参考该代码可快速搭建音乐生成实验环境理解LSTM类深度概率模型如何捕捉旋律的时序依赖并进一步扩展自身的节奏与和弦生成方案。1. 标题没说的潜台词midiGenerator 生成的是乐谱不是音频如果你打开一段由深度神经网络生成的 midi 文件听到的其实是合成器现场“演奏”的结果而不是网络直接吐出来的波形。midiGenerator 这个方向的本质是把音乐作曲当成一个离散序列生成问题输入一段 melody、一个和弦进行、或者干脆什么都不给让网络输出一串带音高、时长、力度和轨道的 note 事件再写成标准 midi 文件。这件事和“用神经网络生成音频”是两条完全不同的技术路线前者处理的是符号后者处理的是采样点评价指标、训练难度、落地姿势也全都不一样。做这件事的人通常有三类一是想做 AI 伴奏工具的产品经理二是研究可控音乐生成的算法工程师三是想给自己的作曲流程加一个自动化和弦与旋律生成器的音乐制作人。很多人第一次跑通训练时都会有一种错觉loss 降得挺好生成的 midi 打开一放却像是“钢琴在乱砸键盘”。这不是模型笨而是你还没把 midi 的稀疏事件结构、词表设计和采样策略搞对。下面按我自己的实践顺序把从数据准备到模型落地中间最容易被人忽略的环节拆开讲。2. 先解决“喂给网络的是什么”midi 事件的拆解与数据集准备2.1 我为什么要放弃“直接给钢琴卷帘”的思路新手最容易踩的第一个设计岔路是把 midi 文件渲染成 piano roll 图片然后用 CNN 或 VAE 去生成图像再把图像转回 midi。这个思路看起来顺实际做起来会遇到三个让模型直接翻车的问题第一钢琴卷帘把时间轴变成了像素列时值精度完全取决于渲染分辨率16 分音符和 32 分音符在低分辨率下根本分不开第二多轨 midi 是多个声部叠加图像模型很难学会“同一时刻不同轨道互不干扰”的结构化约束第三图像生成模型的输出需要后处理成 midi这个后处理的规则往往比训练还难写。我最后采用的方案是把 midi 文件里的 note on、note off、time shift、velocity、track 信息全部转成 token 序列。也就是把一段音乐当成一个“由离散事件组成的文本”再用序列模型去预测下一个事件。这个做法的好处是midi 本身的时间分辨率可以保留用 tick 而不是像素多轨信息可以显式编码成事件类型生成结果可以直接写回 midi 文件不需要任何规则后处理。2.2 最小的解析脚本把 midi 变成 token 序列这里给一个用mido库解析 midi 的最小实现。mido是 Python 生态里处理 midi 文件最常用的库读取、过滤、写回都靠它。import mido from mido import MidiFile def midi_to_events(file_path, ticks_per_beatNone): mid MidiFile(file_path) # 统一分辨率mido 的 tick 值取决于文件头最好统一到同一个值 if ticks_per_beat is None: ticks_per_beat mid.ticks_per_beat events [] # track_id 用来区分声部后续会作为 token 的一部分 for track_id, track in enumerate(mid.tracks): abs_time 0 for msg in track: abs_time msg.time if msg.type note_on and msg.velocity 0: events.append({ type: note_on, track: track_id, note: msg.note, velocity: msg.velocity, time: abs_time, ticks_per_beat: ticks_per_beat }) elif msg.type note_off or (msg.type note_on and msg.velocity 0): events.append({ type: note_off, track: track_id, note: msg.note, velocity: 0, time: abs_time, ticks_per_beat: ticks_per_beat }) # 按绝对时间排序供下一步把时间差量化成 time_shift events.sort(keylambda e: e[time]) return events这段代码做的事情很简单把 midi 文件里的轨道遍历一遍把 note_on力度大于 0和 note_off 提取成事件再按绝对时间排序。注意我把note_on速度为 0 的情况也当成了note_off这是 midi 协议的一个历史遗留习惯很多宿主软件会这么用解析时不做兼容后面会出莫名其妙的和弦断裂问题。有了这个事件列表下一步是把相邻事件的绝对时间差转成离散的time_shifttoken。这里的ticks_per_beat统一很重要不同源的文件可能用不同的分辨率不统一就直接影响训练时的词表分布。我一般用 480这个值是大多数 DAW 的默认值足够表达 64 分音符以内的时值。2.3 构建 midi 乐谱库时我会留的三个质量门槛模型能学到什么完全取决于喂进去的 midi 是什么。公开渠道能找到的 midi 乐谱下载网址和免费 midi 库很多但质量参差到会让你怀疑人生。比如有些从老游戏里扒出来的 midi轨道的音高范围根本没有乐理约束有些转谱软件生成的 midinote_on 和 note_off 在时间上交叉叠成一团人类演奏时根本不可能出现这种情况。我对数据集做三件事第一按曲长过滤。少于 40 秒或超过 10 分钟的 midi 直接丢弃短文件信息量不够长文件通常带有大量无意义的重复片段或未清理的控制器信息。第二检查轨道完整性。很多 midi 里节奏轨用的是 channel 10打击乐它的音符含义和旋律轨完全不同。如果不做区分模型会把鼓组的音高和钢琴音高混在一个词表里生成结果就是“钢琴手在打鼓”。我一般把 channel 10 单独过滤或单独打 track token。第三转调增广。音乐有 12 个调但原始数据往往集中在 C 大调附近。我会把每个 midi 用mido做整体移调把 note 值统一加上一个偏移量生成 12 个版本。这个操作相当于图像分类里的随机翻转成本低但对泛化能力帮助极其明显。3. 把序列交给神经网络模型结构选择与生成流程设计3.1 为什么选序列模型而不是“写死的作曲规则”写死规则的作曲系统存在很多年比如根据和弦级数随机琶音、按五度圈配和弦这些方法稳定但风格单一。深度神经网络的优势在于它可以把海量 midi 里隐藏的风格倾向学出来而不是靠人肉总结规则。但这里有个容易产生误解的地方神经网络不是“学会了乐理”它只是在学习“给定前文下一个事件是什么”的条件概率分布。midiGenerator 这类任务本质上和语言模型是同一个框架把事件序列当句子让网络预测下一个 token。所以我用 LSTM 或者 Transformer 都行关键看数据量。我自己的经验是如果清洗完的 midi 文件不到 500 首LSTM 反而更稳如果数据量上千Transformer 的优势才会真正体现出来。3.2 midiGenerator 网络结构的核心代码下面是一个基于 LSTM 的 midiGenerator 最小实现PyTorch 风格它解决了“如何把事件序列映射成向量并做下一事件预测”的核心问题import torch import torch.nn as nn class MidiGenerator(nn.Module): def __init__(self, vocab_size, embed_size256, hidden_size512, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue, dropout0.2) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): # x: [batch, seq_len] 的 token id 序列 emb self.embedding(x) # [batch, seq_len, embed_size] out, hidden self.lstm(emb, hidden) # out: [batch, seq_len, hidden_size] logits self.fc(out) # [batch, seq_len, vocab_size] return logits, hidden def generate(self, start_tokens, max_len512, temperature1.0): self.eval() tokens list(start_tokens) hidden None with torch.no_grad(): for _ in range(max_len): x torch.tensor([tokens[-1]]).unsqueeze(0) logits, hidden self.forward(x, hidden) logits logits[0, -1, :] / temperature probs torch.softmax(logits, dim-1) next_token torch.multinomial(probs, 1).item() tokens.append(next_token) if next_token END_TOKEN: break return tokens逻辑说明训练时输入是一整段 token 序列输出是每个位置对下一个 token 的预测分布。generate是自回归生成每次只喂最后一个 token维持hidden状态直到模型输出END_TOKEN或到达最大长度。参数说明embed_size256对 midi 事件这种中等词表通常 10004000 个 token够用了hidden_size512是 LSTM 的容量太小抓不住长距离结构太大在数据量不足时很快过拟合num_layers2是我试过的性价比选择3 层在 500 首曲目规模下没有明显收益反而更难收敛temperature是采样参数后面专门讲。3.3 事件词表设计里那个隐藏的“副作用”词表设计决定了模型能力的边界。这里最容易犯的错误是把每个音高都直接当成一个独立的 token忽略了“同一个音高在不同时值下是不同的音乐表达”。我采用的做法是事件类型 参数分开编码音符开始事件NOTE_ON_track_note其中 note 范围 0127音符结束事件NOTE_OFF_track_note时间推进事件TIME_SHIFT_deltadelta 取值通常是 132 个 tick 的量化力度事件VELOCITY_value把 0127 量化到 16 档避免词表爆炸这么做有个副作用同样是弹一个 C 音模型需要连续输出NOTE_ON_0_60、VELOCITY_8、TIME_SHIFT_12、NOTE_OFF_0_60四个 token 才能完成一个音符。学习难度上升了但可控性也上来了——你可以在采样时单独约束力度范围或强制某个轨道只生成某种时值的音符。另一个细节是初版模型我让TIME_SHIFT里的 delta 直接采用原始 tick 数结果生成了大量不规则的 7 tick、13 tick听感乱七八糟。后来我把 delta 量化成 1、2、3、4、6、8、12、16、24、32效果才有质的改善。量化的本质是替模型降低了预测难度。4. 不是“训完就完了”损失设计、采样与可控生成4.1 训练阶段的两个容易被忽略的细节第一个问题是教师强迫teacher forcing。如果用真实前文做输入去预测下一个 token收敛确实快但生成阶段一个 token 出错后面的错误会像滚雪球一样放大。我一般在训练后期做计划采样scheduled sampling前 80% 的 batch 用真实序列后 20% 的 batch 把模型自己生成的 token 混进去当输入。这个比例是我反复试出来的太小没效果太大收敛慢且不稳定。第二个问题是标签平滑。midi 事件序列的分布很尖同一个位置往往只有一个正确答案但音乐本身是允许“等价变体”的。对目标 token 做 0.1 的标签平滑把概率分配给其他事件生成的多样性会明显提高。这不属于玄学而是在告诉模型“别把训练集里的唯一解当成音乐的绝对真理”。4.2 生成阶段让模型从“玄学”变成可用的三个参数训练完成后的生成阶段真正决定听感的是采样参数而不是模型的大小。我把这三个参数调好的过程比训练本身还要磨人。第一个是temperature。温度越高输出的随机性越强。放在音乐生成里temperature 在 0.81.1 之间是可用区间。低于 0.6 会疯狂重复同一个 motif高于 1.2 就开始失去音阶结构听起来像在乱敲。第二个是top_k。我一般取 5080也就是每次只在概率最高的前 K 个 token 里采样。它解决的问题是温度会把那些明显不合理的 token比如跨度过大的音程也放大top_k 直接剪掉。第三个是“重复惩罚”。音乐需要重复来建立结构但模型很容易陷进 4 个小节的死循环不跳出来。我做的办法是记录最近 64 个 token如果某个NOTE_ON事件已经出现过 3 次以上就在采样时手动压低它的概率。这个手段不像前两个那么理论化但非常管用。def sample_with_repeat_penalty(logits, temperature0.9, top_k60, penalty1.2, recent_tokensNone): logits logits / temperature # top_k 截断 if top_k 0: values, _ torch.topk(logits, top_k) logits[logits values[-1]] -float(inf) # 重复惩罚对 recent_tokens 里出现过的 note_on token 降权 if recent_tokens: for tok in set(recent_tokens): logits[tok] / penalty probs torch.softmax(logits, dim-1) return torch.multinomial(probs, 1).item()这段代码的核心逻辑是调整 logits 而不是调整已经算好的概率分布因为 softmax 之前做修改才会真正影响相对关系。penalty1.2意味着重复出现的 token 被除以 1.2概率会下降但不至于完全禁掉这样可以留出合理的重复空间。4.3 让生成结果“像曲子”的条件控制无条件生成的问题在于你没法指定“我想要一首 C 大调、钢琴独奏、每分钟 90 拍的曲子”。解决这个问题的方法是条件事件注入在序列开头放入一个特殊的CONDITIONtoken内容包括调性、轨道数量、BPM 区间。模型训练时我从每首曲子里提取这些信息拼到序列头部生成时手动构造条件 token 再让模型续写。这个方法比事后过滤要优雅得多。因为条件信息在训练时就被模型记住了它知道“看到 C 大调条件后面的音符分布应该偏向 C 大调”不需要额外训练一个分类器。要注意的是条件 token 必须在词表里单独留 ID不能复用音符事件的 ID否则模型会混淆它们的语义。5. 避坑训练与生成的 5 个常见翻车现场排查5.1 现象loss 一直下降生成结果却全是同一个音反复弹原因模型学到了一个懒策略——不断输出TIME_SHIFT和同一个NOTE_ON/NOTE_OFF这样序列长度变长了但信息量很低。这种情况在事件序列模型里极其常见因为事件类别不均衡TIME_SHIFT占了词表的大部分概率。解决第一把TIME_SHIFTtoken 的 loss 权重降为 0.3让模型把更多注意力放在音符事件上第二检查数据里是否有大量音符时值完全相同的“死板 midi”这类样本会放大重复输出第三生成时启用重复惩罚。我自己的经验是重复惩罚对这个问题立竿见影。5.2 现象midi 文件打开后只有开头几个音后面全是休止符原因NOTE_ON生成了但对应的NOTE_OFF事件要么被模型预测成很远的TIME_SHIFT后才出现要么干脆没有生成。如果NOTE_OFF在词表里的概率始终偏低模型会倾向于“让音一直挂着”但挂到序列末尾又被截断听感就变成了长休止。解决在解码阶段做一个约束每个NOTE_ON后面必须跟一个同 note 的NOTE_OFF如果超过 64 个 token 还没触发就强制插入NOTE_OFF。另一个办法是调整损失权重把NOTE_OFF的权重稍微调高到 1.2。5.3 现象钢琴轨和鼓轨串了钢琴突然出现鼓的力度模式原因训练时没有区分轨道类型把所有轨道的 note 混在一起。鼓轨channel 10的 midi 事件结构跟旋律轨不同模型无法区分生成时就会乱串。解决在第七轨道 token 中单独留出一个DRUM类型数据预处理时把 channel 10 的轨道重命名为DRUM其它轨命名为MELODY、BASS、CHORD。采样时如果条件 token 指定了MELODY就把DRUM事件的概率屏蔽。这个操作不算模型改进但能让生成结果的轨道分离度提升一个档次。5.4 现象生成的 midi 用播放器打开速度忽快忽慢原因midi 文件的时间基准是 tick但实际播放速度依赖 midi 里的 BPM 和 tempo meta 事件。我只注意了音符事件忽视了 tempo 事件导致播放器按默认 BPM 播放再加上一些 midi 文件自带 tempo 变化就出现了变速感。解决生成 midi 文件时在文件头部强制写入一个固定 tempo 事件比如 120 BPM并删除训练数据里的 tempo 变化把所有速度信息统一。这样模型学到的是一个稳定的速度基准生成结果的节奏感会稳定得多。5.5 现象模型生成总是卡在某一个小节来回重复原因LSTM 的上下文窗口有限超过一定长度后模型“忘掉”了前面的音乐结构只能根据最近几十个 token 做预测自然就绕进了局部循环。解决加大num_layers或换用 Transformer 的长上下文但更直接的办法是在生成时检测“最近 64 个 token 里是否出现了完全相同的 8-token 子序列”一旦检测到就强制把temperature提高到 1.3 并跳过重复事件。这个技术手段解决的是模型的惯性不是乐理问题。6. 验证与进阶把 midiGenerator 接进真实制作流程的验收方法模型训好了怎么判断它真的“能用”我的做法很简单把生成结果转成 wav 听再转成简谱对照双管齐下。midi 转 wav 我用 FluidSynth 命令行完成选择一个干净的钢琴音色库直接渲染出来听。这一步能快速暴露音高错误、节奏崩坏和轨道串扰问题。fluidsynth -a alsa -g 1.0 -l soundfont.sf2 output.mid output.wav参数说明-g是增益1.0 是正常音量-l表示循环加载音色库-a alsa在 Linux 下用 ALSA 音频驱动渲染到 wav。Windows 用户可以改用-a dsound或者干脆用 DAW 导入 midi 挂音源导出效果是一样的。midi 转简谱则用现成的转换工具不要自己写解析器。简谱是五线谱之外的另一种可视化形式它能把音高序列变成数字方便你快速检查有没有离谱的跳进。我自己会抽查生成的 50 首曲子统计音程分布、音符时长分布、轨道活跃度三个指标如果它们和训练集的分布对得上就可以放心进入下一步。进阶的做法是给生成加“主题约束”取一首曲子的前 8 个音符作为CONDITIONtoken 输入让模型续写出和声与伴奏。这比从头生成好听得多因为它给了模型一个模仿的锚点。我现在做任何 midiGenerator 实验都会先跑一遍“主题续写”再跑无条件生成两个都过了才敢谈上线。这个领域的坑很多但最大的坑不是模型选错而是你早早跳进了“音频生成”的思路。记住 midiGenerator 的招牌是生成乐谱事件不是合成声波。希望帮到你。本文还有配套的精品资源点击获取
返回列表