
简介面向机器学习与音乐生成方向的开发者该资料提供了一套基于LSTM的自动音乐生成优化方案。针对Simple RNN与WaveNet生成音乐同质化、听感欠佳的问题方案以长短期记忆网络为核心在最少人为干预下完成短曲创作与播放既适合课程设计、毕设参考也可作为音乐AI入门的完整实践样例便于理解循环神经网络在时序生成任务中的应用。压缩包共8个文件含2个Jupyter Notebook分别实现主模型与UI界面、1个Python脚本、2个Markdown说明、1份PDF详细设计说明书及许可证文件包体仅466KB轻量易部署。实现代码附有详细注解通过源码与设计文档可系统梳理数据预处理、LSTM训练流程、乐谱序列化、UI交互与项目落地等关键环节PDF说明书还提供了详细设计思路便于二次开发与调优。目前已有239人学习浏览资源结构清晰适合希望快速复现自动作曲流程并尝试改进生成质量的读者。1. 把音乐生成从「能响」做到「能听」LSTM 是这条路最近的拐点做自动音乐生成的人大概率都遇到过同一个尴尬模型跑通了歌也在响了可听来听去总觉得不像曲子像一段循环伴奏。用 Simple RNN 生成旋律过了四个小节就开始原地踏步换成 WaveNet音色是真细腻但音乐性依然糊成一片。这个资源的核心思路很直接——不在这两个老方案上打补丁而是把核心换成 LSTM长短期记忆网络让模型自己去学旋律里的长程结构。它解决的问题不是「能不能生成音符」而是「生成的音符能不能组成一句像样的旋律」。适合做机器学习课程设计的人也适合第一次拿 LSTM 做序列生成的开发者用它把「训练—生成—回放」整条链路一次跑通。2. 选型逻辑为什么不用 Simple RNN 和 WaveNet而是把 LSTM 当核心2.1 同质化问题出在哪Simple RNN 的梯度消失和 WaveNet 的固定感受野先说说市面上那两类方案各自卡在哪。Simple RNN 每次接收输入时只保留上一个时间步的隐状态理论上是能记住上下文的但一旦序列超过几十步反向传播时梯度要穿过所有时间步连乘之后梯度要么爆炸要么消失。梯度一消失模型能学到的就只剩最近几个音符的关系于是生成出来的旋律永远在「局部顺滑、整体原地转圈」。这在摘要里被总结为「同质化严重」现象就是多生成几首听起来像同一首曲子换了调。WaveNet 走的是另一条路它用膨胀因果卷积扩大感受野生成音频波形时确实能把音色做得很真。但它的建模对象是采样点级别的波形不是音符级别的旋律结构。对一首钢琴曲来说真正重要的是主题、变奏、和声进行这些跨几十上百个音符的结构WaveNet 的感受野哪怕撑到几千个采样点换算成音符也就几个音的长度自然学不到「第二段要呼应第一段」这种东西。它擅长的是「像不像一段声音」不是「像不像一支曲子」。这两个方案的共同问题是把「序列建模」这件事做浅了。而音符本身就是一个天然的序列数据要学的是「给定前面 N 个音下一个音最可能是什么」。这正是 LSTM 的主场。2.2 LSTM 的门控结构为什么适合旋律建模LSTM 和 Simple RNN 的差别在于它在隐状态之外多了一条「细胞状态」通道像一条贯穿所有时间步的传送带。信息可以从第 1 个小节一路传到第 50 个小节中间只被门控做小幅修改而不是每走一步就被非线性激活函数重新洗牌一遍。遗忘门决定要不要丢掉旧主题输入门决定新的旋律素材要不要写进细胞状态输出门决定当前时刻要把哪些记忆用于预测下一个音符。把这个机制映射到音乐上很直观一首钢琴曲开头有一个主题动机中间经历了变奏和离调结尾要回到主题。对 Simple RNN 来说这个「主题动机」传到第 16 个小节基本就被梯度消失抹掉了对 LSTM 来说细胞状态里始终保留着主题的轮廓生成到结尾时能自然地把主题「召唤」回来。所以用 LSTM 做音乐生成不需要精心设计特征工程只要把音符序列喂进去门控机制自己就会去学哪些跨距离的依赖值得保留。2.3 资源核心模块LSTM 钢琴作曲器的工作流程看这份资源的文件结构核心链路是Classical-piano-composer数据集 create_music_py.py构建训练序列 main.ipynb训练 UI.ipynb做交互回放。README 和详细设计说明书把每一步的意图都写清楚了适合作为「理解一个完整 LSTM 音乐生成项目」的骨架。常见的工作流程是这样先从 MIDI 文件里读出音符序列把每个音符映射成整数 ID然后用滑窗把长序列切成「前 N 个音预测第 N1 个音」的训练样本。输入是音符 ID 序列输出是移位的下一个音符 ID。LSTM 在这个映射上做多分类类别数就是曲子里出现的不同音符数。生成阶段用训练好的模型给定一个种子序列逐音符采样再把采样的音符 ID 还原成 MIDI 并播放。这个流程里最容易被低估的是两个点一个是 MIDI 转序列时的音符量化方式另一个是生成阶段的采样策略。这两处直接决定了输出是「像曲子」还是「像音符的无序堆叠」后面两章分别展开。3. 从 MIDI 到训练样本把一首钢琴曲拆成 LSTM 认识的序列3.1 MIDI 里的音符和时长怎么变成数字MIDI 文件本质上是一串「音符事件」每个音符有音高note number、开始时间、持续时长和力度。要把它变成 LSTM 能吃的序列第一步是决定「一个时间步」代表什么。常见做法是把一个时间步定义为固定粒度的时长比如一个 16 分音符一个四分音符就占 4 个时间步。这样处理后「旋律」就成了一条等间隔的离散序列序列里每个元素是「当前时间步正在响的音符集合」。这一步有个容易翻车的细节同一时刻可能有多个音符在响也就是和弦。如果简单地把每个时间步的元素定义成单个音符和弦会被拆散生成出来的和声就会支离破碎。更靠谱的做法是压缩编码把每个时间步正在响的所有音符组合成一个「状态 ID」用组合后的状态做分类。这样模型学到的是「下一个音符组合是什么」而不是「下一个单独音符是什么」。def parse_midi_to_sequence(midi_path, time_step0.25): 将MIDI文件解析为按时间步排列的音符序列。返回: list[list[int]]每个元素是某个时间步上所有正在发声的音符列表。 import mido mid mido.MidiFile(midi_path) # 用字典存每个 tick 上发生的音符事件tick 先转成绝对时间 events {} current_time 0.0 for msg in mid: current_time msg.time if msg.type note_on and msg.velocity 0: tick int(current_time / time_step) events.setdefault(tick, set()).add(msg.note) elif msg.type note_off or (msg.type note_on and msg.velocity 0): # 停止音符时记录结束 tick这里简化只统计发声区间 pass max_tick max(events.keys()) return [sorted(events.get(t, [])) for t in range(max_tick 1)]上面的函数把 MIDI 按固定时间步切分每个时间步保留一组正在发声的音符。注意我这里用note_on事件来标记发声起始实际项目中应该同时处理note_off来确定音符结束否则长音会被截短。time_step是量化精度0.25 秒意味着每秒 4 个时间步对 120 BPM 的曲子来说大约是 8 分音符的粒度想要更细的旋律变化可以改成 0.125 秒。3.2 组合状态编码与滑窗切分拿到「每个时间步的音符集合」之后下一步是把这些集合映射成整数 ID。做法是先遍历所有 MIDI 文件收集所有出现过的音符组合按出现频率排序后分配 ID。这样每个时间步都对应一个整数LSTM 的输入就成了shape(batch, sequence_length)的整数张量。滑窗切分是这个流程里最需要调参的地方。窗口越长模型能看到的旋律上下文越多但训练样本数量越少、训练越慢窗口越短样本越多但模型学不到长程结构。我一般先用 32 个时间步跑通流程确认生成结果的质量之后再加大到 64 或 128。这个资源里如果把 sequence_length 定为 64就意味着模型在预测当前音符时能看到前面约 16 个小节的旋律——这个长度足够让「主题再现」这种结构被学到。def build_training_samples(sequences, seq_length64): 把所有时间步序列切成 (输入, 目标) 样本对。 - 输入是长度为 seq_length 的整数序列 - 目标是输入序列右移一位后的下一个音符组合 ID all_ids [] for seq in sequences: for combo in seq: all_ids.append(combo_to_id[tuple(combo)]) x, y [], [] for i in range(len(all_ids) - seq_length): x.append(all_ids[i:i seq_length]) y.append(all_ids[i seq_length]) # 预测第 iseq_length 个时间步 return np.array(x), np.array(y)注意这里滑窗的步长是 1也就是每往后挪一个时间步就切一个样本。步长越小样本越多但相邻样本之间的重叠度极高训练时容易过拟合到「照抄上一个时间步」的惰性解。想缓解这个问题可以把切窗步长设成seq_length // 2让样本之间有一半的重叠既保留连续性又减少冗余。另一个常见做法是训练时在每个 epoch 里随机打乱样本顺序避免模型记住固定的曲子顺序。3.3 训练集和验证集的划分别把同一首曲子切到两边划分数据时最容易踩的坑是一首曲子的前半段进了训练集后半段进了验证集。因为滑窗切出来的样本高度重叠验证集里会出现大量和训练集几乎一样的样本验证损失会虚低让你误以为模型学得很好。正确做法是按文件划分——把 MIDI 文件而不是样本作为划分单位。大约 80% 的曲子进训练集20% 进验证集。验证集只用来观察过拟合程度不参与参数更新。还有一个容易被忽略的细节组合状态的 ID 空间可能非常大。如果一首曲子有大量和弦外音和装饰音出现的「音符组合」可能有几百上千种而训练样本只有几万条模型会花大量容量去记罕见组合。常见的缓解办法是过滤低频状态出现次数少于 3 次的组合统一合并到一个UNK状态。这样既压小了分类空间也避免了模型为了一个只出现一次的组合浪费参数量。4. 训练与采样模型参数怎么设生成的曲子才有变化4.1 核心训练参数与默认值LSTM 音乐生成模型的训练参数业界没有一套「标准答案」但基于这个资源和常见实现最稳的起步配置如下。先说明一点——这些数值不是从资源文档里抄出来的固定参数而是我按这个规模的项目最常用的默认值来设置的资源里的main.ipynb会把这些值作为可选项暴露在单元格里方便你直接改。参数常用默认值说明LSTM 层数21 层学不到复杂结构3 层在小数据集上容易过拟合hidden units128~256128 起步看验证 loss 再决定加大embedding 维度64音符组合 ID 先嵌入到稠密向量再进 LSTMsequence_length64相当于看 64 个时间步的上下文batch_size64显存不够就降到 32learning rate0.001Adam 优化器的默认学习率dropout0.3~0.5只加在 LSTM 层的输出上不加在循环连接上训练轮数30~50以验证 loss 停止下降为准不强行跑满dropout 加在哪一层是我踩过坑的地方。LSTM 的 dropout 分两种dropout是输入到隐层的 dropoutrecurrent_dropout是隐层到隐层循环连接的 dropout。在 Keras / TensorFlow 里这两者是分开设置的。如果只设dropout0.5不设recurrent_dropout循环连接上没有任何正则化小数据集上几乎必然过拟合。我一般两个都设dropout0.3recurrent_dropout0.3。注意recurrent_dropout会让训练变慢不少这是正常的不是程序卡住了。4.2 训练过程的观察点损失曲线的形状说明了什么训练时不要只盯着最终 loss更值得看的是 loss 曲线的形状。一个健康的训练过程前几个 epoch 损失会快速下降然后进入缓慢下降的区间。如果 loss 在前 5 个 epoch 就降得非常低、后面几乎不动了大概率是模型记住了训练集中的高频模式验证集损失会开始回升——这就是过拟合信号。处理方法是加大 dropout或者缩小 hidden units 到 128而不是继续加训练轮数。反之如果 loss 一直缓慢下降、始终没有平台期说明模型容量不够或序列长度不够。先尝试把 hidden units 翻倍到 256再把 sequence_length 从 32 提到 64。多数情况下这两个参数比学习率更值得调。model Sequential([ Embedding(vocab_size, 64, mask_zeroTrue), LSTM(128, return_sequencesTrue, dropout0.3, recurrent_dropout0.3), LSTM(128, return_sequencesFalse), Dense(vocab_size, activationsoftmax) ]) model.compile(losssparse_categorical_crossentropy, optimizerAdam(0.001))我一般会顺手在后面加一个ModelCheckpoint按验证损失保存最优权重而不是依赖某个 epoch 结束时的权重。很多时候第 20 个 epoch 的验证 loss 最低但第 40 个 epoch 因为学习率没降又变差了。只保存最后一轮的话白白浪费了最好的模型。4.3 生成阶段逐音符采样才是音乐性的来源训练完成后生成阶段和训练阶段的差异很大容易踩坑。训练时我们会把目标序列直接喂给 LSTM 计算损失生成时每一个时间步都需要把前一步的输出作为当前步的输入这叫「自回归生成」。这也是很多音乐生成项目里「训练时 loss 很好、生成时旋律特别怪」的原因——训练时模型看到的是真实音符生成时看到的是自己上一轮的采样结果误差会逐步累积。缓解办法是训练阶段引入 scheduled sampling以一定概率把模型自己的输出当成下一步的输入但这个资源里不一定要做先把温度采样做好效果提升就足够明显了。def generate_melody(model, seed_sequence, length256, temperature1.0): 从种子序列开始逐音符采样生成新旋律。 参数: model: 训练好的 LSTM 模型 seed_sequence: 长度为 seq_length 的起始音符 ID 序列 length: 要生成多少时间步 temperature: 采样温度越低越保守越高越随机 current_seq seed_sequence[:] generated [] for _ in range(length): x np.array(current_seq)[np.newaxis, :] probs model.predict(x, verbose0)[0] # 用温度系数重新计算概率分布然后按概率采样 logits np.log(probs 1e-8) / temperature exp_logits np.exp(logits) probs exp_logits / np.sum(exp_logits) next_id np.random.choice(len(probs), pprobs) generated.append(next_id) current_seq current_seq[1:] [next_id] return generated这段代码里最关键的是temperature这个参数。temperature 大于 1 时概率分布被拉平低概率音符更容易被选中旋律会更跳跃、更多变但也更容易变得杂乱无章。temperature 小于 1 时分布变得更尖模型会倾向于反复选最高概率的音符旋律稳定但机械。对钢琴曲来说1.0 到 1.2 之间通常能保持「有变化但不失控」。我自己的习惯是先跑几首 T0.8 的看结构再跑 T1.2 的看变化最后挑中间值做正式输出。还有一个生成时的经典失误把model.predict放在循环里直接调用。每次 predict 都有一层 Python 层的前向计算开销生成 256 个音符要调用 256 次慢得让人怀疑代码写错了。常见做法是用 TensorFlow 的model.predict_on_batch替换或者直接用 tf.function 包裹生成函数。不过对几百个音符的生成量来说这个瓶颈不算致命可以先不管。5. 常见问题与避坑数据、训练、生成三个环节的翻车记录5.1 训练损失低但生成的全是单音反复现象训练结束时 loss 曲线很漂亮但生成结果永远是同一个音或者极少数几个音来回循环。原因这类情况通常有两个来源。第一生成时直接取np.argmax(probs)而不是按概率采样导致模型每步都选概率最高的那个音符一旦某个音符概率略占上风它就会被反复选中形成死循环。第二训练数据里音符类别不平衡某个音的样本占比过高模型学会了「无脑输出高频音」这种捷径来压低 loss。解决生成阶段改用概率采样也就是代码里np.random.choice(pprobs)那一行同时检查训练集的音符分布如果发现个别音符占比超过 20%统计时把它过滤掉或对样本做重采样。之后再生成旋律的多样性会有肉眼可见的提升。5.2 生成的旋律四小节一循环像卡带现象生成的曲子不是不好听而是每隔几小节就开始重复。不是主题变奏是完全一样的片段。原因sequence_length 太短模型只学到了「最近若干个音符的局部依赖」。当生成的音符逐渐偏离训练数据时模型会在自己最近的输出中找到一个高频片段然后不断把它复制下去。滑窗步长设为 1 时相邻样本大量重叠模型学到的是「照着上一个时间步的结果平移」而不是真正学旋律演化。解决先检查数据切分的步长把步长改到seq_length // 4以上再把 sequence_length 从 32 提升到 64 或 128。在我的经验里四小节循环这个问题八成是滑窗步长太密集导致的两成是序列长度不够。先动步长再动长度。5.3 MIDI 转序列后音符都成了短音旋律线断了现象原曲里有不少长音但转成序列后长音全变成了一个个孤立的短音符生成的旋律听上去像被剁碎了一样。原因解析 MIDI 时只处理了note_on事件没有完整处理note_off事件来决定音符的结束时间导致一个持续两拍的音只被记录成了开始瞬间的一个点。时间步切分越细这个问题越严重0.25 秒的粒度下一个全音符如果没被正确延续会被记成只有一个时间步的短音。解决解析时维护一个「当前正在发声的音符集合」在每个时间步检查集合是否变化。把note_off事件的时间戳也映射到 tick 上在 tick 到达时从集合里移除对应音符。确认转换逻辑无误后再跑一遍音符长度分布统计看看有没有异常的长音被截断。这一步是最容易返工的地方建议把转换函数单独拆出来做单元验证不要直接在训练 Notebook 里改。5.4 训练到一半 loss 变成 NaN现象前几个 epoch 正常某个 epoch 开始 loss 变成 NaN之后无法恢复。原因最常见的原因是学习率过大导致 LSTM 的梯度在循环连接上累积后爆炸另一个原因是输入序列中出现全零向量embedding 层查表时产生无效梯度。这在 MIDI 解析不完整、某些时间步没有音符时很容易出现。解决先给优化器加梯度裁剪比如Adam(clipnorm1.0)这是最省事的止血方案再把输入序列中全零的行过滤掉确保每个时间步至少有一个音符状态最后确认 embedding 层的输入 ID 不包含负值或超出词表大小的值。这三步做完NaN 基本不会再出现。6. 让生成结果更像钢琴曲温度系数、seed 控制和后处理技巧生成阶段做得再花哨最终要解决的根本问题只有一个怎么让模型输出的状态 ID 序列听起来「像人手弹的钢琴曲」。这里说来也玄学但实际操作中有一套可以反复用的经验能从结构感和细节两个层面提升听感。第一个技巧是控制温度和重复惩罚。生成时不要用固定温度跑完全曲我先用 T0.6 生成开头 32 个时间步让主题稳定落地中间段切到 T1.1让变奏部分有更多自由度最后 16 个时间步再降回 T0.6把旋律拉回稳定区域。这个「三段式」温度和采样思路比一个温度跑到底更能模拟人对乐曲的听感期待。重复惩罚的做法是在计算概率分布时给已经生成过的音符 ID 乘一个折扣系数比如 0.85让模型不至于在短时间内把同一个音重复第三遍。注意折扣系数不能太低否则旋律会变得过度跳跃没有了钢琴曲的连贯感。第二个技巧是控制 seed 序列。种子序列的长度和内容直接影响生成曲子的风格和稳定度。如果 seed 太短模型没有足够的上下文来确定调性中心生成的旋律容易在几个调之间漂移如果 seed 来自训练集里某一首曲子的开头生成结果会明显带有那首曲子的风格影子。我在实际使用时一般从训练集的验证部分随机截取一段 64 个时间步的旋律当作 seed而不是每次都从同一个种子开始。这样每次生成的曲子风格稳定但又不会和训练集里任何一首完全一样。第三个技巧是生成后的 MIDI 后处理。模型输出的状态 ID 序列还原成 MIDI 后通常会有几个听感问题音符重叠导致的和声过于拥挤、旋律线在某个音区长时间停留、结尾没有一个明确的收束感。我的处理习惯是从库里三段式改写第一步把重复音符合并两个相同音高之间间隔少于 3 个时间步的视为同一次发声只保留第一次第二步对整条旋律计算音高范围如果超过三个八度按中位数向下折叠中声部避免音区离散太远第三步在最后 8 个时间步把力度逐渐降低模拟钢琴踏板渐弱的收尾。这套后处理不需要额外训练模型却能明显减少「听三秒就想关掉」的不适感。用这套流程生成十几首曲子之后我养成了一个固定习惯生成之前先跑一遍parse_midi_to_sequence的数据完整性检查打印出时间步数量、音符状态类别数和平均序列长度确认这三个数字都在合理范围然后再用一个很小的模型hidden units64训练 1 个 epoch做一次干跑确定数据管道没问题才开始正式训练。从那以后我几乎很少遇到「练了一晚上、生成的曲子完全不能用」的尴尬收场。这个检查流程只用 5 分钟但省下的重训时间往往是以小时计的。希望这套拆解和排错经验能帮你把这份 LSTM 音乐生成资源一次跑通。本文还有配套的精品资源点击获取