简介:面向音乐生成与深度学习实践者的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 = "output"SEQUENCE_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,避免每次训练都重跑一遍 music21:
tokens = 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, dtype=np.float32) y = np.array(network_output, dtype=np.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_dim=vocab_size, output_dim=64, input_length=SEQUENCE_LENGTH), LSTM(256, return_sequences=True), Dropout(0.2), LSTM(256), Dropout(0.2), Dense(vocab_size), Activation("softmax"), ]) model.compile(loss="sparse_categorical_crossentropy", optimizer="adam", metrics=["accuracy"]) model.summary()逐层说清楚为什么这么搭。Embedding把每个 token 映射成 64 维向量,词表越大这一层省下的参数越多。第一层LSTM带return_sequences=True,因为它要把每个时间步的隐状态都传给下一层;第二层LSTM不带,只输出最后一个时间步的状态,用来做整段序列的总结。两个Dropout分别压在两处,防止模型死记训练集的和弦走向。
损失函数用sparse_categorical_crossentropy而不是categorical_crossentropy,因为标签是整数索引而不是 one-hot,前者内部会自动做转换,省一次编码也省显存。optimizer="adam"默认学习率 0.001 在这个规模下比较稳,如果 loss 前期震荡明显,可以在compile里传tf.keras.optimizers.Adam(learning_rate=1e-3)手动调低。
3.4 训练循环:checkpoint 和早停
训练脚本核心就两件事:喂数据、存权重。每训完一个 epoch 存一次权重,这样即使中途中断,也能挑历史最好的一份继续:
from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping checkpoint = ModelCheckpoint( filepath="models/weights.{epoch:02d}-{loss:.4f}.h5", monitor="loss", save_best_only=True, verbose=1, ) early_stop = EarlyStopping(monitor="loss", patience=10, restore_best_weights=True) history = model.fit( X, y, batch_size=64, epochs=80, callbacks=[checkpoint, early_stop], )filepath里带{epoch}和{loss}是为了让文件名自己说明问题,事后翻models/目录一眼就能看出哪份权重是在哪个阶段存下的。save_best_only=True只保留 loss 最低的,避免存一堆越训越差的权重占空间。patience=10的意思是连续 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, temperature=1.0): generated = list(seed_seq) for _ in range(length): x = np.array([generated[-SEQUENCE_LENGTH:]], dtype=np.float32) preds = model.predict(x, verbose=0)[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, p=preds) 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_duration=0.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", fp=out_path)offset是音符在时间轴上的起始位置,这里用固定note_duration=0.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(..., p=preds)),把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_dim=vocab_size, output_dim=64, input_length=SEQUENCE_LENGTH), Bidirectional(LSTM(128, return_sequences=True)), 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组装。
验证改造是否有效,别只用耳朵判断。我的习惯是固定随机种子,把生成样本和训练集的音高直方图各画一张对比,分布接近说明模型学到了调性,偏离太远说明要么过拟合、要么训练数据不够。每次改完模型结构,这两张图我都要看一眼,才敢说“效果变好了”。
从那以后我每次改这类序列生成项目的模型参数,都强制先把预处理重跑一遍、再用固定种子生成两版结果对比,确认改动是真的起作用,而不是随机性蒙对了。希望帮到你。
本文还有配套的精品资源,点击获取