
简介面向音乐生成与深度学习实践者的MIDI音乐生成项目代码资源围绕在MIDI数据集上训练深度神经网络并生成音乐展开适合具备Python基础、希望了解序列建模与生成式音乐流程的学习者参考。资源说明显示项目包含数据计算与numpy数组构建、模型创建与训练、加载模型生成音乐以及贝叶斯超参数搜索等环节并涉及TensorFlow、Keras、pygame与music21等工具可用于理解从MIDI预处理到音乐输出的完整实验链路。上游未标注文件总数与类型明细压缩包约1.02MB从说明看核心以Python脚本和模型训练代码为主便于按训练、生成与调参模块查阅。已有187人学习或下载可作为音乐生成课程设计、硕士论文实验复现与深度生成模型练手的参考资料。1. 训练一个会写旋律的网络midiGenerator 能给你什么上周我拿一份开源工程试手里面的 midiGenerator 喂进几十个 MIDI训练一晚之后真能吐出一段没听过的钢琴片段虽然偶尔走调但结构上还像模像样。它的思路很朴素用 music21 把 MIDI 拆成音符序列用 Keras 里的 LSTM 学“给定前 32 个音下一个音是什么”再把预测一步步喂回去最后写回标准 MIDI 文件用 pygame 直接播。技术栈就是 Python 加 tensorflow、keras、music21、pygame没有隐藏服务也不依赖复杂部署。想搞清楚音乐生成完整链路的开发者或者手上有 MIDI 素材想做实验的人拿它当起点挺合适——先跑通再谈改造。2. 骨架与运行环境从 requirements 到第一次跑通拿到这类小工程我一般不急着看模型先看目录和入口。模型再花哨如果数据从哪来、权重存哪、脚本怎么串都说不清后面调参基本靠猜。midiGenerator 这块设计得比较克制四五个脚本一个配置模块数据、模型、产物分目录放属于“看得懂就能改”的那种结构。这一章先把整体骨架和运行路径理清楚后面拆代码才不会迷路。2.1 目录结构数据、脚本、产物三层分开工程本身不大但目录划分是有讲究的——源码、数据、训练产物、临时输出各自独立避免训练一次就把原始素材目录搞乱。典型结构长这样midi_generator/ ├── data/ │ ├── midi_sources/ # 原始 MIDI 素材只读不往里写东西 │ └── notes.pkl # 预处理缓存解析后的音符序列 ├── models/ # 训练产出的权重文件.h5 ├── output/ # 生成阶段写出的 .mid 文件 ├── src/ │ ├── config.py # 序列长度、batch、学习率等集中参数 │ ├── preprocess.py # music21 解析 - 编码 - 切分训练样本 │ ├── train.py # 构建 Keras 模型并训练 │ ├── generate.py # 加载权重按概率采样生成音符序列 │ └── play.py # 序列回写 MIDI并调用 pygame 试听 ├── requirements.txt └── README.mddata/midi_sources是唯一需要你手动准备东西的地方扔进去的 MIDI 越干净后面生成的片段越不容易“发疯”。notes.pkl是缓存的中间产物删掉它重新跑preprocess.py就能重建。models和output分别存放权和生成结果这两个目录的内容都不进版本管理——体重文件几十兆起提交上去纯属给自己找麻烦。src里的五个文件对应一条单向流水线preprocess.py出样本train.py吃样本出权重generate.py吃权重出序列play.py把序列变成能听的 MIDI。中间任何一步都能单独重跑不用从头再来这对反复调参很重要。2.2 环境安装tensorflow 自带 keras别重复装这个项目的依赖非常干净没有需要编译的本地库虚拟环境里装四五个包就能跑python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install tensorflow music21 pygame numpy四个包各管一摊tensorflow负责模型和训练music21负责 MIDI 的解析、音符对象建模和写回pygame负责把生成好的 MIDI 播出来numpy做序列和矩阵运算。这里有个很容易踩的点tensorflow 2.x 已经内置了 keras不要再单独pip install keras。两个包版本对不上时from tensorflow.keras.models import Sequential可能直接抛导入错误排查半天还找不到原因。我第一次装的时候就顺手装了个独立 keras结果训到一半报层属性缺失血泪经验。提示music21第一次导入会去扫描本机乐谱软件和字体配置启动慢几秒是正常现象不是卡死。如果机器上有独立显卡装完可以用一句python -c import tensorflow as tf; print(tf.config.list_physical_devices(GPU))确认是否识别到。识别不到也不影响跑通只是训练时间从十几分钟拉到一两个小时先拿小数据集验证链路完全够用。2.3 配置中心所有能调的参数集中在一个文件工程里最容易失控的是参数散落各处。midiGenerator 把关键常量收在config.py训练、生成、预处理三个脚本都从这里读改一处全链路生效# src/config.py SEQUENCE_LENGTH 32 # 输入窗口长度模型一次看多少个音 BATCH_SIZE 64 EPOCHS 80 HIDDEN_UNITS 256 # LSTM 隐层维度 DROPOUT 0.2 LEARNING_RATE 0.001 DATA_DIR data/midi_sources MODEL_DIR models OUTPUT_DIR outputSEQUENCE_LENGTH是最值得先动的参数调大模型能参考更长的上下文旋律连贯性更好但样本数会减少、显存占用上升调小训练快但生成出来容易变成短句循环。HIDDEN_UNITS决定模型容量256 在小数据集上是比较稳的起点数据量大再往上加。DROPOUT是为了压制过拟合——小样本训练音乐模型过拟合来得比想象中快训练 loss 一直降但生成结果全是训练集原样复读就是典型症状。2.4 四步跑通从原始 MIDI 到能听的片段装好依赖、素材放进data/midi_sources之后按顺序执行四步就能听到东西# 第一步解析所有 MIDI缓存成音符序列 python src/preprocess.py # 第二步训练模型权重按 epoch 存进 models/ python src/train.py --epochs 80 # 第三步加载最新权重采样生成一段音符序列 python src/generate.py --length 200 --temperature 0.8 # 第四步把序列写回 MIDI 并播放 python src/play.py每一步的输入输出都要对得上preprocess.py读完整个midi_sources目录产出统一的音符词表和切好的样本train.py从缓存里加载样本训练结束把权重写进models/generate.py自动挑models/里最新的一份权重采样出一串音符索引play.py读这串索引用词表反查回音高名写成.mid后用 pygame 播放。这个顺序不能乱。跳过预处理直接训练会因为找不到缓存文件报错改了SEQUENCE_LENGTH却不重跑预处理样本形状和模型输入对不上会在训练第一步就炸掉。我一般习惯改完任何影响数据形状的参数都把预处理和训练连着跑一遍别让缓存和代码“各说各话”。3. 数据管线与网络结构music21 负责解析Keras 负责学习生成音乐和普通序列预测的差别全在数据这一层。音乐不是一串孤立数字音符有音高、有时值、有和弦怎么把它们压成一个模型能吃的整数序列直接决定生成结果像不像音乐。这一章把预处理、编码、模型结构三段拆开讲它是整个项目最值得细看的部分。3.1 用 music21 把 MIDI 拆成音符与和弦music21的converter.parse()能把一个.mid文件读成乐谱对象树然后通过.flat.notes把多声部、小节、各种记号都摊平成一条按时间排列的音符序列。这里要区分两种元素单个的note.Note和同时发声的chord.Chord。和弦不能丢否则整段音乐的和声结构就塌了处理方式是把和弦内的音高按数值排序后用点号连接成一个字符串# src/preprocess.py import glob import pickle from music21 import converter, note, chord def extract_notes(midi_path): 把一个 MIDI 文件摊平成音符/和弦字符串列表 score converter.parse(midi_path) tokens [] for element in score.flat.notes: if isinstance(element, note.Note): # 单音直接取音高名如 C4、F#3 tokens.append(str(element.pitch)) elif isinstance(element, chord.Chord): # 和弦把音高按编号排序拼起来如 4.7.11 nums sorted(str(n.pitch.midi) for n in element.notes) tokens.append(..join(nums)) return tokens def build_corpus(data_dir): all_tokens [] for path in glob.glob(f{data_dir}/*.mid): all_tokens.extend(extract_notes(path)) return all_tokens关键点有三个。第一用element.pitch而不是element.name前者在不同调号下更稳定不会因为降号写法不同把同一个音拆成两个词表项。第二和弦用 MIDI 音高编号0–127拼接保证排序稳定C.E.G和E.C.G不会被当成两个不同的词。第三整个语料库的音符是按文件顺序首尾相接的这是个粗糙但有效的处理——真实工程里更严谨的做法是每个文件之间插一个分隔符避免上一首的结尾直接影响下一首的开头。解析完整个目录后把结果缓存成 pickle避免每次训练都重跑一遍 music21tokens build_corpus(data/midi_sources) with open(data/notes.pkl, wb) as f: pickle.dump(tokens, f) print(f共解析出 {len(tokens)} 个音符/和弦 token)这个 token 总数很关键。少于几千个模型基本学不出东西生成结果就是随机拼贴几万到几十万是比较舒服的区间。素材太少时与其硬训不如先去找一批风格接近的 MIDI 补进去。3.2 序列编码从字符串到模型能吃的整数矩阵模型不认识C4这种字符串得先把词表映射成整数。这里用最简单的“按出现顺序建字典”方式import numpy as np from config import SEQUENCE_LENGTH with open(data/notes.pkl, rb) as f: tokens pickle.load(f) vocab sorted(set(tokens)) # 去重并排序保证可复现 token_to_int {t: i for i, t in enumerate(vocab)} int_to_token {i: t for t, i in token_to_int.items()} network_input, network_output [], [] for i in range(len(tokens) - SEQUENCE_LENGTH): seq_in tokens[i:i SEQUENCE_LENGTH] # 前 32 个 token seq_out tokens[i SEQUENCE_LENGTH] # 第 33 个作为标签 network_input.append([token_to_int[t] for t in seq_in]) network_output.append(token_to_int[seq_out]) X np.array(network_input, dtypenp.float32) y np.array(network_output, dtypenp.int32)这段代码干的事就是滑窗每 32 个 token 做输入紧跟着的第 33 个做标签窗口整体右移一格继续。sorted(set(tokens))这一步必须排序否则每次跑set()的顺序可能不同词的编号一变旧权重就废了。这里有个设计选择要交代清楚输入直接用整数索引再送进模型的Embedding层或者归一化后进 LSTM而不是 one-hot。词表动辄几百上千one-hot 会造出一个极其稀疏的大矩阵训练又慢又占内存。用整数索引 Embedding 是序列模型的常见做法模型自己学一个低维稠密表示。这也是这个项目和“教科书式 one-hot”实现最大的区别之一改造时优先考虑。样本切完还要做一步把X按词表大小归一化到 0–1 区间或者保留整数交给 Embedding。如果用归一化方案记得在生成阶段用同一套vocab_size做反归一化两边必须一致。3.3 Keras 模型两层 LSTM 堆叠加 Dropout模型结构不复杂属于“够用就好”的配置两层 LSTM 叠起来中间和后面加 Dropout最后接一个全连接层输出到词表维度softmax 得到每个候选音的概率分布。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout, Embedding, Activation vocab_size len(vocab) model Sequential([ # 输入是 [batch, SEQUENCE_LENGTH] 的整数序列 Embedding(input_dimvocab_size, output_dim64, input_lengthSEQUENCE_LENGTH), LSTM(256, return_sequencesTrue), Dropout(0.2), LSTM(256), Dropout(0.2), Dense(vocab_size), Activation(softmax), ]) model.compile(losssparse_categorical_crossentropy, optimizeradam, metrics[accuracy]) model.summary()逐层说清楚为什么这么搭。Embedding把每个 token 映射成 64 维向量词表越大这一层省下的参数越多。第一层LSTM带return_sequencesTrue因为它要把每个时间步的隐状态都传给下一层第二层LSTM不带只输出最后一个时间步的状态用来做整段序列的总结。两个Dropout分别压在两处防止模型死记训练集的和弦走向。损失函数用sparse_categorical_crossentropy而不是categorical_crossentropy因为标签是整数索引而不是 one-hot前者内部会自动做转换省一次编码也省显存。optimizeradam默认学习率 0.001 在这个规模下比较稳如果 loss 前期震荡明显可以在compile里传tf.keras.optimizers.Adam(learning_rate1e-3)手动调低。3.4 训练循环checkpoint 和早停训练脚本核心就两件事喂数据、存权重。每训完一个 epoch 存一次权重这样即使中途中断也能挑历史最好的一份继续from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping checkpoint ModelCheckpoint( filepathmodels/weights.{epoch:02d}-{loss:.4f}.h5, monitorloss, save_best_onlyTrue, verbose1, ) early_stop EarlyStopping(monitorloss, patience10, restore_best_weightsTrue) history model.fit( X, y, batch_size64, epochs80, callbacks[checkpoint, early_stop], )filepath里带{epoch}和{loss}是为了让文件名自己说明问题事后翻models/目录一眼就能看出哪份权重是在哪个阶段存下的。save_best_onlyTrue只保留 loss 最低的避免存一堆越训越差的权重占空间。patience10的意思是连续 10 个 epoch 没有改善就停防止在小数据集上过拟合到没边。判断训练是否健康看两条曲线训练 loss 稳定下降、验证集如果切了的话在某个点之后开始回升说明过拟合了该加 Dropout 或者减HIDDEN_UNITS。如果训练 loss 一直不降多半是词表或编码阶段出了问题先回去查样本。4. 生成与试听从概率采样到能听的 MIDI 文件训练完拿到权重只是半成品真正能听的片段在生成阶段才出现。这一章讲清怎么把模型一步步“喂自己”吐出长序列怎么把索引翻回音高名写成标准 MIDI以及 pygame 播放这一环的边界在哪里。4.1 采样策略与 temperature为什么生成结果总在复读生成时的循环逻辑很直白拿一段起始序列预测下一个 token把它追加到序列末尾再把窗口右移一格继续预测。循环次数决定生成长度import numpy as np from config import SEQUENCE_LENGTH def sample_sequence(model, seed_seq, length, int_to_token, vocab_size, temperature1.0): generated list(seed_seq) for _ in range(length): x np.array([generated[-SEQUENCE_LENGTH:]], dtypenp.float32) preds model.predict(x, verbose0)[0].astype(float64) # 温度调整temperature 越低分布越尖锐越高越平缓 preds np.log(preds 1e-8) / temperature preds np.exp(preds) / np.sum(np.exp(preds)) next_idx np.random.choice(vocab_size, ppreds) generated.append(next_idx) return generatedtemperature是整个生成阶段最重要的旋钮。小于 1 时会放大高概率 token 的优势旋律更“规矩”但容易陷入重复大于 1 时分布被压平会出现更多意外音听着新鲜但也更容易跑调。0.7–0.9是我试下来比较舒服的区间。如果直接用argmax取最大概率结果通常是一段完全可预测的复读机听着像卡带。注意preds先转成float64再做对数运算float32在log附近容易精度不够出现NaN。另外1e-8的平滑项不能省否则概率为 0 的 token 在取对数时报错。4.2 回写 MIDI把索引翻译成音符和时值生成出来的是整数索引序列要变成 MIDI 文件得先反查回 token再逐个构造音符对象并设置时间偏移from music21 import stream, note, chord def tokens_to_midi(tokens, out_path, note_duration0.5): output_stream stream.Stream() for i, token in enumerate(tokens): if . in token: # 和弦token 里存的是 MIDI 音高编号用点号分隔 pitches [int(p) for p in token.split(.)] new_chord chord.Chord(pitches) new_chord.offset i * note_duration output_stream.append(new_chord) else: # 单音token 是 C4 这类音高名 new_note note.Note(token) new_note.offset i * note_duration output_stream.append(new_note) output_stream.write(midi, fpout_path)offset是音符在时间轴上的起始位置这里用固定note_duration0.5拍来排列听起来像匀速的旋律。真实工程里更讲究的做法是从训练数据里学一份时值分布按 token 概率采样不同的时值节奏才会自然。这是这个项目最明显可以改进的地方之一。4.3 pygame 播放能响但别指望它专业写出的.mid用 pygame 播出来是最省事的验证方式不需要额外装合成器import pygame import time pygame.mixer.init() pygame.mixer.music.load(output/generated.mid) pygame.mixer.music.play() while pygame.mixer.music.get_busy(): time.sleep(0.1)这段代码短期用来快速试听完全够用但要有个心理准备pygame.mixer.music对 MIDI 的支持取决于底层音频后端部分平台上它会直接返回False或者无声。遇到这种情况不要死磕把.mid用系统自带的播放器打开或者转成.wav再播问题立刻消失。我在不同机器上装同一个环境播放行为确实不完全一样这点后面「避坑」章节会详细讲。5. 避坑清单五个真实会卡住你半天的问题这个项目流程短但每一段都埋了坑。下面五条是我从解析到播放一路踩过来的按“现象 → 原因 → 解决”写清楚遇到问题先照着对照。5.1 解析大曲子时卡死或内存飙升现象跑了几个 MIDI 文件都没事加入一首多声部钢琴曲后preprocess.py占满内存、进程卡住。原因score.flat.notes会把整份乐谱展开遇到大量装饰音、重复轨和极短时值音符时会爆发式增长有些 MIDI 里存在时值为 0 的占位音符也会被当成有效元素算进去。解决解析前先过滤时值过短的元素if element.duration.quarterLength 0.1: continue必要时用score.chordify()把织体压成和弦流再取.flat.notes。大目录分批跑每首解析完就释放别一次性把所有乐谱对象都留在内存里。5.2 训练 loss 一上来就是 NaN现象model.fit跑了几十步 loss 突然变成 NaN之后再也降不回来。原因输入序列是未经处理的整数索引数值范围跨度大某些层对输入尺度敏感另外float32下做概率归一化时除以接近 0 的和也会溢出。解决在送进模型前把序列归一化到[0, 1]或者显式用Embedding层接住整数输入——这也是为什么上面模型结构里第一层是 Embedding。学习率从1e-3往下降一档也很管用。生成阶段同理概率运算统一转float64并加平滑项。5.3 生成结果反复出现同一段旋律现象生成长度设成 300但听起来像同一句在循环毫无变化。原因采样时用了argmax或者temperature设得过低模型在训练集上过拟合后对某个局部片段形成了极强的偏好。解决把argmax换成按概率采样np.random.choice(..., ppreds)把temperature调到0.8左右过拟合的话回头调高 Dropout、减小HIDDEN_UNITS、增加训练数据。只调采样不调训练效果有限两边要一起动。5.4 换了 SEQUENCE_LENGTH 之后加载旧权重报错现象把窗口长度从 32 改成 64训练能跑但generate.py加载旧权重时形状不匹配直接抛异常。原因LSTM 的输入形状和SEQUENCE_LENGTH绑定权重文件里记的是旧尺寸换参数后模型结构变了权重自然对不上。解决权重的文件名里带上关键参数比如weights.seq64.h5改参数就重新训一份。生成脚本加载时先校验形状对不上就明确报错而不是让 TensorFlow 抛出一堆看不懂的层名。5.5 pygame 播 MIDI 没声音现象MIDI 文件用系统播放器能正常响但pygame.mixer.music.play()之后静默。原因pygame 的音频后端对 MIDI 格式的支持并不一致某些环境缺少对应的合成器或解码器play()不报错但没输出。解决先用pygame.mixer.music.load()的返回值确认加载是否成功失败就换方案——用系统播放器打开或者用music21的write(audio)转成 wav 再播。pygame 在这个项目里只是试听工具不要把它当成播放链路的一部分换成别的播放方式不影响核心逻辑。6. 二次开发三个能立刻见效的改造方向项目跑通之后真正有意思的是改。原版结构已经把数据、训练、生成三段拆开了改哪一段都不会牵连另外两段这点对二次开发很友好。下面三个方向是我实际动过刀、也验证过有效果的。第一个是给模型加Embedding之后换成双向 LSTM。原版单层前向 LSTM 只能看到“过去”的音符双向结构能同时利用前后文生成的乐句衔接会自然一些。改动只在train.py里from tensorflow.keras.layers import Bidirectional, LSTM model Sequential([ Embedding(input_dimvocab_size, output_dim64, input_lengthSEQUENCE_LENGTH), Bidirectional(LSTM(128, return_sequencesTrue)), Dropout(0.2), Bidirectional(LSTM(128)), Dropout(0.2), Dense(vocab_size), Activation(softmax), ])双向会把单层参数翻倍小数据集上过拟合风险也翻倍Dropout 得跟着调高到 0.3 左右。改完记得重新训旧权重不能复用。第二个是给节奏加变量。原版把每个音符时值写死成 0.5 拍听起来像节拍器。改进思路是在 token 里带上时值比如C4:0.5解析时从element.duration.quarterLength读出来生成时一起预测写回 MIDI 时用采样到的时值设置offset和duration。代码量不大但生成的音乐立刻有了呼吸感。第三个是加多轨。现在的模型把所有声部压成一条序列改成按轨道分别训练、生成时再合并能做出简单的伴奏加旋律结构。改动集中在extract_notes里按part分组以及生成阶段用多个stream.Part组装。验证改造是否有效别只用耳朵判断。我的习惯是固定随机种子把生成样本和训练集的音高直方图各画一张对比分布接近说明模型学到了调性偏离太远说明要么过拟合、要么训练数据不够。每次改完模型结构这两张图我都要看一眼才敢说“效果变好了”。从那以后我每次改这类序列生成项目的模型参数都强制先把预处理重跑一遍、再用固定种子生成两版结果对比确认改动是真的起作用而不是随机性蒙对了。希望帮到你。本文还有配套的精品资源点击获取