☰
Keras 搭建 MIDI 生成神经网络:从音符序列到可播放文件
2026/10/14 16:20:24 网站建设 项目流程

简介:这份资源是面向深度学习与音乐生成方向学习者的实战代码包,围绕在Midi数据集上训练神经网络生成音乐展开,适合具备Python基础、希望入门序列生成与概率模型的中高级开发者。包内以Python脚本为核心,涵盖贝叶斯超参数搜索、数据集预处理与numpy数组构建、模型训练与音乐生成等完整流程,并借助TensorFlow、Keras、music21与pygame等库实现从数据到音频的闭环。资源共0个文件,压缩包约1.02MB,体量轻便,便于快速部署与二次开发。目前已有187人学习关注,可作为硕士论文《具有深度概率模型的音乐完成》的配套实践参考。读者可据此掌握Midi数据清洗、超参数调优、模型训练与生成评估的完整链路,理解深度概率模型在音乐补全任务中的落地方式,并在此基础上迁移到其他序列生成场景。

1. 用 Keras 搭一个能写 MIDI 的深度神经网络:从音符序列到可播放文件

手里有一堆 MIDI 文件,想让模型学着写出一段能听的旋律,这件事比想象中直接。midiGenerator这个资源包干的就是这件事:用 TensorFlow/Keras 搭一个深度神经网络,把 MIDI 里的音符序列喂进去训练,再让模型一个音一个音地预测出新的序列,最后用 music21 写回标准 MIDI 文件,配合 pygame 直接播放试听。整条链路是 Python 生态里最成熟的一套组合——music21 负责解析和生成乐谱结构,Keras 负责建模,pygame 负责让你不用装 DAW 就能听到结果。它适合已经会写基础 Python、想入门序列生成的人,也适合做音乐信息检索方向、需要一个能跑通的最小闭环的从业者。下面按「数据怎么进、模型怎么搭、结果怎么出、坑在哪」的顺序拆一遍。

2. 数据管线:把 MIDI 拆成模型能吃的音符序列

2.1 为什么选 music21 而不是自己解析二进制

MIDI 文件本质是二进制事件流,直接读字节当然可以,但你会立刻撞上变长量、running status、多轨同步这些细节。music21 把这些封装成了Stream、Note、Chord对象,取音高、时值、偏移量都是一行属性访问。常见做法是用converter.parse()把每个 MIDI 读成 Stream,再flatten().notes拿到按时间排好的音符列表。这里有个选型理由值得说清:music21 的解析速度不算快,几千个文件可能要跑几分钟,但它的正确性经过大量乐谱验证,比手写解析器省下的调试时间多得多。如果你后续要处理几万首,再考虑换mido做批量预处理,但原型阶段没必要。

2.2 提取音符与和弦,统一成可编码的 token

模型不认识Note对象,得先把每个音符转成字符串 token。音高用pitch.nameWithOctave(比如C4、F#5),和弦用chord.normalOrder排序后拼起来,休止符单独标记。这样每个时间步就是一个 token,整首曲子变成一个 token 列表。

from music21 import converter, note, chord import glob def extract_tokens(midi_dir): all_tokens = [] for file in glob.glob(f"{midi_dir}/*.mid"): try: stream = converter.parse(file) except Exception as e: # 损坏文件直接跳过,别让一个坏样本中断整批 print(f"skip {file}: {e}") continue tokens = [] for element in stream.flatten().notes: if isinstance(element, note.Note): tokens.append(str(element.pitch.nameWithOctave)) elif isinstance(element, chord.Chord): # 和弦按音高排序后拼成 "C4.E4.G4" 形式 tokens.append(".".join(str(n) for n in element.normalOrder)) if tokens: all_tokens.append(tokens) return all_tokens

逻辑说明:flatten()把多声部压成单序列,notes只取音符和和弦,自动过滤掉拍号、调号这些非演奏元素。参数上,glob的路径按你实际存放 MIDI 的目录改;normalOrder返回的是音级整数,转成字符串是为了和单音 token 统一进同一个词表。注意异常捕获那一段——实际数据集里总有几个文件是坏的,不跳过的话整批预处理会直接崩掉。

2.3 建立词表并做序列切分

把所有 token 收集起来去重,得到词表。然后要决定用多长的窗口做输入。常见做法是取 50 到 100 个 token 作为一个样本,前 n 个预测第 n+1 个。窗口太短模型学不到乐句结构,太长则训练慢且容易过拟合。我一般先用 64 试,看验证集损失再调。

from tensorflow.keras.utils import to_categorical import numpy as np def build_dataset(all_tokens, seq_len=64): vocab = sorted(set(t for seq in all_tokens for t in seq)) token_to_id = {t: i for i, t in enumerate(vocab)} X, y = [], [] for seq in all_tokens: ids = [token_to_id[t] for t in seq] for i in range(len(ids) - seq_len): X.append(ids[i:i + seq_len]) y.append(ids[i + seq_len]) X = np.array(X) y = to_categorical(y, num_classes=len(vocab)) return X, y, vocab, token_to_id

逻辑说明:vocab排序是为了让每次运行词表顺序一致,方便复现。to_categorical把标签转成 one-hot,配合后面的 softmax 输出。参数seq_len就是前面说的窗口长度,改它要同步改模型输入维度。这一步内存占用和样本数成正比,如果 MIDI 很多,X可能撑爆内存,那就改用生成器分批喂。

3. 模型搭建与训练:LSTM 还是 GRU,层数和维度怎么定

3.1 网络结构的选择理由

序列生成任务里,LSTM 和 GRU 都是常见选择。GRU 参数少、训练快,在中小规模数据上表现和 LSTM 差不多;LSTM 在长依赖上略稳,但门控多、显存吃得多。这个资源场景下数据量通常不大(几百到几千首),我倾向先用 GRU 跑通,再换 LSTM 对比。结构上典型配置是:Embedding 层把 token id 映射成稠密向量,接一到两层循环层,最后 Dense + softmax 输出词表概率分布。Embedding 维度取 64 到 256,循环单元数取 128 到 512,这些不是玄学,是显存和表达力的折中。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, GRU, Dense, Dropout def build_model(vocab_size, seq_len, emb_dim=128, units=256): model = Sequential([ Embedding(vocab_size, emb_dim, input_length=seq_len), GRU(units, return_sequences=True), Dropout(0.3), GRU(units), Dropout(0.3), Dense(vocab_size, activation="softmax"), ]) model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["accuracy"]) return model

