简介:面向深度学习音乐生成方向的实践型代码包,适合具备 Python、TensorFlow/Keras 基础,希望复现“用神经网络生成 MIDI 音乐”实验的高年级本科生、研究生与算法爱好者。内容围绕在 MIDI 数据集上训练不同深度概率模型并完成音乐续写与生成展开,提供超参数搜索、数据集预处理、模型训练和音乐生成等 Python 脚本,可帮助读者理解从 MIDI 数据处理、numpy 数组构造到模型训练与采样输出的完整链路。压缩包约 1.02MB,文件总数与类型明细上游暂未提供,现有信息显示以 Python 脚本为主体,便于按训练流程拆分阅读。已有 187 人浏览学习。该资源源自硕士论文相关代码,包含贝叶斯优化调参、模型加载与生成等环节,适合作为音乐生成课题的实验基线、课程项目参考或论文复现起点,也能为 MIDI 数据清洗与训练排错提供思路。
1. 从一段听不出调子的 MIDI 说起:midiGenerator 到底在做什么
上周有位做游戏音频的朋友发来一段 MIDI,八个小节翻来覆去就三个音,问我是不是模型训崩了。打开工程一看,他把整段旋律当成了逐帧像素——每个时间步都做 one-hot,词表有四百多个音高加时值组合,样本两万条,网络三千个 Epoch 就学会了“永远输出中央 C”。这不是模型崩了,是把音乐当分类任务硬做,没做序列建模该有的那层抽象。midiGenerator 这类项目解决的就是这件事:用深度神经网络学一段 MIDI 语料里的音符转移规律,然后让它自己往下“接龙”,输出一段能直接丢进 DAW 的新 MIDI。它适合两类人:手上有一批风格统一的 MIDI(自己写的、买的、开源数据集都行),想让模型学这种风格往下续写的音乐制作人;以及想完整跑一遍“序列数据 → 张量 → Keras 模型 → 生成文件”闭环的 Python 开发者。技术栈很轻:music21 负责把 MIDI 解析成音符对象,TensorFlow/Keras 搭网络并训练,pygame 做即时试听,不依赖任何在线服务。
2. 数据管道:用 music21 把 MIDI 拆成模型能吃的音符序列
2.1 为什么先把 MIDI 解析成音符对象,而不是直接读二进制
MIDI 文件本质是一串事件流:按下哪个键、力度多少、在第几拍松开。如果直接拿字节流喂网络,模型要同时学“事件编码规则”和“音乐规律”两件事,样本量不够必然学歪。music21 的价值在于它把这层事件流抽象成了note.Note、chord.Chord、stream.Stream这些对象,你可以按“音高 + 时值”直接取属性,不用自己解析 delta time。常见做法是先converter.parse()拿到Stream,再flatten().notes遍历,把每个音符压成一个(pitch, duration)元组。这里有个选型细节:只取音高会丢掉节奏信息,生成的曲子会变成匀速音阶;只取时值又会变成打拍器。业内比较稳妥的折中是“音高 + 量化后的时值”二元组,时值用四分音符的整数倍表示,量化到 1/4、1/2、1、2 四档,词表规模立刻从上千降到可控范围。
2.2 解析、量化、建词表一条龙
下面这段是数据入口,我一般把它单独放data_pipeline.py,因为它几乎每个训练脚本都要 import。
# data_pipeline.py from music21 import converter, note, chord import glob, pickle, os def parse_midi_folder(folder, quantize_steps=(0.25, 0.5, 1.0, 2.0)): """把文件夹下所有 .mid 解析成 (pitch, duration) 序列""" all_tokens = [] for path in glob.glob(os.path.join(folder, "*.mid")): try: stream = converter.parse(path) except Exception as e: print(f"[skip] {path}: {e}") continue # 只取单声部旋律,复调先压成最高音 notes = stream.flatten().notes for n in notes: if isinstance(n, note.Note): pitch = n.pitch.mid dur = float(n.duration.quarterLength) elif isinstance(n, chord.Chord): # 和弦取最高音,保留节奏轮廓 pitch = max(p.midi for p in n.pitches) dur = float(n.duration.quarterLength) else: continue # 时值量化到最近的合法档 dur = min(quantize_steps, key=lambda q: abs(q - dur)) all_tokens.append((pitch, dur)) return all_tokens def build_vocab(tokens): """把 (pitch, dur) 元组映射成整数 id""" vocab = sorted(set(tokens)) token2id = {t: i for i, t in enumerate(vocab)} id2token = {i: t for t, i in token2id.items()} return vocab, token2id, id2token if __name__ == "__main__": tokens = parse_midi_folder("./midi_corpus") vocab, t2i, i2t = build_vocab(tokens) print(f"共 {len(tokens)} 个音符,词表 {len(vocab)} 项") with open("vocab.pkl", "wb") as f: pickle.dump((vocab, t2i, i2t), f)三个函数各自负责一件事:parse_midi_folder负责把目录里的文件统一清洗成元组序列,build_vocab把元组映射成整数 id,最后落盘成vocab.pkl。参数上quantize_steps是节奏的精度旋钮,想要更细的切分就加 0.125、0.75,但每加一档词表就线性膨胀,模型要学的组合也跟着涨。pitch用pitch.midi而不是音名,是因为整数在张量里做 embedding 更直接,不用额外做音名到数字的映射表。和弦只取最高音是血泪经验:早期我试过把和弦每个音都当场一个 token,结果模型学到的全是“同时按 C、E、G”这种三音组合,生成阶段一采就采出大七和弦堆叠,听着像手肘压键盘。压成最高音之后,和弦变成了旋律线上的一个重音,模型先学对节奏骨架,后面再考虑加回和声。
3. 网络结构:Keras 里那套 DNN 到底该长什么样
3.1 输入输出维度和 one-hot 词表的坑
词表建好之后,训练样本是“前 N 个 token 预测第 N+1 个 token”。这里最容易翻车的是维度对齐:Embedding层的input_dim必须是词表大小,不是 vocab 文件里的最大值加一。早期我用max(t2i.values()) + 1做input_dim,结果有一次词表从 0 开始编号没问题,换了一批语料 id 不连续,input_dim直接对不上,训练不报错但 loss 卡在 4.6 不动。后来固定成len(vocab),并且训练脚本开头先断言t2i的 key 集合和id2token的值集合一致,这类玄学问题少了一大半。序列长度seq_len也有讲究:太短(< 8)模型学不到乐句结构,太长(> 64)显存和梯度爆炸概率一起飞。我一般取 16 到 32 之间,样本量少于五千条时取 16,多了再往上调。
3.2 把 Embedding + GRU 堆起来的网络
结构上没有必要求新,序列预测任务里一层Embedding接一到两层GRU再接Dense就够跑出能听的旋律。下面是完整模型定义:
# model.py import tensorflow as tf from tensorflow.keras import layers, Model def build_model(vocab_size, seq_len, embed_dim=64, hidden=256, dropout=0.2): inputs = layers.Input(shape=(seq_len,), dtype="int32") x = layers.Embedding(input_dim=vocab_size, output_dim=embed_dim)(inputs) # 第一层 GRU 返回完整序列,方便堆叠 x = layers.GRU(hidden, return_sequences=True, dropout=dropout)(x) x = layers.GRU(hidden, dropout=dropout)(x) x = layers.Dense(hidden, activation="relu")(x) # 输出层:词表大小的 softmax outputs = layers.Dense(vocab_size, activation="softmax")(x) model = Model(inputs, outputs, name="midi_generator") model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"], ) return model if __name__ == "__main__": import pickle with open("vocab.pkl", "rb") as f: vocab, _, _ = pickle.load(f) m = build_model(len(vocab), seq_len=16) m.summary()结构上三个关键点。第一,Embedding的output_dim取 64 是经验值,词表小于 500 时 32 也够,大于 5000 再考虑 128。第二,两层GRU中间那层return_sequences=True不能漏,否则第二层拿到的是一维向量,Dense之前的relu就没意义了。第三,输出层激活函数是softmax,配合sparse_categorical_crossentropy损失,标签直接传整数 id 就行,不用手动做 one-hot,省显存也少一层错误来源。dropout=0.2是防过拟合的常规设置,语料少于一百首时可以提到 0.3,代价是早期收敛慢一点。别用LSTM替换GRU除非你有充足理由——同等 hidden size 下GRU参数少三分之一,训练快一截,在 MIDI 这种短序列任务上效果差异很小。
3.3 切片与 Dataset 的工程细节
拿到模型之后,训练数据怎么组织直接影响显存和收敛速度。seq_len=16意味着从一段 500 个 token 的曲子能切出将近 500 个样本,样本量增长很快,这时候千万别用 numpy 数组一次性全塞进内存,用tf.data.Dataset.from_tensor_slices做惰性加载。
# dataset_builder.py import numpy as np import tensorflow as tf def make_sequences(token_ids, seq_len): X, y = [], [] for i in range(len(token_ids) - seq_len): X.append(token_ids[i:i + seq_len]) y.append(token_ids[i + seq_len]) return np.array(X, dtype=np.int32), np.array(y, dtype=np.int32) def build_dataset(token_ids, seq_len, batch_size=128): X, y = make_sequences(token_ids, seq_len) ds = tf.data.Dataset.from_tensor_slices((X, y)) ds = ds.shuffle(2000).batch(batch_size).prefetch(tf.data.AUTOTUNE) return dsshuffle(2000)的缓冲区不要设成整个数据集大小,否则内存直接翻倍;2000 到 5000 之间随机切窗已经足够打散顺序。prefetch(AUTOTUNE)是几乎零成本的加速,让 CPU 组 batch 和 GPU 训练并行。batch_size=128对 8G 显存是安全线,想压更大就先把seq_len降下来,反过来的坑是seq_len没变、batch 翻倍,训练到第三十轮左右梯度直接 NaN,前功尽弃。
4. 训练与采样:从概率分布到能播放的 MIDI
4.1 训练循环和该盯的几个量
训练脚本我一般用model.fit加ModelCheckpoint和EarlyStopping,别急着上自定义循环。MIDI 数据体量不大,fit提供的回调足够用。
# train.py import pickle, numpy as np, tensorflow as tf from data_pipeline import parse_midi_folder, build_vocab from dataset_builder import build_dataset from model import build_model # 1. 读数据、建词表 tokens = parse_midi_folder("./midi_corpus") vocab, t2i, i2t = build_vocab(tokens) with open("vocab.pkl", "wb") as f: pickle.dump((vocab, t2i, i2t), f) # 2. 转 id 序列,按 9:1 切训练/验证 ids = [t2i[t] for t in tokens] split = int(len(ids) * 0.9) train_ds = build_dataset(ids[:split], seq_len=16, batch_size=128) val_ds = build_dataset(ids[split:], seq_len=16, batch_size=128) # 3. 建模型并训练 model = build_model(len(vocab), seq_len=16) callbacks = [ tf.keras.callbacks.ModelCheckpoint("ckpt/best.keras", save_best_only=True, monitor="val_loss", mode="min"), tf.keras.callbacks.EarlyStopping(patience=8, restore_best_weights=True), ] model.fit(train_ds, validation_data=val_ds, epochs=200, callbacks=callbacks)三个监控量:loss在前 5 轮内应该掉到 4 以下,如果一直横在 4.6 附近基本就是词表或输入维度对错了;val_loss和train_loss的差值超过 0.5 就该加 dropout 或者收窄 hidden size;accuracy别当成 KPI 看,MIDI 词表大、音符分布本来就长尾,拿到 30% 的 top-1 准确率已经能生成可听的旋律。EarlyStopping的patience=8是我试出来的经验值,设 3 太容易在第 10 轮就被叫停,设 20 又浪费时间。别迷信“训到 loss 接近 0”,那多半是记住了训练集,采样时会开始循环一段四小节的东西,听着更无聊。
4.2 温度采样:把 softmax 输出变成能听的旋律
训练完成后,推理阶段最关键的一步是采样策略。贪心取argmax会让旋律变得过于确定,两三个音来回跳;完全按概率采样又容易跳出调外音。常用的折中是带温度的采样,温度系数T控制概率分布的“尖锐程度”。
# generate.py import numpy as np, pickle, tensorflow as tf from music21 import stream, note, chord, tempo, meter def sample_with_temperature(preds, temperature=1.0): preds = np.asarray(preds).astype("float64") preds = np.log(np.clip(preds, 1e-8, 1.0)) / temperature exp = np.exp(preds - np.max(preds)) # 数值稳定 probs = exp / exp.sum() return int(np.random.choice(len(probs), p=probs)) def generate(model, i2t, seed_ids, length=200, temperature=0.8, seq_len=16): result = list(seed_ids) for _ in range(length): x = np.array(result[-seq_len:], dtype=np.int32)[None, :] preds = model.predict(x, verbose=0)[0] next_id = sample_with_temperature(preds, temperature) result.append(next_id) return result def to_midi_stream(id_seq, i2t, bpm=100): s = stream.Stream() s.append(tempo.MetronomeMark(number=bpm)) s.append(meter.TimeSignature("4/4")) for i in id_seq: pitch, dur = i2t[i] n = note.Note() n.pitch.midi = pitch n.quarterLength = dur s.append(n) return ssample_with_temperature里先取对数再除温度,是为了把概率分布按幂律调整——T < 1放大高概率项,输出保守但稳定;T > 1拉平分布,随机性增强。我一般从 0.8 起步,听感抒情就往 0.7 调,想要跳脱点就上 1.1,超过 1.3 基本出调。np.clip那行是防log(0)的黑匣子补丁,早期没写这行,生成时偶尔弹出-inf直接报错,查了半天才发现是某个音符概率被模型压到了 0。to_midi_stream里给每个 id 还原成note.Note并写入时值,最后s.write("midi", fp="output.mid")就能在 DAW 里打开。BPM 不参与训练,纯输出属性,随意调。
5. 避坑与常见问题排查
5.1 生成的文件能打开但完全没有声
现象:output.mid在 DAW 里能加载,轨道上也显示有音符块,但播放一秒是纯静音。 原因:note.Note()只设了pitch.midi没设pitch.step/octave,music21 写 MIDI 时某些版本会写出空的 pitch 字段,读回来就是一串 rest。另外quarterLength设成 0 或负数也会静音。 解决:统一用n = note.Note(pitch.midi)构造,让 music21 自己派生音名和八度,别手动改pitch.midi属性。生成后立即converter.parse("output.mid")回读一次,检查len(stream.flatten().notes) == 期望音符数,对不上就在写文件那一步加断言。
5.2 训练 loss 一直卡在 4.6 不动
现象:fit跑了十几个 Epoch,loss稳定在 4.6 附近波动,accuracy不到 1%,像什么都没学。 原因:九成是Embedding的input_dim小于实际 id 最大值,或者sparse_categorical_crossentropy的标签越界。另一种常见情况是整个语料只有一个音高,标签分布完全均匀,数学上就是学不动。 解决:训练脚本开头加assert max(t2i.values()) < vocab_size;再打印set(tokens)的大小,如果小于 5 直接放弃当前语料,换更丰富的 MIDI 数据集。别在 loss 卡住的时候加层加参数,方向反了越加越乱。
5.3 生成几百个音符后开始无限循环
现象:前 16 个 seed 之后的旋律挺正常,生成到七八十步开始“三音循环”,后半段全是同一小节的复制。 原因:模型在训练集上过拟合,采样时陷入概率最高的吸引子;另一种原因是seq_len太短,模型看不到更早的上下文,只能根据局部模式反复输出。 解决:先把dropout从 0.2 提到 0.3、seq_len从 16 提到 32 重训一轮;采样端把temperature从 0.8 提到 1.0,并在推理时给最近 8 个 token 加重复惩罚(出现过一次就减一个固定值)。这两招叠加基本能打破循环。
5.4 训练到一半显存爆掉或报 ResourceExhausted
现象:前 20 轮正常,中途ResourceExhaustedError,或者系统直接杀进程。 原因:batch_size和seq_len乘积超过了显卡容量;或者tf.data.Dataset没加prefetch,CPU 批量组装时内存和显存一起涨。 解决:先把batch_size砍半试,不行再降seq_len,两个一起降是下策会让模型变笨。Dataset那侧检查batch()之后有没有.prefetch(tf.data.AUTOTUNE),加之前显存峰值能差 30%。另外别在fit里开verbose=2刷屏,长训练时日志输出本身也吃内存。
5.5 Windows 下 music21 读中文路径直接抛异常
现象:Linux 上跑得好好的脚本,换到 Windows 机器上converter.parse()对某些目录下的文件报UnicodeDecodeError或直接返回None。 原因:music21 底层用open()按系统默认编码读取,中文路径 + 非 ASCII 文件名容易触发解码错误。 解决:语料目录和文件名统一改成 ASCII 命名(track_001.mid),训练脚本路径里不出现中文;实在要保留原名,读之前用pathlib.Path(path).read_bytes()先读成字节再交给converter.parseData()。这是纯粹的工程卫生问题,和模型无关,但第一次踩会查很久。
6. 进阶:用 pygame 做即时试听,再谈可控生成
训练和生成打通之后,真正磨人的是“生成二十首,挑一首能听”。每次写 MIDI 再拖进 DAW 太慢,我一般在generate.py后面接一个 pygame 试听函数,跑一次生成直接循环播放。核心代码不长:
# listen.py import pygame, time from music21 import converter from music21.midi.translate import midiFileToStream def play_midi(path, loop=1, seconds_per_file=None): pygame.mixer.init() pygame.mixer.music.load(path) pygame.mixer.music.play(loops=loop - 1) # loops 是额外重复次数 while pygame.mixer.music.get_busy(): time.sleep(0.1)pygame.mixer.music走的是系统 MIDI 音源,Windows 上默认用 Microsoft GS Wavetable,macOS/Linux 上可能需要先装 FluidSynth 并设置SDL_SOUNDFONTS环境变量指向.sf2文件,不然播放是静音。loops=loop - 1是 pygame 的参数习惯,传 0 表示播一遍,传 -1 是无限循环,我第一次用的时候传loop=1以为播一遍,实际播了两遍,排查半天才发现是这层叠加逻辑。
参数调控上,真正影响听感的是三个旋钮:temperature、seq_len和 prompt 长度。温度前面说过,0.7 到 1.1 之间扫一遍基本能覆盖“稳定抒情到活泼跳跃”的区间。seq_len决定模型看多远的上下文,16 适合短动机生成,32 更适合整段旋律续写,代价是显存和训练时间都翻上去。prompt 长度是很多人忽略的:给 4 个音符的种子,模型会在最初几步输出很大方差,给 16 个音符的种子,接出来的旋律和种子风格一致性明显更高。我一般从训练集里随机切 16 个 token 做种子,生成完再比较不同种子下的听感,挑风格最接近目标的那组温度和种子组合。
从那以后我每次跑新语料都强制走一遍“解析数量对账 → 词表大小检查 → loss 前 5 轮是否下降 → 生成 20 个样本人工抽听”这条流水线,中间任何一步异常就先停下看数据而不是调模型。模型结构在 MIDI 任务上差距真的没想象中大,数据管道和采样策略才是决定生成能不能听的地方。希望帮到你。
本文还有配套的精品资源,点击获取