资讯详情 基于LSTM的自动音乐生成:从MIDI清洗到模型实现全攻略
📅 2026/10/26 15:55:07
简介面向机器学习课程设计与期末大作业场景这份基于LSTM的自动音乐生成项目以Python为主要实现语言借助循环网络对古典钢琴曲进行序列建模与音符预测最终生成可播放的旋律。项目已通过导师指导并获97分高分适合作为深度学习入门阶段的完整参考方案。压缩包共6个文件约462KB包含两个可交互运行的Jupyter Notebook训练主流程与界面演示、Python源码脚本、详细设计说明书PDF、项目说明及开源许可文件结构清晰下载解压即可按流程复现。目前已有296人浏览学习适用于需要快速搭建音乐生成Demo或撰写课程设计报告的读者。内容从数据预处理、LSTM模型构建到音乐输出均有覆盖说明书对设计思路与实现细节作了系统整理还可结合Notebook逐段运行观察中间结果便于理解代码逻辑与答辩展示。1. 自动音乐生成为什么选 LSTM从音符序列到可复现落地的完整路径基于机器学习 LSTM 的自动音乐生成是课程设计里性价比最高的选题之一。原因很直接音乐是天然的时间序列前后音符之间存在明显长程依赖而 LSTM 的门控机制恰好能把这种依赖保存在隐状态里。相比 Transformer 动辄上千万参数、相比 GAN 在片段生成上的不稳定单层 LSTM 用几十万参数就能产出听感可接受的主旋律和伴奏还能把源码、训练过程、生成样例完整写进设计说明书。这篇文章给出一套可以直接跑通的中小体量方案MIDI 数据怎么清洗、LSTM 网络怎么搭、超参数怎么设、生成时怎么避免循环重复和乱音四类翻车现场。适合正在做课程设计、毕业设计或者想快速验证 LSTM 在音乐生成任务上效果的同学。下面直接进入落地流程每一步都给源码和参数说明。2. 训练数据准备MIDI 转序列样本的清洗与编码方案2.1 音乐数据的两种形态事件序列与 Piano-Roll 矩阵MIDI 文件记录的是离散事件流比如某个时间点按下 60 号音符、再过多少 tick 松开它。这种表示天然适合循环神经网络逐步读取但直接拿事件流做训练有一个代价事件之间的时间间隔不均匀模型每读一步都要额外消化“间隔”信息批量训练也不方便。更通用的做法是先把 MIDI 转成 Piano-Roll 矩阵。Piano-Roll 是一个二维矩阵行是量化后的时间步列是音高常用 88 键对应 MIDI 21 到 108值 1 表示该时间步这个音正在发声0 表示未发声。转完之后音乐生成任务就变成标准的序列预测任务给模型前 N 行预测第 N1 行。课程设计和轻量级项目里我一般选 Piano-Roll 而不是事件流理由是矩阵输入可以整段并行送进模型滑窗切样本、做 Teacher Forcing、算 loss 都很直接。事件流方案适合做细粒度节奏控制的高级玩法但对做实操验证来说复杂度偏高。2.2 MIDI 转 Piano-Roll时间量化与音符对齐Piano-Roll 的第一个关键参数是时间步长 time_step。常见做法是取 0.125 秒对应 120 BPM 下的十六分音符。太细0.0625 秒会让矩阵里全零帧暴增训练变慢太粗0.25 秒会丢失八分音符以内的旋律细节。time_step对应节奏现象0.0625s三十二分音符时间分辨率高但全零帧占比大loss 被空帧主导0.125s十六分音符推荐默认值旋律细节与训练速度平衡0.25s八分音符训练最快但快速音阶和装饰音会被压平时间量化涉及一个隐藏问题音符对齐。MIDI 原始事件里的 note_on 和 note_off 不一定落在整数时间步上转换时必须确定音符从哪一格开始、到哪一格结束。常见做法是按时间排序事件后用字典记录当前打开的音符遇到 note_off 再配对最后把 start 到 end 之间的帧全部置 1。import mido import numpy as np def midi_to_pianoroll(path, time_step0.125, bpm120, lo21, hi108): mid mido.MidiFile(path) # 每秒 tick 数 ticks_per_beat * 每秒拍数 tick_per_sec mid.ticks_per_beat * (bpm / 60.0) events [] # (时间秒, 音符, 是否按下) for track in mid.tracks: tick_now 0 for msg in track: tick_now msg.time time_sec tick_now / tick_per_sec if msg.type note_on and msg.velocity 0: events.append((time_sec, msg.note, 1)) elif msg.type note_off or (msg.type note_on and msg.velocity 0): events.append((time_sec, msg.note, 0)) # 同一时刻先处理 note_off再处理 note_on避免同音高冲突 events.sort(keylambda x: (x[0], x[2])) active {} notes [] # (start_time, end_time, note) for t, note, is_on in events: if lo note hi: if is_on: active[note] t elif note in active: notes.append((active[note], t, note)) del active[note] total_steps int(max([e[0] for e in events], default0) / time_step) 2 roll np.zeros((total_steps, hi - lo 1), dtypenp.float32) for start, end, note in notes: s int(start / time_step) e max(s 1, int(end / time_step)) # 至少占一个时间步 roll[s:e, note - lo] 1.0 return roll这里有几个实现细节值得注意。排序时用(x[0], x[2])做键让同一时间点的 off 事件排在 on 前面避免同一个音在一个时间步内既被关闭又被打开导致漏音。结束帧e用max(s 1, ...)兜底确保时值再短的音符也至少出现一帧否则生成模型会学到“吞音”。MIDI 文件多数没有显式标注 BPM这里默认 120如果原始文件有 tempo 变化需要按变化点分段重算 tick_per_sec课程设计阶段通常用统一 BPM 即可。2.3 滑窗切分与样本组织单首曲子的 Piano-Roll 长度动辄上千帧不能整段塞进 LSTM。常见做法是用滑窗切成固定长度的样本每个样本都是一个独立的训练序列。def make_samples(roll, seq_len64, stride32): samples [] for start in range(0, max(len(roll) - seq_len, 0), stride): samples.append(roll[start:start seq_len]) if not samples and len(roll) seq_len: samples.append(roll[:seq_len]) return np.array(samples)序列长度 seq_len 直接影响模型能感知的乐句跨度。64 帧在 0.125 秒时间步下对应 8 秒大约等于 120 BPM 下的 4 小节对主旋律生成来说是够用的。步长 stride 决定样本重叠程度strideseq_len 时样本完全不重叠数据量小但多样性低stride32 会让相邻样本共享后半段数据扩充到约两倍。我一般用 seq_len64、stride32这个组合在数据量和过拟合风险之间比较均衡。2.4 噪声数据过滤机器学习的噪声数据直接影响生成质量音乐生成的数据清洗比图像分类更讲究因为 MIDI 文件来源五花八门质量参差不齐。机器学习的噪声数据在这里体现得非常直接一段乱弹的 MIDI 会让模型学到无规律跳音一段 30 秒静音会让模型学会“沉默”。我一般设三条过滤规则丢弃总时长小于 seq_len 乘以 time_step 的片段样本太短没法形成完整乐句。丢弃全零帧占比超过 40% 的样本否则训练时大部分时间步都是空帧模型会偏向输出全零。丢弃单帧同时发音符超过 8 个的样本这类文件多半是 MIDI 转换错乱产生的和弦堆叠不是真实音乐。过滤之后最好再统计一下剩余样本的平均音符密度。如果每帧平均只有 0.5 个音符生成结果会偏稀疏如果高于 4 个则要考虑是否是数据源本身太嘈杂。这一步做扎实后面模型训练会省很多事。3. 搭建 LSTM 模型源码结构与核心参数设计3.1 输入输出为什么是“过去 N 帧预测下一帧”把音乐生成建模成序列预测后输入输出非常直观给定前 N 个时间步的 88 维音符向量预测第 N1 个时间步的 88 维音符向量。模型本身不“作曲”它学习的是条件概率分布 P(下一帧 | 前 N 帧)。这里有一个关键选型输出层用 sigmoid 还是 softmax。很多课程设计示例默认用 softmax然后取概率最大的音高作为输出这对单音旋律可行但对钢琴曲、和弦伴奏是灾难——同一时间步往往同时有多个音在响softmax 强制只选一个等于把复音音乐压成单音。输出层适用场景行为特征softmax单音主旋律每个时间步只输出一个音符概率和为 1sigmoid复音、和弦、多轨合奏每个音符独立判断发声与否可同时输出多个音MIDI 音乐默认就是多轨复音所以模型输出层用 sigmoid每个音高独立输出一个 0 到 1 的概率值生成时按伯努利分布采样这样既保留和弦又能控制采样随机性。3.2 模型结构单层 LSTM 加 Dropout 足够网络结构我通常给一个克制版本单层 LSTM 128 单元接 Dropout再接 Dense 88 输出。不用堆两层原因有两个。第一音乐生成训练集通常只有几十到几百首 MIDI参数越多越容易过拟合生成结果变成重复片段。第二双层 LSTM 在长乐句建模上确有提升但训练时间几乎翻倍课程设计周期内很难感受到明显差别。不要用 stateful LSTM这是个常见的坑。stateful 模式要求按顺序切分样本并手动管理状态复位和滑窗重叠样本配合时逻辑容易出错。无状态 LSTM 每次看到完整的 64 帧窗口上下文已经包含在窗口里了足够学习短乐句结构。3.3 核心模型源码import tensorflow as tf from tensorflow.keras.layers import Input, LSTM, Dense, Dropout from tensorflow.keras.models import Model def build_lstm_model(seq_len64, note_dim88, lstm_units128, dropout0.2): inputs Input(shape(seq_len, note_dim)) # return_sequencesTrue 保证每个时间步都有输出方便逐帧算 loss x LSTM(lstm_units, return_sequencesTrue, dropoutdropout)(inputs) x Dropout(dropout)(x) outputs Dense(note_dim, activationsigmoid)(x) model Model(inputs, outputs) model.compile( optimizeradam, lossbinary_crossentropy, metrics[accuracy, tf.keras.metrics.Recall(), tf.keras.metrics.Precision()] ) return model这段代码的核心是把return_sequencesTrue打开让 LSTM 输出和输入保持同样的时间维度batch, seq_len, 128这样 Dense 层会对每个时间步分别预测下一帧loss 能覆盖整段序列而不是只覆盖最后一步。训练时用“输入前 63 帧、输出后移一帧”的构造方式推理时再把预测出的 88 维向量拼回窗口末端这个循环逻辑在第五章展开。参数上note_dim88对应钢琴 88 键lstm_units128是隐状态维度。dropout 取 0.2 左右比较稳太高会让模型学不动太低又容易把训练集背下来。提一个隐藏的模型设计问题有人会在输入后加Masking(mask_value0.0)让 LSTM 跳过全零帧。音乐生成里这个做法要谨慎休止符在音乐中是重要的节奏信息不是需要跳过的无效填充。如果数据清洗后仍有大量空帧正确做法是调整过滤阈值而不是用 Masking 把它们藏起来。我自己的实现里不加 Masking 层。3.4 损失函数与评价指标accuracy 在这里基本没用多标签二分类的标配损失是binary_crossentropy对应每个输出单元独立的 logloss。不建议用categorical_crossentropy它默认标签是互斥类别和 sigmoid 多标签输出不匹配。评价指标要特别小心。Piano-Roll 里大部分时间步是空的全预测 0 也能拿到 90% 以上的 accuracy但生成的音乐会变成一片死寂。正确的做法是同时看 Recall 和 PrecisionRecall 低说明模型漏掉了大量音符Precision 低说明模型在乱发声。训练过程中如果发现 accuracy 很高但 Recall 很低基本可以断定模型退化成全零预测器需要调整正负样本权重。4. 训练流程与超参数调优让模型真正学到乐句4.1 数据生成器与 Teacher Forcing 构造训练时最常见的做法是 Teacher Forcing输入前 seq_len-1 帧预测后面 seq_len-1 帧监督信号就是真实样本本身。用生成器组织数据可以避免把全部样本一次性加载进内存也方便在训练中做数据增强。def batch_generator(sequences, batch_size64): while True: idx np.random.choice(len(sequences), batch_size) batch sequences[idx] # shape: (batch, seq_len, 88) X batch[:, :-1, :] # 输入前 seq_len-1 帧 Y batch[:, 1:, :] # 后移一帧做监督 yield X, Y这里有个容易搞错的维度输入取[:, :-1, :]监督取[:, 1:, :]长度都是 63。模型内部靠return_sequencesTrue对每个时间步输出预测从第二帧开始做对比天然对齐了“用历史预测未来”的目标。训练时模型看到的全是真实音符这能加速收敛但推理时模型吃的是自己生成的预测帧两者分布不一致这就是所谓的 exposure bias。缓解办法是在训练后期随机混入少量预测帧但课程设计阶段一般不用做那么深说明书写清这个机制即可。4.2 三个必调参数序列长度、batch size、学习率这几个参数是训练曲线好不好看的关键也是设计说明书中必须写明白的部分。参数建议值调参现象seq_len64太短学不到乐句太长训练慢且梯度传递弱batch_size64太小 loss 波动大太大会占用大量显存learning_rate1e-3 配合衰减太高 loss 震荡不降太低收敛极慢LSTM 单元数128256 提升有限训练时间明显变长dropout0.2小于 0.1 容易过拟合大于 0.3 学不动学习率是玄学味最重的一个参数。我一般先用 1e-3 跑 10 个 epoch 看走势如果 loss 完全不降或者直接变成 NaN降到 3e-4 重开。配合ReduceLROnPlateau让它在训练中自动降一半比自己手动调省心得多。from tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau, EarlyStopping checkpoint ModelCheckpoint(best_model.h5, save_best_onlyTrue) reduce_lr ReduceLROnPlateau(monitorloss, factor0.5, patience5) early_stop EarlyStopping(monitorloss, patience15, restore_best_weightsTrue) model.fit( batch_generator(train_samples, batch_size64), steps_per_epoch200, epochs100, callbacks[checkpoint, reduce_lr, early_stop] )save_best_only会保留验证 loss 最低的权重防止训练后期过拟合把好权重覆盖掉。patience5表示连续 5 个 epoch 不下降就降学习率。4.3 三招识别训练失败不是 loss 降了就万事大吉第一招loss 降不下去数值停在 0.65 附近不动。先检查学习率再看数据清洗有没有做过空帧比例太高的样本会让 loss 在局部最小值附近徘徊。第二招loss 持续下降但生成的音乐循环重复同一段。这是过拟合的典型信号模型把训练样本背下来了。解决方法是增大 dropout增加训练数据或者调高温度采样参数而不是继续训练更多 epoch。第三招loss 很好看但生成结果全是空帧。这是正负样本极度不平衡造成的模型发现全输出 0 的 loss 已经足够低。解决方法是过滤全零样本或者在binary_crossentropy里给正样本加权人为提高音符帧在 loss 中的占比。训练过程中固定每个 epoch 生成一次 4 小节音乐秒听一下输出比盯 loss 曲线靠谱。这个习惯帮我避开了很多次“指标满分、听感零分”的假阳性训练结果。5. 生成阶段与避坑指南从预测音符到可播放的 MIDI5.1 温度采样argmax 的替代方案模型输出的是 88 个 sigmoid 概率值直接取阈值 0.5 判定是否发声出来的音乐会非常机械而且容易陷入局部循环。常见做法是引入温度参数做随机采样温度越低越接近 argmax输出保守温度越高越随机输出跳跃。def sample_from_logits(pred, temperature0.8): pred np.clip(pred, 1e-7, 1 - 1e-7) # 防止 log 溢出 logits np.log(pred / (1 - pred)) # 转成 logit 空间 logits logits / temperature prob 1.0 / (1.0 np.exp(-logits)) # 再转回概率 return (np.random.random(88) prob).astype(np.float32)这个函数先把 sigmoid 概率转回 logit除以温度后再转回概率本质是调整分布的“陡峭程度”。温度等于 1 时保持原分布不变小于 1 时分布变陡大于 1 时分布变平。注意不要直接对pred / temperature做伯努利采样那样会破坏概率的归一化语义。5.2 生成循环与 MIDI 导出生成时从训练集随机截取一段作为种子序列然后循环预测下一帧把预测帧拼到窗口末尾同时丢掉最早的一帧保持输入长度恒为 seq_len。def generate_music(model, seed, length128, temperature0.8): window seed.copy() output [] for _ in range(length): pred model.predict(window[np.newaxis, :, :], verbose0)[0, -1, :] next_frame sample_from_logits(pred, temperature) output.append(next_frame) window np.vstack([window[1:], next_frame]) return np.array(output)导出 MIDI 用 MIDIUtil 最省事。它按秒记时间不需要手动算 tick和 Piano-Roll 的时间步长天然对应。from midiutil import MIDIFile def pianoroll_to_midi(pianoroll, output_path, time_step0.125, bpm120, velocity64): midi MIDIFile(1) midi.addTempo(0, 0, bpm) active_notes {} for t, frame in enumerate(pianoroll): active set(np.where(frame 0)[0]) # 先关掉已经结束的音符 for note in list(active_notes): if note not in active: start active_notes[note] * time_step duration (t - active_notes[note]) * time_step midi.addNote(0, 0, int(note 21), start, duration, velocity) del active_notes[note] # 再打开新出现的音符 for note in active: if note not in active_notes: active_notes[note] t # 序列结束时关闭剩余音符 for note, start_t in active_notes.items(): start start_t * time_step duration (len(pianoroll) - start_t) * time_step midi.addNote(0, 0, int(note 21), start, duration, velocity) with open(output_path, wb) as f: midi.write(f)注意导出时int(note 21)是把 0-87 的列索引还原成 MIDI 音高号这个逆转换要和第二节的note - lo严格对应漏掉这里生成的文件会整体跑调。MIDIFile 的addNote参数依次是轨道号、通道号、音高、开始时间、时长、力度duration 过短时播放器可能听不到音所以至少给一个 time_step 的时长。5.3 三个高频踩坑现场现象、原因、解决踩坑一是生成结果无限循环同一段旋律。现象是每当生成到第 30 帧左右就开始重复循环周期固定。原因是温度太低退化成 argmax模型每次都选概率最高的路径一旦陷入稳定轨迹就很难跳出。解决方法是把温度调到 0.8 到 1.0如果仍重复检查训练数据里是否有大量重复乐句这种噪声数据会让模型把重复当成常态。踩坑二是生成结果出现大段空白。现象是输出里连续 20 帧以上全是零向量音乐突然中断。原因是全零帧在数据清洗时没有被过滤干净模型学到了休止符的过长版本。解决方法是降低全零样本占比并在生成时对连续空白做长度约束强制打断。踩坑三是生成结果像乱弹音符跳变频繁毫无调性。现象是相邻两帧的音高差经常超过 12 个半音。原因是温度太高模型在每帧都独立采样忽略了钢琴 roll 里前后帧的平滑性。解决方法是把温度降到 0.6 到 0.8同时在生成循环里加一个简单规则如果某音符只出现一帧就被切换在下一次采样时给它少加一点概率强制时值稳定。这三个坑基本覆盖了课程设计答辩时评委最爱问的“你为什么生成结果不稳”问题写进设计说明书的测试章节里很加分。6. 进阶验证与收尾从能出声到能解释6.1 用隐状态可视化验证模型是否在“听”如果答辩或进阶验证时需要展示模型内部行为可以把 LSTM 的隐状态抽出来看分布。Keras 的处理是在建模型时把 LSTM 层拆出来单独构建一个输出隐状态的推理模型。from tensorflow.keras.models import Model layer_name lstm lstm_layer model.get_layer(layer_name) state_model Model(inputsmodel.input, outputslstm_layer.output) states state_model.predict(seed[np.newaxis, :, :], verbose0) # states shape: (1, seq_len, 128)画出 state 矩阵的热力图会发现模型在乐句边界或音符密集区域隐状态变化剧烈在持续长音处趋于平缓。这一步不需要额外训练却能把“LSTM 学到了时序结构”这件事可视化地写进说明书。6.2 三个最快的客观验证指标生成结果没法定量评分但可以用三个统计指标做横向对比重复度相邻 16 帧窗口的余弦相似度、音符覆盖率用到的音高数除以 88、空帧占比。重复度低于 0.3、覆盖率高于 0.2、空帧占比在 0.1 到 0.4 之间一般听感不会太差。这三个数可以写进测试表让生成质量有可复现的数据支撑。6.3 最后一课先把数据洗到满意再碰模型结构个人经验里最大的教训是刚开始做音乐生成时拿了一批含大量静音段的 MIDI 直接训练无论怎么调 LSTM 单元数生成结果都偏向“沉默”后来才发现是数据清洗环节出了问题。换模型结构不如换数据这个方向的第一优先级永远是数据质量。后来我把清洗规则固化成上面第三节那三条加上每次训练前打印样本的空帧比例和音符密度翻车概率大幅下降。节奏、和弦、休止符这些音乐特征最后都沉淀在数据分布里模型只是把它们复述出来。这套基于 LSTM 的自动音乐生成方案从 MIDI 清洗到源码实现每一步都能写进课程设计文档里值得完整做一遍。希望帮到你。本文还有配套的精品资源点击获取