逻辑说明:第一层 GRU 设return_sequences=True是为了把序列信息传给第二层,第二层只返回最后一步的隐状态。Dropout(0.3)是防过拟合的常规手段,数据越少越要加。损失用交叉熵是因为每个时间步本质是多分类。参数emb_dim和units是主要调参对象,显存不够就先降units。

3.2 训练过程与检查点

训练时把数据切一部分做验证集,用ModelCheckpoint保存验证损失最低的权重。批次大小常见取 64 或 128,轮数看损失曲线,通常几十轮就收敛。这里有个血泪经验:不要只看训练准确率,序列生成任务里训练损失降得很低但生成结果全是重复音符是常事,必须定期用模型实际生成一段来听。

from tensorflow.keras.callbacks import ModelCheckpoint checkpoint = ModelCheckpoint("best.keras", monitor="val_loss", save_best_only=True, verbose=1) model.fit(X, y, batch_size=128, epochs=80, validation_split=0.1, callbacks=[checkpoint])

逻辑说明:validation_split=0.1从训练数据里划 10% 做验证,数据量大时可以改成显式传入验证集。save_best_only=True保证只留最好的那一版,避免最后一轮过拟合的权重覆盖掉好模型。注意 Keras 新版本保存格式是.keras,老代码里的.h5也能用但会有告警。

3.3 生成时的温度参数

生成不是取概率最大的那个 token 就完事,那样结果会很单调。常见做法是引入温度:温度趋近 0 时接近贪心,趋近 1 时按原始分布采样,大于 1 会更随机但也更容易跑调。我一般从 0.8 到 1.0 之间试,想要稳定旋律就调低,想要意外惊喜就调高。

def sample_with_temperature(preds, temperature=1.0): preds = np.asarray(preds).astype("float64") preds = np.log(preds + 1e-8) / temperature exp_preds = np.exp(preds) preds = exp_preds / np.sum(exp_preds) return np.random.choice(len(preds), p=preds)

逻辑说明:先取对数再除以温度,等价于对原始概率做幂次缩放,加1e-8是防止 log(0)。这个函数每次只采一个 token,生成整段时循环调用,把新 token 接到序列尾部再喂回模型。

4. 从 token 回到 MIDI:music21 写文件与 pygame 试听

4.1 把预测序列还原成 Stream

生成出来是一串 token,得反向映射回音符对象。单音 token 直接建note.Note,带点的按和弦处理。每个音符给一个固定时值(常见是八分音符或四分音符),偏移量按顺序累加。

from music21 import stream, note, chord def tokens_to_stream(tokens, note_dur=0.5): output = stream.Stream() offset = 0.0 for t in tokens: if "." in t: # 和弦 token,normalOrder 是音级整数,转回音名 pitches = [str(p) for p in t.split(".")] c = chord.Chord(pitches) c.offset = offset output.insert(offset, c) else: n = note.Note(t) n.offset = offset output.insert(offset, n) offset += note_dur return output

