ARTICLE DETAIL

资讯详情

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

深度神经网络作曲:用midiGenerator生成Midi的完整实践

深度神经网络作曲:用midiGenerator生成Midi的完整实践 简介midiGenerator是一套基于深度神经网络生成MIDI音乐的完整代码面向对AI音乐创作、序列生成模型感兴趣的Python开发者与研究人员。项目源于作者硕士论文“具有深度概率模型的音乐完成”基于TensorFlow/Keras、music21与pygame生态覆盖MIDI解析、特征编码、模型训练与生成等环节提供从数据预处理到音乐输出的全流程工具。压缩包仅1.02MB内含bayesian-opt.py贝叶斯超参数寻优、compute_data.pyMIDI转numpy数组、train.py模型训练与generate.py音乐生成等核心脚本并附有数据集放置与调用顺序说明便于快速搭建环境、复现实验。资源已有187人浏览学习适合希望深入了解深度音乐生成、贝叶斯优化或自行扩展MIDI数据集的读者。通过研读这些脚本可掌握循环训练框架的搭建、音乐序列的向量化表示方式以及如何利用生成模型创作旋律片段。1. 用 midiGenerator 生成 Midi先弄明白这套东西解决的是哪一环做算法作曲的人通常会碰到一个尴尬模型训练得再好产物不是人能直接用的音乐而是一堆概率矩阵。midiGenerator 这类项目解决的就是中间这一段——用深度神经网络把音符事件当作 token 序列来建模预测下一个事件再把预测结果写回标准 Midi 文件。它不负责音色、混响和后期渲染只负责把“音符该在哪个时刻出现、持续多久、力度多大”说清楚。它适合两类人想跑通“语料→模型→midi”完整链路的入门者以及手里有模型但卡在数据预处理和 Midi 写盘环节的熟手。一个反直觉的经验是网络结构反而是这条链路里最不需要纠结的部分真正吃掉时间的是 midi 解析、token 设计和解码器可靠性。2. 先想清楚再写代码Midi 序列化与深度神经网络选型的三个前提midiGenerator 不是一个模型而是一条流水线解析、token 化、训练、解码、写盘。模型只负责一件事——给定前面 N 个事件预测下一个事件是什么。动手之前先把三件事定下来生成结果用什么符号表示、用哪类深度神经网络骨架、训练数据从哪来。这三件事没定后续写出的代码大概率要推翻重来。2.1 为什么生成 Midi 而不是直接生成音频直接生成音频的第一道坎是序列长度。44.1kHz 采样率下一首 3 分钟的钢琴曲相当于 800 万个采样点任何序列模型在这种尺度上都难以建模长期结构就算退一步用 mel spectrogram 做频谱预测还要处理相位恢复和音质损失工作量比 Midi 路线大一个量级。Midi 是符号化表示。一首钢琴曲通常只有几千个 note 事件序列模型用 256 到 512 的上下文就能覆盖一个完整乐句。另一个实际理由是“后悔药”——Midi 文件生成后音符位置错了可以拖力度可以改音色可以换波形一旦生成想逐音符编辑就难了。所以 midiGenerator 的核心价值是把深度神经网络输出的概率分布翻译成可编辑的音乐符号。它适合旋律生成、伴奏织体生成这类任务但不负责出成品音频成品还需要采样音源渲染。明确这个边界后面做模型评估时就不会拿“音质不好”来冤枉它。2.2 深度神经网络骨架怎么选LSTM 与 Transformer 的取舍LSTM 是这类项目最稳妥的起点。Midi 事件流本质上是时序数据LSTM 的归纳偏置就是局部连贯性对 note_on 后面跟 note_off、一小节内音型重复这些模式非常敏感。参数量也小两层 LSTM、hidden size 512 大约两千万到四千万参数消费级显卡能训。Transformer 的优势在长程结构比如 32 小节后主题再现。代价是要把序列长度拉到 1024 以上才能体现优势训练时间翻倍而且 Midi 事件局部强相关自注意力会算出大量冗余的 attention 分数。选型对比可以参考下面这张表项目LSTMTransformer建模长度256-512 足够建议 1024训练开销单卡可训显存和时间约 2-4 倍适合数据量万首以内尽量大典型缺陷长距离重复结构弱小数据下容易过拟合我的建议是数据量小于一万首、以单轨钢琴为主先用 LSTM 把整条链路跑通等需要生成多声部长结构再切 Transformer。无论哪一种输入输出都是离散 token做 next-token prediction这一点和语言模型没有本质区别。如果你是从 MNIST 分类教程或入门课转过来的最该改掉的思维惯性是别把分类头直接搬到生成任务上——midi 生成每一步输出的是整个词表上的概率分布不是类别标签。2.3 数据从哪来公开数据集与自制语料的取舍MAESTRO 是钢琴演奏 Midi和音频对齐过音符质量高适合训练旋律模型缺点是风格集中在古典钢琴。Lakh 规模更大覆盖流行和摇滚但很多文件是自动扒谱或多轮编辑的结果音轨混乱、时值误差大。自己收集 Midi 要注意不少文件把节拍器轨、歌词轨和主旋律混在一起。清洗原则只有一个方向只保留音符信息去掉一切装饰。control change、pitch bend、program change 对旋律模型是纯噪声。先把所有文件统一到 4/4 拍、120 BPM减少 time_shift 的分布漂移。一首 Midi 的 note 数少于 50 或大于 5000 的都丢掉——太少学不到结构太多通常是多轨混在一起。按轨道过滤可以写成一个简单函数def clean_tracks(mid): keep [] for track in mid.tracks: name note_count 0 for msg in track: if msg.type track_name: name msg.name if msg.type in (note_on, note_off): note_count 1 if note_count 20: continue if any(k in name.lower() for k in (drum, perc, bass, lyric)): continue keep.append(track) return keep逻辑说明先按轨道名和音符数两层过滤把碎轨和明显不属于旋律声部的轨道剔除。参数说明note_count 20 通常是不完整的装饰轨bass 轨是不是要排除取决于任务——如果你生成的是独奏钢琴排掉 bass 轨能少很多低音噪声如果目标是乐队作品保留反而是好事。3. 落地一条“语料→token→模型→midi”的最小流水线下面这条流水线是我在类似 midiGenerator 项目里用得最顺的形态改动少、问题容易定位。核心是四步解析 Midi 成事件、事件转整数 token、训练模型预测下一个 token、推理时采样 token 再翻译回 Midi 文件。3.1 用 mido 解析 midi事件流与元信息的取舍第一步把 .mid 变成程序能直接操作的事件列表。mido 是首选因为它保留每个消息的 tick 级相对时间这对 token 化很关键。pretty_midi 更适合做统计分析它会直接填充出 note 对象、把时间换算成秒方便但离 token 序列远了一步。import mido def midi_to_events(path, merge_tracksTrue): mid mido.MidiFile(path) # 合并轨道后事件按时间顺序排列方便转 token merged mido.merge_tracks(mid.tracks) if merge_tracks else mid.tracks[0] events [] for msg in merged: if msg.is_meta: continue if msg.type in (note_on, note_off): # msg.time 是相对前一个事件的 tick 差 events.append({ type: msg.type, note: msg.note, velocity: msg.velocity, time: msg.time, }) return mid.ticks_per_beat, events逻辑说明merge_tracks 会把多个音轨合并成一条带相对 tick 的事件流后续转 token 时不用再管轨道概念。过滤 is_meta 是把拍号、速度标记这类对音符生成无效的消息去掉。msg.time 是相对时间单位是 tick 而非秒解码写盘时要用同一个时间基准避免混用。参数说明merge_tracksTrue 只适合单乐器或独奏钢琴多轨 Midi 想保留声部结构就不要合并逐轨转独立序列复调生成的链路会复杂一个量级不建议第一版就这么做。3.2 构造训练样本note_on / note_off / time_shift 三段式 tokenMidi 事件不能直接喂给神经网络要编码成整数 token。最常见的编码就是三类事件note_on 表示按下某个键、note_off 表示松开某个键、time_shift 表示向前推进一段时间。这种设计的优点是词表小且完整解码时不用额外推理。TIME_SHIFT_BUCKETS [0, 10, 20, 30, 40, 60, 80, 120, 180, 240, 360, 480] def events_to_tokens(events, ticks_per_beat, bpm120): ms_per_tick 60000 / (bpm * ticks_per_beat) tokens [] for ev in events: dt_ms ev[time] * ms_per_tick # time_shift 拆成多个桶保证桶值之和等于原始间隔 while dt_ms 0: bucket max(b for b in TIME_SHIFT_BUCKETS if b dt_ms) tokens.append((time_shift, bucket)) dt_ms - bucket if ev[type] note_on and ev[velocity] 0: tokens.append((note_on, ev[note], ev[velocity])) else: tokens.append((note_off, ev[note])) return tokens逻辑说明time_shift 用桶离散化而不是存原始 tick原因是 tick 几乎是连续量模型无法对几千种取值做分类。这里用了循环拆分每次选一个不大于剩余时间的最大桶不断累加保证所有 time_shift 桶值之和等于原始间隔误差只发生在取整那一步。参数说明TIME_SHIFT_BUCKETS 是这套方案最重要的超参数。最小值 0 表示事件紧跟前一个事件最大值 480ms 是四分音符如果数据里有长音或休止一定要把桶延伸到 960、1920否则模型永远学不会长音符生成的旋律全都短促。velocity 还可以进一步量化把 0-127 压成 8 档词表能缩小不少。3.3 训练参数序列长度、批次、学习率怎么给初值参数给初值是有章法的先跑通再调优。下表供直接抄参数初值说明sequence_length256约 2-4 小节先跑通链路batch_size32显存 12GB 以下用 16learning_rate1e-3LSTM/ 3e-4Transformer配 warmup 更平稳grad_clip1.0Midi 序列 loss 波动大必开训练轮数30-50 epoch看验证 loss 早停Midi token 词表比 NLP 小得多一般只有一两千所以不需要语言模型那套复杂调度。序列长度不建议一上来就 1024Midi 里真正有用的上下文是“前一小节的和声走向”256 足够让模型学到乐句内部的收束。batch 大小和显存挂钩LSTM 按序列反传Batch 32 加序列 256 的显存占用约 4GB12GB 卡可以跑得很从容。学习率给初值后先跑 1000 步看曲线——如果 loss 在 3.0 附近震荡不降问题多半不在学习率而在 time_shift 桶分布太散回去调桶更有效。还有一个容易忽略的细节next-token prediction 不需要像 BERT 那样做 mask token 任务这里唯一的 mask 是 padding。如果你的训练代码实现的是“输入序列和标签序列错开一位”那 padding 位置会被当成真实标签哪怕 loss 下降了生成结果也会莫名其妙地长。统一做法是标签序列是输入序列右移一位padding 位置在 loss 里置 0。3.4 推理时的采样策略temperature 与 top-k 对生成结果的影响训练结束后最影响听感的是采样函数。GPT 风格的解码函数可以直接拿来用import torch import torch.nn.functional as F def sample_token(logits, temperature0.9, top_k16): # temperature 先作用在 logits 上再取 softmax logits logits / temperature probs F.softmax(logits, dim-1) if top_k 0: topk_val, _ torch.topk(probs, top_k) probs[probs topk_val[-1]] 0 probs probs / probs.sum() return torch.multinomial(probs, 1).item()逻辑说明temperature 先除到 logits 上再做 softmaxtemperature 大于 1 会让分布趋于平坦模型容易跳出重复音型但也更容易选中异常音符小于 1 则让高概率事件更突出旋律更顺但可能陷入“同一个音反复出现”。top_k 把候选缩小到前 16 个避免长尾里那些很少出现的音符乱入。参数说明Midi 生成和文本生成一个明显区别是音符分布天然有尖锐峰主音出现概率远高于其他音所以 top_k 不适合设得太小我一般固定 16 只调 temperature起步 0.9旋律连续但缺乏变化时降到 0.85如果出现大量半音跳跃把 top_k 缩到 8。这一节还有一个实用技巧temperature 可以分类设置。time_shift 对温度非常敏感温度高了节奏会散note 对温度相对钝感。分开采样——note_on 用 0.9time_shift 用 0.7——是生成结果里“旋律自然但节奏规整”的最快途径。4. 把网络输出还原成 .mid解码器与文件写入的细节模型输出的是一串 token id要变成能听的 Midi解码器要做三件事把概率变成事件序列、把事件序列翻译成 Midi 消息、按正确的时间基准写盘。三个环节里最容易翻车的是第三个。4.1 从概率分布到事件序列贪心、beam 与采样贪心解码是每步取概率最大的 token实现最简单但用在 Midi 上会很快陷入两三个音符的循环因为每个局部最优都指向同一个短环。beam search 在文本生成里常用在 Midi 上更糟它会保留时值规整、音高重复的路径生成结果又平又呆而且 beam 的数量越多越倾向把音符做成等时值。原因在于 Midi 序列的高概率区域就是“规整节奏、重复音型”这正好是听觉上最无聊的部分。所以这类任务实际可用的还是采样解码配上温度控制。实现时补一个约束如果连续采样出 time_shift token要设置最大连续休止数。比如允许 token 层面最多出现 4 个连续的 time_shift 大桶否则模型可能输出十几秒的空白。这个约束不是模型能力问题而是自回归生成的常见病一旦进入“一直输出休止”的低能量区域概率彼此接近很难自己走出来。4.2 用 mido 写 Midi 文件轨道、节拍与音长换算写盘的第一步是先把 token 翻译成绝对 tick 的事件表。常见错误是直接按 token 顺序写消息把相对 time 当成绝对 time结果所有音符挤在一个时间点上。这里给出我验证过的写法def tokens_to_midi(token_seq, ticks_per_beat480, bpm120): ms_per_tick 60000 / (bpm * ticks_per_beat) events [] abs_tick 0 for tok in token_seq: if tok[0] time_shift: abs_tick int(tok[1] / ms_per_tick) elif tok[0] note_on: events.append((abs_tick, note_on, tok[1], tok[2])) elif tok[0] note_off: events.append((abs_tick, note_off, tok[1], 0)) mid mido.MidiFile(ticks_per_beatticks_per_beat) track mido.MidiTrack() prev 0 for abs_tick, typ, note, vel in sorted(events, keylambda x: x[0]): delta abs_tick - prev prev abs_tick if typ note_on: track.append(mido.Message(note_on, notenote, velocityvel, timedelta)) else: track.append(mido.Message(note_off, notenote, velocity0, timedelta)) mid.tracks.append(track) return mid逻辑说明先用 time_shift 把绝对 tick 算出来加上 note 事件后再统一按 tick 排序最后才写 Message。这样做的核心是每条消息的 time 只跟前一条消息相关排序后 delta 计算是可靠的不会出现时值全挤在最后。note_off 的 velocity 写 0 是标准做法多数 DAW 会把 note_on velocity0 等价处理成 note_off但显式 note_off 兼容性最好。参数说明ticks_per_beat480 是通用值一拍 480 tick八分音符 240 tick如果从 MAESTRO 这类数据集里拿到的是 1024 或 96 ticks/beat建议统一转成 480否则 time_shift 桶和 tick 换算会出现小数多次取整误差累积后时值越来越离谱。bpm 只影响播放默认速度不影响音符结构。写盘前还有一个配对检查每遇到一个 note_on把它塞进 pending 字典遇到 note_off 就从字典里弹出。生成结束时如果 pending 里还有音符说明序列不完整要强制补 note_off。这一步能拦住大量“DAW 打开一片空白”的问题。4.3 一个可直接跑的生成脚本骨架上面各段拼起来就是一个完整的最小生成器def generate_midi(model, tokenizer, start_seq, steps512, temperature0.9, top_k16, outout.mid): model.eval() seq list(start_seq) with torch.no_grad(): for _ in range(steps): input_ids torch.tensor([seq[-256:]], dtypetorch.long) logits model(input_ids)[0, -1, :] next_id sample_token(logits, temperature, top_k) if next_id tokenizer.eos_id: break seq.append(next_id) mid tokens_to_midi(tokenizer.decode(seq)) mid.save(out)逻辑说明输入每次取序列末尾 256 个 token让生成步数增加时显存和推理时间都保持恒定。next_id 如果是 EOS token 就提前结束防止无限生成如果 tokenizer 没定义 EOS就用“最大 token 数加最大连续休止数”双保险收尾。参数说明steps 是 token 数不是音符数——time_shift 也占 token所以一首 64 小节的钢琴曲通常需要 2000 到 3000 个 tokensteps 给 512 只适合先听短动机。start_seq 可以来自用户给的一段种子旋律用 3.1 和 3.2 的函数把 midi 转成 token 序列取前 32 个 token 作为引导。空序列时用 BOS token 开头让模型自由发挥。这个骨架跑通后再往里面加采样约束和滑窗缓存。5. midiGenerator 生成 Midi 常见问题排查五个高频踩坑点下面五条是我自己踩过、也帮别人排查过的问题。每一条都按现象、原因、解决三件事说清楚你遇到时可以直接对号入座。5.1 生成的 midi 在 DAW 里是空白文件现象训练和推理都不报错loss 也正常降但生成的 midi 拖进 DAW只有一个空轨道音符一个都看不到。原因绝大部分是 note_on 后面没有配对 note_off或是所有 note_on 的绝对 tick 相同播放器把零时值音符直接丢弃也有可能是解码时 note 编号超出 0-127mido 写文件时抛了异常被上层代码吞掉。解决往解码器加断言——每个 note_on 必须入 pending 字典遇到对应 note_off 才弹出写盘结束后检查 pending 为空不为空就强制补 note_off 并记日志。再加一个自检函数读回刚保存的 midi统计音符数等于 0 直接抛错别让坏文件一路进到试听环节。def assert_valid_midi(path): mid mido.MidiFile(path) # velocity 0 的 note_on 才算有效音符 note_count sum( 1 for t in mid.tracks for m in t if m.type note_on and m.velocity 0 ) assert note_count 0, fempty midi: {path}逻辑说明写完文件立刻读回统计 velocity 大于 0 的 note_on 数量为 0 就是空文件。参数说明velocity0 的 note_on 在标准里等价于 note_off所以统计时排除掉避免把闭合事件误算成音符。5.2 旋律像机关枪所有音符时值都偏短现象生成结果听感全是八分或十六分音符没有长音也没有休止像节拍器上挂了几个音。原因time_shift 桶最大只到 240ms模型无论怎么采样都生成不了超过一拍的音符另一种可能是训练数据里 time_shift 占比太低模型学会了直接忽略它把每个 note 都排在相同 tick 上。解决把 TIME_SHIFT_BUCKETS 最大值扩到 1920ms并检查训练语料里长时值 token 的占比最好不低于 5%。如果数据没问题就在 loss 上给 time_shift 类 token 加权比如系数 1.5逼模型把时值预测重新当回事。检查占比可以先写个小工具from collections import Counter def check_time_shift_ratio(token_seq): cnt Counter(t[0] for t in token_seq) return cnt.get(time_shift, 0) / max(len(token_seq), 1)逻辑说明统计三类事件各自占比time_shift 低于 0.1 说明时值信息几乎没进序列模型不可能学出节奏。参数说明不同数据集比值会有波动但古典钢琴语料里 time_shift 占比通常在 0.15 到 0.3 之间低于 0.1 就直接停训查预处理。5.3 loss 很低但生成结果像乱弹现象验证 loss 降到了 2.x听起来却毫无调性频繁半音跳跃。原因数据里混入了大量无效音轨——有些 midi 的鼓轨用 note_on 表示打击乐有些自动扒谱轨时值错误严重模型把这些噪声当成统计规律学到权重里。解决回到数据清洗按 channel 过滤只保留钢琴轨统计每首 midi 的音符数低于阈值直接丢弃。这里想再多说一句从“头歌神经网络与深度学习”这类入门课程里出来的人容易把 MNIST 时代的“loss 低等于模型好”直接搬过来。生成任务里 loss 低只说明模型拟合了训练分布不代表那个分布本身是干净的。遇到这种情况先怀疑数据再怀疑模型。想确认是不是数据问题可以从训练集里随机抽几首转成 token 人工看一眼如果原始序列已经是一堆无意义跳进模型学到乱弹不奇怪。5.4 Windows 下中文路径保存失败或生成 0 字节文件现象输出路径含中文、空格或括号程序不报错但生成的 midi 是 0 字节或者直接抛 OSError。原因mido 在 Windows 下写文件用的是系统默认编码中文路径会触发编码问题有些封装把异常吞掉了留下一个空文件。解决统一把输出路径先做规范化只允许 ASCII 字符文件名用 run_001.mid 这种风格不带空格和括号。这不是模型的错却能让一次完整的推理在最后一步翻车。我的做法是在 generate_midi 函数入口直接调一个 sanitize_path 工具把所有非法字符替换掉一劳永逸。import re def sanitize_path(path): # 只保留英文字母、数字、点、下划线、连字符和路径分隔符 return re.sub(r[^A-Za-z0-9_.\-/\\], _, path)逻辑说明把所有非白名单字符统一替换成下划线中文会被替换成多个下划线旧路径没法还原但文件能正常保存。参数说明字符集合里保留反斜杠和正斜杠是为了兼容 Windows 和 Linux 的目录分隔如果路径里只有文件名可以连斜杠一起去掉看使用场景。5.5 训练时 batch 内序列长度不一致导致崩溃现象DataLoader 报 tensor 维度对不上或 LSTM 训练中途 hidden state 的 shape 变成 0。原因midi 事件序列天然不等长直接 pad 到固定长度但没传 attention maskTransformer 就会把 padding 位置当真实 token 参与计算LSTM 如果用 pack_padded_sequence又要求按真实长度降序排列很多人没注意到。解决第一版最省事的方案是训练时直接截断到固定长度 256不要 padbatch 天然等长代码简单排查容易。等后面要上长上下文再引入 padding mask。截断时不要只取前 256 个 token而是随机选起点切一段等于免费的数据增强。import random def random_crop(seq, length): if len(seq) length: return seq start random.randint(0, len(seq) - length) return seq[start:start length]逻辑说明随机起点切一段而不是固定从头切这样不同 epoch 看到同一首曲子的不同片段模型不会记住固定开头。参数说明length 要和训练时 sequence_length 保持一致切完后不需要校验有没有完整的 note_on/note_off 对——模型本来就是在窗口内做条件概率预测片段从哪里开始都能学习。6. 进阶验证用客观指标和听觉测试给 midiGenerator 打分6.1 生成质量怎么量化模型好不好不能只看 loss。用 pretty_midi 把生成文件读回来算几个和训练集可比的指标import pretty_midi def analyze_midi(path): mid pretty_midi.PrettyMIDI(path) # 把所有乐器的 note 对象展平统一统计 notes [n for instr in mid.instruments for n in instr.notes] durations [n.end - n.start for n in notes] pitches [n.pitch for n in notes] return { note_count: len(notes), pitch_mean: sum(pitches) / len(pitches), dur_mean: sum(durations) / len(durations), notes_per_sec: len(notes) / mid.get_end_time(), }逻辑说明把生成 100 条结果的平均值和训练集统计值对比重点看三项音符数是否在合理范围——太多说明机关枪太少说明模型学会了无限休止平均时值是否明显偏短音高均值是否因为模型偏爱某个音域而偏移。参数说明notes_per_sec 是密度指标训练集如果是每 10 秒 30 个音生成结果是 60那说明时值分布已经崩了不用试听就能定位到 time_shift 桶。还有一个可选指标是音符重复率统计相邻音符间相同 pitch 的比例过高说明模型在重复音型里打转。6.2 试听顺序与人工校验客观指标只能拦住明显异常最终判断还是靠耳朵。我的试听顺序是固定的先用钢琴音源把 midi 渲染成音频听前 30 秒重点听节奏是否变化、乐句是否有起伏再打开 midi 编辑器看音符的横向形状有没有连续大跳、有没有超过三个相同节奏型连着出现最后把听感最差的样本单独放进一个文件夹作为下一轮训练的负例参考。这一步比看曲线有用得多。我现在的习惯是每次训练完不急着展示结果先跑一遍 analyze_midi 再听。这个习惯帮我省下大量“听起来怪但说不清哪怪”的返工时间。midiGenerator 这个方向网络结构只占三成数据清洗和解码器的可靠性占七成。先把 token 化和写盘做扎实再用统计指标做闸门最后用耳朵投票这条链路就能持续地产出让人愿意听完的旋律。希望帮到你。本文还有配套的精品资源点击获取
返回列表