ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于LSTM的音乐生成实战:从MIDI序列到自动作曲系统

基于LSTM的音乐生成实战:从MIDI序列到自动作曲系统 简介一份面向高校课程设计与期末大作业场景的 LSTM 自动音乐生成项目基于机器学习长短期记忆网络提供可直接运行的 Python 源码、项目说明与详细设计说明书。整个资源压缩包只有 462KB共 6 个文件其中两个 Jupyter Notebook 分别实现主程序和 UI 交互界面一个 Python 脚本用于核心音乐生成逻辑PDF 详细设计说明书可作为课程设计报告或答辩参考README 方便快速上手License 明确了使用授权。已有 296 人浏览学习。该项目曾获得导师指导并被评为 97 分属于高分期末大作业强调完整性与可运行性下载后即可使用无需额外修改。借助这套资源学习者可以了解 LSTM 在音乐序列生成中的建模过程包括数据准备、模型训练、生成旋律与结果展示的完整思路详细设计说明书进一步补充了设计背景、功能模块和实现步骤适合作为机器学习、深度学习相关课程设计的模板。1. 基于机器学习LSTM的自动音乐生成课程设计它到底在做什么“基于机器学习LSTM的自动音乐生成”是课程设计里出现频率很高的一道题拿到一批 MIDI 格式的钢琴片段用 LSTM 神经网络学会旋律的时序规律再逐音符往外生成新的曲子。很多人第一反应是“要教电脑作曲”落地了才发现它本质上是序列生成问题——和用 LSTM 做时间序列预测、设备寿命预测是同一套思路只不过输入从连续数值变成了离散的音符 ID。这个题目做完你能同时得到可运行的 python 源码和一份能交差的详细设计说明书对新手非常友好因为数据量要求低、模型结构简单、结果还能直接听。适合正在做课程设计的学生也适合想快速入门序列生成的人。整套方案的核心就一句话把音乐拆成序列用 LSTM 学分布再采样生成。2. 把 MIDI 变成 LSTM 训练样本解析、切窗与三份关键数据2.1 为什么选 MIDI 而不是音频离散事件序列才是 LSTM 的主场音乐生成有两条技术路线一条在音频域直接生成波形另一条在符号域生成音符序列。课程设计不要碰音频域。波形文件一秒钟几万个采样点LSTM 直接建模等于让网络从极高维的原始信号里学特征想训到能听要么需要大算力要么需要大规模数据集都不是课程设计能承受的成本。MIDI 不一样它记录的是一串离散事件哪个音符在什么时候按下、什么时候松开、力度多大。这个表示天然就是序列和 LSTM 的输入结构完全匹配。我一般会这样对比维度音频 WAVMIDI数据形式连续波形采样离散音符事件序列长度每秒数万采样点每秒几个到几十个音符建模难度高需额外做频谱特征工程低直接喂 ID 即可需要的数据量大几十首钢琴曲就够听感可控性难音高、时长都可解析课程设计推荐度不推荐推荐选 MIDI 还有一层现实原因它能很方便地回写和试听。训练过程中生成的中间结果可以随时导出成 MIDI 用播放器打开甚至丢到 DAW 里配和弦这对调试非常有用。反过来音频生成出一个半成品想定位是哪一步出了问题全靠耳朵硬猜。2.2 解析 MIDI一个能直接用的 python 预处理脚本常见做法是用 mido 这个库它轻量、只做一件事把 MIDI 文件解析成消息流。安装就一条命令pip install mido。不要一上来用 music21虽然它更“音乐学”但对象模型重课程设计只需要音符、时长、时间戳mido 足够了。import mido def midi_to_notes(midi_path, track_index0): 读取 MIDI 文件提取指定轨道的音符事件。 返回按开始时间排序的列表每个元素是 (note, duration, start_time)。 时间单位统一用 tick避免不同 MIDI 的精度不一致。 mid mido.MidiFile(midi_path) track mid.tracks[track_index] notes [] current_time 0 active_notes {} for msg in track: current_time msg.time if msg.type note_on and msg.velocity 0: active_notes[msg.note] current_time elif msg.type note_off or (msg.type note_on and msg.velocity 0): if msg.note in active_notes: start active_notes.pop(msg.note) duration current_time - start notes.append((msg.note, duration, start)) return sorted(notes, keylambda x: x[2])这段代码的逻辑是维护一个 active_notes 字典遇到 note_on 就把音符编号和绝对时间存进去遇到对应的 note_off 就弹出来凑成一个音符事件。需要特别注意的是 msg.time 是 delta time也就是相对于上一条消息的间隔所以要用 current_time 累加成绝对时间。另一个坑是很多 MIDI 用 velocity0 的 note_on 表示音符结束而不是真的发 note_off所以判断条件里要把这种情况一并处理成“结束事件”。轨道怎么选有的 MIDI 文件第一条轨道是 tempo 轨全是 set_tempo 和 time_signature 元消息没有音符。更稳妥的做法是遍历所有轨道取 note_on 消息最多的一条作为旋律轨。多轨 MIDI 里还经常混有鼓轨鼓轨的音符编号集中在 35 到 81 这一段和旋律轨合并会让模型学到一堆无规律的打击乐训练时建议直接过滤掉。2.3 切窗生成训练对seq_len、step 与时长量化构建 vocab 之前先统计所有歌曲的音符分布。课程设计数据集一般几十首钢琴曲音符编号集中在 40 到 84对应钢琴中央区附近但也会有极端低音或极少出现的高音。这些稀有音符如果直接进 vocab模型会把概率分配给它们浪费容量。常见处理是只保留出现次数大于某个阈值的音符比如过滤掉出现少于 5 次的音符剩下的固定成 vocab没见过的音符映射成一个 UNK 占位符。vocab_size 通常在 60 到 120 之间音乐生成不像文本那样动辄几万词表。LSTM 训练不是把整首曲子喂进去而是像时间序列预测一样滑窗。常见的做法是固定一个窗口长度 seq_len32 或 64每 step 个音符滑动一次窗口内的音符 ID 作为输入错位一位作为输出。这种组织方式叫 teacher forcing模型每一步的预测目标来自真实序列的下一个音符而不是自己上一步的输出。import numpy as np def make_sequences(notes, note_to_idx, dur_bins, seq_len32, step4): notes: midi_to_notes 的输出 note_to_idx: 音符编号到索引的映射 dur_bins: 时长量化桶 note_ids [note_to_idx[n] for n, _, _ in notes] dur_ids [np.digitize(d, dur_bins) for _, d, _ in notes] X_n, X_d, y_n, y_d [], [], [], [] for i in range(0, len(note_ids) - seq_len - 1, step): X_n.append(note_ids[i:i seq_len]) X_d.append(dur_ids[i:i seq_len]) y_n.append(note_ids[i 1:i seq_len 1]) y_d.append(dur_ids[i 1:i seq_len 1]) return (np.array(X_n), np.array(X_d)), (np.array(y_n), np.array(y_d))代码里两个输出 y_n 和 y_d 是 X 整体向右平移一位的结果这样每个输入窗口都对应“下一个音符是什么、下一个音符有多长”两个监督信号。seq_len 决定模型能看到的上下文长度太小听感会碎太大训练会慢而且显存压力大step 决定相邻样本的重叠程度step1 时相邻窗口只差一个音符样本几乎重复模型很快过拟合step 取 4 到 8 比较平衡。时长为什么要离散化成桶而不是直接用原始数值因为模型输出层如果用回归去预测一个浮点时长MSE 损失会把所有预测往均值上压最后生成出来的音符时长全是中间值没有节奏变化。把时长分成 16 到 32 个桶当作分类问题去做每个桶代表一个可解释的时值比如十六分音符、八分、四分、二分生成结果更符合真实音乐的离散节奏感。提示切窗之前一定要按曲目划分训练集和验证集。先切窗再随机划分会把同一首歌的碎片分到两边val_loss 看着很漂亮但模型换一首歌就废这是最容易骗到自己的一个坑。数据量不够时性价比最高的数据扩充是转调把每首歌整体上下移调 2 到 4 个半音数据集直接翻倍vocab 完全不用变。注意移调后音符范围不能超出 MIDI 的 0 到 127超出的直接丢弃。3. 搭建 LSTM 生成模型双输入双输出网络结构与训练参数3.1 为什么是 LSTM门控机制如何记住旋律动机音乐和文本一样是强序列依赖数据。一个动机比如前四个音符可能在十几小节后以变化的形式重现模型需要某种“记忆”才能让生成结果有结构感。基础 RNN 理论上能记住但训练时梯度随时间步连乘很容易指数级衰减或爆炸长距离信息根本传不回来这就是常说的梯度消失问题。LSTM 的解决思路是加一条细胞状态cell state的传送带再配上遗忘门、输入门、输出门三个门控让网络自己决定哪些信息要写进去、哪些要忘掉、哪些要输出。门控机制让梯度在细胞状态上有一条相对通畅的高速路长依赖才学得动。这也是为什么课程设计几乎都用 LSTM 而不是朴素 RNN。GRU 是 LSTM 的简化版只有两个门效果在很多任务上和 LSTM 接近参数量更少如果追求训练速度可以换 GRU但写课程设计说明时 LSTM 的门控机制更好讲答辩时也更有话说。3.2 模型结构Embedding 双层 LSTM 两个输出头模型结构我一般这样搭输入是音符 ID 序列和时长 ID 序列音符经过 Embedding 层映射成向量时长因为类别少用一个小 Embedding两个向量拼起来进双层 LSTM最后一层输出两个 softmax一个预测下一个音符一个预测下一个时长。双输出头的好处是音符和时长共享特征提取层但又各自保留独立的预测头不会互相干扰。from tensorflow.keras.layers import Input, Embedding, LSTM, Dense, Dropout, Concatenate from tensorflow.keras.models import Model def build_model(vocab_size, dur_vocab_size, seq_len32, embed_dim128, lstm_units256): note_in Input(shape(seq_len,), namenote_in) dur_in Input(shape(seq_len,), namedur_in) note_emb Embedding(vocab_size, embed_dim)(note_in) dur_emb Embedding(dur_vocab_size, 8)(dur_in) merged Concatenate()([note_emb, dur_emb]) x LSTM(lstm_units, return_sequencesTrue, dropout0.3, recurrent_dropout0.2)(merged) x LSTM(lstm_units // 2, return_sequencesFalse, dropout0.3)(x) x Dropout(0.3)(x) note_out Dense(vocab_size, activationsoftmax, namenote_out)(x) dur_out Dense(dur_vocab_size, activationsoftmax, namedur_out)(x) model Model(inputs[note_in, dur_in], outputs[note_out, dur_out]) model.compile( optimizeradam, loss{note_out: sparse_categorical_crossentropy, dur_out: sparse_categorical_crossentropy}, metrics{note_out: accuracy, dur_out: accuracy} ) return model第一层 LSTM 设置 return_sequencesTrue因为第二层 LSTM 需要拿到每个时间步的隐藏状态而不是只收最后一个输出。第二层 return_sequencesFalse 只输出最后一个时间步再接 Dense 层做分类。embed_dim128 对只有几十个音符的 vocab 来说已经足够再大收益不大训练还慢。损失用 sparse_categorical_crossentropy因为它直接接受整数标签省去 one-hot 展开。两个输出头共用一个 Adam 优化器keras 会自动把两个 loss 相加做反向传播。如果希望时长任务占比小一点也可以在 compile 里传 loss_weights{note_out: 1.0, dur_out: 0.5}把音符的优先级拉高。有人会把音符和时长编码成复合 token比如 (60, 0.5) 合成一个 token。这样 vocab 会剧烈膨胀而且把音高和节奏耦合起来模型学到的是“每个音高配什么时长”的联合分布灵活性很差。双输入双输出让两个特征各自独立训练更快生成的时候还能分别控制音符和时长的采样温度这是整个结构里我认为最值得保留的设计。3.3 训练参数设定batch size、学习率与早停训练参数这部分在课程设计里往往是玄学但有几个保底经验可以照抄参数推荐值说明sequence_length32 或 64越大上下文越长显存压力也越大embed_dim128词向量维度lstm_units256 / 128第一层 256第二层 128batch_size64CPU 降到 16显存不够先降这个learning_rate0.001Adam 默认值loss 不降再降到 0.0005dropout0.3数据集小不加必过拟合epochs100配合早停实际 30 到 50 轮基本收敛from tensorflow import keras model.fit( [X_n_train, X_d_train], # 音符输入、时长输入 [y_n_train, y_d_train], # 音符输出、时长输出 batch_size64, epochs100, validation_split0.1, callbacks[ keras.callbacks.EarlyStopping( monitorval_loss, patience10, restore_best_weightsTrue ) ] )validation_split0.1 会在训练集内部随机切 10% 做验证但要注意它发生在切窗之后如果你的切窗脚本没有按曲目划分这一步的 val_loss 也是不可信的。早停的 patience10 表示连续 10 轮验证损失不下降就停restore_best_weightsTrue 保证停的时候把验证损失最低那轮的权重回滚回来而不是用最后一轮的。课程设计训练不需要 GPU。几十首钢琴曲切出来的样本量在几万到几十万条CPU 上跑几十轮也就几十分钟正好可以在等待时间里把项目说明书写掉。如果一定想用 GPU注意 LSTM 里的 recurrent_dropout 在 cuDNN 加速时不受支持kernel 会报错或者默默降级不想处理就直接去掉 recurrent_dropout只在 LSTM 输出后加 Dropout。4. 从概率到旋律采样策略与 MIDI 回写4.1 为什么不能直接 argmax复读机式生成是怎么来的训练结束后模型输出的是词表上的概率分布下一步要决定到底选哪个音符。新手最容易上来就 np.argmax结果生成出来的音乐永远是那么几个音符的无限循环这就是生成模型的经典翻车现场。原因不复杂argmax 每次都选概率最高的类别而概率最高的类别很快会陷入一个局部循环模型一旦进入这个循环每一步都自增强再也走不出来。另一个副作用是音乐里很多好听的变化恰恰来自低概率的“意外”选择argmax 把这些可能性全部抹掉了。4.2 温度采样与 top-k生成效果最见效的两个参数解决的办法是采样而不是取最大值。温度参数 T 先对 logits 做缩放T 越大分布越平、越随机T 越小分布越尖、越保守。top-k 再把候选限制在概率最高的 k 个类别里防止小概率长尾音符突然蹦出来。这两个参数是调生成听感最见效的旋钮。import numpy as np def temperature_sample(logits, temperature0.9, top_k8): logits: 模型输出层之前的线性值形状 (vocab_size,) temperature: 小于 1 更保守大于 1 更随机 top_k: 只保留概率最高的 k 个候选 logits logits / temperature if top_k is not None: k min(top_k, len(logits)) indices np.argsort(logits)[-k:] mask np.full_like(logits, -np.inf) mask[indices] logits[indices] logits mask exp_logits np.exp(logits - np.max(logits)) # 减去最大值防止溢出 probs exp_logits / np.sum(exp_logits) return np.random.choice(len(probs), pprobs)温度范围与听感大致是这样温度听感0.5 到 0.7保守贴近训练集风格容易重复0.8 到 1.0推荐区间有变化但不乱1.2 以上随机性过强连续听感差再写生成循环也就是把网络输出变成一条完整音符序列的部分def generate(model, seed_note_ids, seed_dur_ids, length200, temperature0.9, top_k8, seq_len32): note_seq list(seed_note_ids) dur_seq list(seed_dur_ids) for _ in range(length): x_n np.array(note_seq[-seq_len:]).reshape(1, seq_len) x_d np.array(dur_seq[-seq_len:]).reshape(1, seq_len) p_note, p_dur model.predict([x_n, x_d], verbose0) next_note temperature_sample(np.log(p_note[0] 1e-8), temperature, top_k) next_dur temperature_sample(np.log(p_dur[0] 1e-8), temperature, top_k) note_seq.append(next_note) dur_seq.append(next_dur) return note_seq, dur_seq生成的本质是自回归把最后 seq_len 个已生成的音符作为输入预测下一个再把新音符拼到序列尾部循环往复。length 是生成的总音符数200 个音符大约对应几十秒音乐。温度推荐从 0.9 起步出来的旋律会比较“飘”再往 0.7 调会更稳top_k 在 8 到 15 之间k 太大容易冒出怪音太小会单调。进阶做法是用 top-pnucleus sampling按累积概率动态截断候选集比固定 top_k 更平滑但课程设计用 top_k 就够答辩了。4.3 回写 MIDI 并试听从数字序列变回音乐生成出来的是音符 ID 和时长桶 ID要用 mido 把它们写回 MIDI 文件。def notes_to_midi(note_ids, dur_ids, idx_to_note, dur_bins, out_pathgenerated.mid, bpm100, velocity70): mid mido.MidiFile() track mido.MidiTrack() mid.tracks.append(track) ticks_per_beat mid.ticks_per_beat ticks_per_second ticks_per_beat * bpm / 60.0 last_end_ticks 0 for note_idx, dur_idx in zip(note_ids, dur_ids): note idx_to_note[note_idx] dur_seconds dur_bins[dur_idx] dur_ticks max(1, int(dur_seconds * ticks_per_second)) track.append(mido.Message(note_on, notenote, velocityvelocity, timelast_end_ticks)) track.append(mido.Message(note_off, notenote, velocityvelocity, timedur_ticks)) last_end_ticks 0 # 下一个音紧跟前一个结束 mid.save(out_path)回写时最常出问题的就是 time 字段它是相对上一条消息的 tick 数不是绝对时间。上面的写法把上一条 note_off 的结束位置作为当前 note_on 的起点所以 note_on 的 time 传的是上一个音符的 duration_ticksnote_off 的 time 是自己的 duration_ticks。如果嫌连音太挤可以在 note_on 的 time 里加一个固定的间隔 tick比如空半拍。试听直接双击生成的 .mid 文件Windows 用自带播放器macOS 用 QuickTime或者拖进 DAW 里挂一个钢琴音色。更省事的做法是装一个 timidity 或 fluidsynth 把 MIDI 转成 WAV放手机里路上听这个习惯我保持了很久比盯着 loss 曲线有效得多。5. 课程设计避坑清单五个常见问题与排查方法5.1 loss 不降或剧烈震荡现象训练一开始 loss 就在 3 到 5 之间来回跳几十轮过去纹丝不动或者 loss 降了十几轮之后突然开始剧烈震荡验证集也跟着抽风。原因最常见的是学习率太大Adam 的默认 0.001 对音乐生成这类小数据集偶尔也偏激进其次是时长没有量化归一化直接拿原始 tick 数当回归目标数值跨度从几十到几万梯度被大数值样本带跑再有一种是数据里有大量休止符或空轨模型学到的全是“什么都不弹”的模式loss 看起来很高但死活降不下去。解决先把学习率降到 0.0005 重新跑确认训练数据里没有空轨和长休止符段把 loss 曲线单独画出来看是持续不降还是降了又升后者通常要降学习率或增大 dropout。顺手检查一下 note 的 vocab 是否把出现次数过少的稀有音符也放进来了稀有类别会持续带来噪声梯度。5.2 生成结果全是同一个音或整段静音现象生成的序列翻来覆去只有一两个音符或者连续的“休止符”导致听起来基本是静音。原因一个是训练数据分布极度不均衡某个低音或休止符占了大头模型学到的经验分布就是“一直弹这个音”另一个是采样时 top_k 选得太小候选里只有概率最高的几个音一旦陷入局部循环就出不来。解决先在数据统计阶段把出现频次极低的音符过滤掉同时看看是不是把鼓轨混进来了鼓轨的打击音符会让模型输出一堆杂音再把 top_k 适当调大比如从 8 提到 15同时把温度提高到 1.0给模型一点跳出循环的随机性。如果整段静音检查是不是在生成时把休止符也当作普通音符塞给了模型休止符在 MIDI 里不是音符事件正确做法是单独用一个 REST 占位符参与训练而不是让模型去“生成”一个持续时间很长的音符。5.3 节奏死板所有音符时长几乎一样现象音高变化听着还行但节奏像机器人所有音符时长都卡在同一个桶上完全没有切分和休止。原因时长桶数太少。如果只分了 4 个桶模型稍微一偷懒就会把大部分概率压到最常见的那一桶上另外训练时如果每首歌都统一量化为“四分音符网格”时间的多样性从一开始就被抹掉了。解决把时长桶数加到 16 到 32并且用非均匀间隔按实际音乐里常见的时值分布来设定边界别用等距切分。还有一个后处理层面的后悔药生成之后对每个时长的桶索引做一点随机抖动比如有 20% 概率把相邻桶之间取平均能明显改善机械感。如果模型对时长的预测确实很差可以退一步训练时固定一个基础时值网格只在音高上做生成课程设计也能过但答辩就会单薄一些。5.4 详细设计说明书和代码对不上现象文档写到“详细设计”一节就开始挤牙膏粘贴的需求分析、代码截图和实际脚本逻辑对不上答辩证说代码是自己写的但文档里讲的数据流在代码里根本不存在。原因很多同学的习惯是先把代码调到能跑再回头补文档结果代码已经改了好几版文档只记了第一版。课程设计说明书本质上是一份系统设计文档它的主线应该是模块划分和数据流而不是代码粘贴。解决动手写代码之前先用半小时把模块清单列出来就三份脚本preprocess.py 负责 MIDI 解析和切窗train.py 负责模型定义和训练generate.py 负责采样和回写 MIDI。每个脚本写清楚输入、输出、依赖的模块再在文档里画一张文字版数据流MIDI 文件 - 音符事件 - 滑窗样本 - LSTM 训练 - 采样 - 生成 MIDI。这张图出来需求分析、概要设计、详细设计三节都能顺着写。测试部分就把 loss 曲线和生成 MIDI 的试听片段作为验收证据补进去文档和代码自然就对上了。5.5 环境问题GPU 报错与显存溢出现象安装 tensorflow 之后跑训练直接报 cuDNN 相关错误或者 batch_size64 一执行就 OOM 退出。还有人说“CPU 跑得太慢”。原因TensorFlow 2.x 对 CUDA 和 cuDNN 版本极其敏感版本对不上连 import 都过不去显存溢出则是 batch_size 和 sequence_length 太大单卡 8G 根本吃不下 64 个 64 长度的 LSTM 序列。解决课程设计完全可以用 CPU 跑几十首 MIDI 的数据量在 CPU 上几十轮也就是几十分钟别折腾 CUDA。跑通之后再考虑 GPU。显存不够就先降 batch_size 到 16再不行把 seq_len 降到 32。recurrent_dropout 如果和 cuDNN 冲突直接删掉换成 LSTM 输出之后的 Dropout 层效果几乎一样。还有一个容易被忽略的把 model.predict 的 verbose0整个训练过程中控制台日志也调成静默能节省不少内存开销。6. 验证与进阶用 loss 曲线和耳朵判断模型有没有真学会训练结束不等于项目交付。课程设计真正难回答的问题就一个怎么证明模型学会了我习惯分三层验证。第一层看 loss 曲线训练 loss 持续下降、验证 loss 不高过训练 loss 太多说明没有严重过拟合如果训练 loss 很低而验证 loss 高就是背样本了。第二层做生成集测试从全部 MIDI 里留出两首不参与训练只看这两首在模型上的验证损失能反映模型对没见过的曲风的适应能力。第三层是主观听感把生成的 MIDI 转成 WAV 放进手机里走路、骑车的时候反复听重点问三个问题旋律有没有重复到让人厌倦、相邻音符之间有没有连贯的走向、节奏有没有变化。这三个问题比任何指标都诚实。再高阶一点可以做一个“复现测试”把训练集里某首曲子截掉后半段让模型续写对比续写部分和原曲后半段的相似程度。如果模型输出的片段和原曲几乎一字不差说明模型在背谱而不是学规律这时候把 dropout 调大、减少训练轮数、或者增大温度重新采样。进阶方向常见的有两个一是从单轨旋律生成扩展到双轨解析 MIDI 时同时保留旋律轨和伴奏轨两条序列错位对齐喂给模型二是把时长预测从分类改成混合密度输出让模型直接预测一个时长的概率分布。这些改动不大但对课程设计的深度提升很明显。我现在的习惯是每训完一版就把生成结果导出来听一遍再顺手把 loss 曲线存成截图攒到最后放进说明书里当测试证据既是对自己工作的记录也是答辩时最硬的素材。希望帮到你。本文还有配套的精品资源点击获取
返回列表