逻辑说明:note_dur控制每个音符占多少拍,0.5 对应八分音符。和弦那段的音级整数转音名在 music21 里有现成方法,这里简化成字符串拼接,实际用的时候建议查一下pitch.Pitch的转换接口,避免调号问题。insert的 offset 必须单调递增,否则 music21 会报错。

4.2 写出 MIDI 并用 pygame 播放

写文件一行搞定,播放则需要 pygame 的 mixer。注意 pygame 播放 MIDI 依赖系统里的音源,不同平台表现不一样,这是最容易翻车的地方。

import pygame def play_midi(path): pygame.mixer.init() pygame.mixer.music.load(path) pygame.mixer.music.play() while pygame.mixer.music.get_busy(): pygame.time.wait(100) output_stream = tokens_to_stream(generated_tokens) output_stream.write("midi", fp="generated.mid") play_midi("generated.mid")

逻辑说明:write("midi", fp=...)是 music21 的标准写法,第一个参数是格式。get_busy()轮询保证播放期间程序不退出。如果播放没声音,先确认系统有没有 MIDI 音源,Linux 上常见要装 soundfont,这是环境问题不是代码问题。

5. 避坑与排查:训练不收敛、生成全是重复音、播放没声音

5.1 损失不下降或震荡

现象:训练几轮后 loss 卡住不动,或者上下大幅震荡。原因通常是学习率太大、批次太小,或者数据里 token 分布极度不均(某个音出现频率过高)。解决:先把学习率降到 1e-3 以下试,批次调到 128;再统计词表频率,对高频 token 做下采样或给损失加权重。还有一种情况是序列窗口太长导致梯度问题,把seq_len从 64 降到 32 往往能救回来。

5.2 生成的旋律全是同一个音

现象:模型输出一长串相同的 token,听起来像卡带。原因一般是训练不充分,或者生成时温度太低走了贪心。解决:先确认训练轮数够不够,验证损失是否还在降;再把温度调到 0.9 到 1.0 之间。如果还是重复,检查训练数据本身是不是就有大量重复片段——数据质量决定生成质量,这点没有后悔药。

5.3 和弦 token 还原时报音高错误

现象:tokens_to_stream处理带点的 token 时抛异常或生成的和弦音不对。原因是normalOrder返回的是音级整数(0 到 11),直接当音名用会错。解决:用 music21 的pitch.Pitch(midi=...)或先转成chromatic再取 name,别用字符串硬拼。这个坑在第一次跑通单音、加和弦时几乎必踩。

5.4 pygame 播放无声或报错

现象:mixer.music.load成功但没声音,或者直接抛pygame.error。原因是系统缺少 MIDI 音源或 mixer 初始化参数不对。解决:先单独用系统播放器打开生成的.mid确认文件本身没问题;再检查 pygame 版本和音频后端。实在不行就跳过播放,用 DAW 或在线 MIDI 播放器试听,别在环境上耗太久。

5.5 显存溢出

现象:训练到一半报 OOM。原因是X一次性全载入内存,或者units、batch_size太大。解决:改用tf.data.Dataset做流式加载,把units从 512 降到 256,批次从 128 降到 64。序列任务里显存和序列长度是平方关系,降seq_len比降units更有效。

6. 进阶技巧:用多轨生成和种子序列控制输出风格

跑通单轨之后,想让生成结果更可控,有两个方向值得试。一是种子序列:不要从随机 token 开始,而是拿一段真实 MIDI 的前几十个 token 当输入,让模型接着往下写,这样风格会贴近种子。二是多轨:把不同乐器轨分别提取、分别训练,生成时按轨拼接,能得到有伴奏的片段。种子序列的实现很简单,把X的初始窗口换成种子 token 的 id 列表即可,注意长度必须等于seq_len。

参数作用常用取值调整方向
seq_len输入窗口长度32 / 64 / 100短则快但结构弱,长则慢且吃显存
emb_dimtoken 嵌入维度64 / 128 / 256词表大就调大
units循环单元数128 / 256 / 512显存不够先降这个
temperature采样温度0.8 / 1.0 / 1.2低则稳,高则野
note_dur输出音符时值0.25 / 0.5 / 1.0控制节奏密度

验证生成质量别只靠耳朵。我一般会统计生成序列的 token 分布和训练集的 KL 散度,散度太大说明模型没学到数据分布;再看平均音程跨度,跨度过大通常意味着跑调。这两个指标比单纯听更客观,也方便调参时对比。

从那以后我每次改完模型结构,都强制先用种子序列生成 30 秒听一遍,再决定要不要继续训练——耳朵比损失曲线诚实。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询