1. 项目概述为什么做音乐大模型要先想清楚“序列”这件事先说结论基于 Transformer 的音乐生成模型本质上是把“作曲”这件事转成了“预测下一个音符”的语言建模任务。这个思路听起来简单但真正动手做的时候你会发现音乐和文本有个根本性的差异——文本是线性的一维符号流而音乐天然是多声部的、有和声走向的、存在严格时间对齐关系的立体结构。怎么把这种立体结构编码成 Transformer 能消化的序列直接决定了模型最后是能写出像样的乐句还是只会产出杂乱无章的音符堆砌。这个项目适合三类人。第一类是刚接触生成式 AI、想做点语言模型之外尝试的研究者音乐生成是检验序列建模能力的绝佳试验场第二类是有 MIDI 数据处理经验、想用深度学习方法提升编曲效率的音乐技术爱好者第三类是想把 Transformer 真正落地到非文本领域的工程开发者音乐领域的 token 设计、条件控制、解码策略都跟 NLP 有微妙差异这些差异恰恰是最有价值的经验。我自己做这个项目的动机很简单图像和文本的生成模型已经卷得差不多了但音乐生成领域的数据清洗、表征设计、评估方式都还处在“手工作坊”阶段很多经验散落在论文和开源代码里缺少一份从数据处理讲到模型训练、再到推理解码的完整串联。所以这篇文章不是论文复现而是把我从零搭建一个音乐 Transformer 模型的完整路径、踩过的坑、以及最后沉淀下来的经验原原本本写出来。2. 核心设计拆解音乐跟文本的本质差异决定建模方式2.1 音乐的“双轨性”时间维度和垂直维度的同时展开自然语言是严格线性的一句话从左到右读每个 token 只有一个“下一个”。但音乐的阅读方式有两个方向横向是时间轴旋律的先后顺序 纵向是和声轴同一时刻多个声部叠在一起 。如果直接把多轨 MIDI 合并成一个序列喂给模型你会发现模型很难学到“和弦内音之间的垂直关系”因为它在序列里被拆成了很远的距离。这也是第一版模型失败得最惨的地方。我当时把 MIDI 里同一时刻的多个音符按音高排序后压扁成一个序列再丢给一个标准的 Decoder-only Transformer 训练。结果生成的音乐几乎全部是单旋律线偶尔出现两个音同时响也是一堆刺耳的小二度碰撞。原因很简单同一时刻互相有和声关系的音符在序列里相隔了十几个 token自注意力虽然理论上能捕捉长距离依赖但训练数据量和模型容量不够时这种“跨声部依赖”根本学不动。所以后来我调整了 token 设计思路把“同一时刻的一组音符”打包成一个事件块用特殊分隔符标记块边界让 Transformer 在一个块内先学习垂直关系再跨块学习时间连接。这一步改动让生成结果的和声 richness 立刻上了一个台阶。2.2 为什么选 Transformer 而不是 RNN/LSTM如果只是生成单轨旋律RNN 系列也能做而且 GRU 之类的小模型在资源受限条件下表现不差。但音乐生成有几个硬性需求第一音乐存在长程结构。一段 32 小节的钢琴曲开头主题可能在 16 小节后以变化形式再现这要求模型能记住很远之前的上下文。LSTM 的理论记忆长度有限实际训练中超过几百步的依赖基本会衰减Transformer 的自注意力是显式建模任意两两位置的关系只要位置编码合理、数据足够长程依赖的建模能力上限高得多。第二训练可以并行。音乐序列动辄上千 tokenRNN 的串行计算让训练效率非常低。我自己在单卡上跑实验同样规模的数据量Transformer 的训练速度大约是 LSTM 的 3 到 4 倍。做研究的时候要反复调参这个速度优势极其宝贵。第三条件控制更自然。无论是给模型一个起始旋律让它续写还是指定调性、风格、乐器Transformer 都可以把这些条件拼进序列前缀或者用特殊的控制 token 实现。RNN 做这件事就要设计复杂的门控机制麻烦得多。2.3 模型选型Encoder-only 还是 Decoder-only音乐生成这个任务我强烈推荐 Decoder-only 的自回归架构也就是跟 GPT 系列一致的范式。原因在于作曲本质上是一个条件概率分布采样问题给定前面的音乐上下文采样下一个音符事件。Decoder-only 用掩码注意力天然支持这种从左到右的生成流程。Encoder-only 的模型比如 BERT 风格适合做理解类任务比如给一段音乐打标签、识别和弦进行。它需要遮住一部分 token 然后预测被遮住的内容训练效率和生成时的自回归采样逻辑对不上生成时还要额外设计解码器等于把一个模型拆成两个用。还有一个折中方案是 Encoder-Decoder 架构适合“输入一段旋律、输出配器版本”这种序列到序列的改写任务。但如果你只是想做一个从零开始的作曲模型Decoder-only 是最直接、最少弯路的路线。3. 数据工程的隐藏战场MIDI 清洗与 token 化策略3.1 数据来源和基础过滤规则训练音乐大模型数据质量比模型结构重要十倍。我用的是公开的 MIDI 数据集和自制爬虫收集的 MIDI 文件混合训练。原始 MIDI 文件的质量落差极大有些是专业编曲作品有些是自动扒谱生成的噪音。清洗时我定了几个硬性过滤规则时长过滤文件长度低于 15 秒的丢弃这类文件大多是片段或无效数据。轨道数过滤只保留 2 到 8 个轨道的文件。多于 8 轨的往往是 MIDI 打包文件或者包含大量打击乐分层训练时容易让模型学到杂乱无章的打击乐模式。音符密度过滤一秒钟内平均音符数低于 0.5 的大概率是稀疏的试弹文件高于 20 的大概率是机械生成的音符堆叠。音高范围过滤偏离标准钢琴音域太多的文件通常是测试程序生成的异常数据。这套规则跑下来大概能过滤掉 40% 左右的原始数据。别心疼这些被删掉的文件训练数据里混入垃圾模型学会的就是垃圾模式。3.2 速度标准化与时值量化MIDI 文件里的速度信息千奇百怪有人用 120 BPM有人用 90 BPM还有人全程变速。Transformer 只认相对的序列模式如果训练数据里速度分布混乱模型学到的时间比例关系就会扭曲。所以我在预处理阶段把所有文件统一到 120 BPM再按 16 分音符的粒度做时值量化。时值量化的精度是个关键权衡。量化为 8 分音符序列长度大幅缩短训练压力小但生成结果会很机械化几乎所有音符都在正拍上量化为 32 分音符序列长度爆炸训练成本成倍上升模型容易学到碎片化的装饰音反而影响音乐性。我最终选的是 16 分音符为主辅以三连音和附点音符的独立 token。实测下来这个粒度在音乐表现力和序列长度之间取得了最好的平衡。3.3 Token 类型设计不只是“音符编号”文本模型的 token 是词汇表里的词音乐模型的 token 要复杂得多。我把每一类事件都定义为一种独立的 token 类型而不是简单地把 MIDI 音符编号映射成一个整数。事件类型包括Note On哪个音高开始发声、Note Off哪个音高结束、Velocity力度值量化成 8 到 12 档、Time Shift时间偏移用 16 分音符的倍数表示间隔。另外还有全局控制 token比如调性标记、拍号标记、轨道类型标记这些 token 拼在序列开头或者小节边界处给模型提供稳定的全局条件。有人会问为什么不直接用 MIDI 的原始音符编号加 duration 属性因为 Transformer 对序列位置极度敏感把 duration 作为显式属性反而模糊了事件边界。事件序列化的方式让每一个时间步都有一个明确的语义角色模型学习时更清晰。这里贴一段 token 化伪代码展示核心逻辑def midi_to_tokens(midi_file, quantize_step0.25): notes extract_notes(midi_file) # 提取音符列表 notes.sort(keylambda x: (x.start, x.pitch)) # 按起始时间排序 tokens [] current_time 0.0 for note in notes: start_tick quantize(note.start, quantize_step) time_gap start_tick - current_time if time_gap 0: tokens.append(fTIME_SHIFT_{time_gap}) current_time start_tick tokens.append(fNOTE_ON_{note.pitch}) tokens.append(fVELOCITY_{quantize_velocity(note.velocity)}) tokens.append(fNOTE_OFF_{note.pitch}) return tokens当然真实项目里还要处理 Note Off 和 Note On 的组合问题——是让每个音符的 On 和 Off 成对出现还是用“在同一时间步统一关闭”的策略。后者生成的序列更短但会丢失音符的精确时值信息我用的是前者代价是序列长度大约多出 20%但生成结果的可控性强很多。4. 模型架构与训练策略从零搭建到调参实战4.1 标准 Decoder-only 架构的配置选择模型骨架采用标准的 Decoder-only Transformer包含掩码自注意力层和前馈网络。具体配置我实验了多组最终用的是 8 层 Transformer 块、8 个注意力头、嵌入维度 512、前馈网络维度 2048。这个参数量大约 60M 左右比动辄上亿的大语言模型轻量得多但在单张消费级显卡上已经能训练出有音乐感的模型。为什么不用更大的模型我试过 12 层、嵌入维度 768 的版本loss 确实降得更低但生成结果的多样性和音乐性反而变差了。这个现象值得注意音乐生成和文本生成有个区别文本的正确性是硬性的但音乐的好坏是主观的。过大的模型倾向于输出“平均化”的音乐什么都是中等水平失去了风格和个性。反而是中等规模的模型加上合适的采样策略能产出更有惊喜感的旋律。4.2 位置编码的选择绝对位置编码依然够用Transformer 本身不感知序列顺序必须靠位置编码。业界两种主流方案经典的 Sinusoidal 绝对位置编码和 RoPE旋转位置编码。我两个都试了在音乐生成这个任务上经典 Sinusoidal 的稳定性和 RoPE 差不多最终选择了经典方案。原因有二。第一音乐序列的长度分布比较稳定很少出现超长序列绝对位置编码在 4096 以内的序列长度上没有劣势。第二Sinusoidal 的实现极其简单没有额外的参数需要调调试成本低。如果你的音乐数据里包含长达数万 token 的完整交响乐那 RoPE 的外推优势会体现出来但对于绝大多数流行、电子、钢琴曲目经典方案完全够用。4.3 训练损失函数与采样策略的平衡训练时用的是标准的交叉熵损失关注所有 token 类型的预测准确率。但我加了两个特殊处理第一对 Time Shift 和 Note Off 这两个 token 类型做轻微的类别权重调整。如果不调整模型会倾向于频繁输出这两个 token 来“混日子”——一直生成短音符和频繁停顿因为这样最容易骗过交叉熵损失。我通过权重衰减鼓励模型更关注音高选择生成的旋律线立刻就流畅了。第二推理时使用温度采样加 Top-K 过滤。温度参数控制概率分布的锐化程度温度小于 1 时采样集中在高概率 token音乐更保守稳定温度大于 1 时冒险性增强可能出现惊喜但也会出乱子。我对旋律 token 用 1.0 到 1.2 的温度对 Time Shift token 用更低的温度保证节奏相对稳定。下面是一个简化的采样函数def sample_token(logits, temperature1.0, top_k50): logits logits / temperature filtered_logits top_k_filter(logits, top_k) probs softmax(filtered_logits) return random_choice(len(probs), pprobs)4.4 训练过程中的监控指标除了标准的训练 loss我强烈建议每个 epoch 跑一次验证集生成。怎么听生成 8 到 16 个小节的音乐片段保存为 MIDI 文件直接拖进 DAW 里听。Loss 下降了但生成结果在退化这个现象经常发生原因是模型开始学会“复读机”模式——重复生成完全相同的小节因为这类序列的交叉熵本来就很低。我在训练过程中发现 loss 从 1.8 降到 1.2 的时候生成效果最好再往下训练到 loss 1.0 左右时音乐变得越来越“平”几乎每个乐句都能听出前一句的影子。这说明模型容量不够开始过拟合训练集中的高频模式。解决办法是提早停止训练用 loss 在 1.2 左右的 checkpoints。5. 实操过程数据处理到 MIDI 输出的全流程实现5.1 环境搭建与依赖选择整个开发环境我用的是 Python 3.10 PyTorch 2.x。MIDI 处理库选的是 mido 作为底层 IO 工具这个库足够轻量就是速度慢一点处理大规模数据时要配合 multiprocessing。如果你想快速验证数据处理逻辑也可以用 music21它封装了更多乐理相关的功能比如直接识别和弦、调性但缺点是依赖重、运行慢我的项目里只在调试阶段用 music21 做可视化验证批量处理阶段全部切换到 mido。训练损失曲线标注上我加了一个简单的早停机制校验集 loss 连续 3 个 epoch 不下降就触发早停恢复最优模型参数。事实证明这个机制在数据量不大的时候特别重要。5.2 MIDI 解析与 token 序列生成流程整个数据预处理流程分四步第一步批量解析 MIDI 文件。遍历文件夹下的所有 .mid 文件用 mido 读取提取每条轨道的事件列表。这个阶段需要记录轨道名因为轨道名往往包含了乐器信息比如 Piano、Bass、Drums后续可以作为条件 token 使用。第二步轨道合并。多轨 MIDI 中经常出现一个乐器的音符被分散到多个轨道的情况需要按乐器类别合并。我将合并后的声部分为 melody主旋律、harmony和声、bass、drums 四大类每一类给一个独立的轨道前缀 token。第三步量化与时间标准化。先把所有音符的时间信息转成 tickMIDI 的最小时间单位然后按设定的量化粒度做取整。这里有个容易踩的坑直接按浮点数时间取整会导致节奏漂移因为 MIDI 文件内部的 tick 分辨率各不相同。正确的做法是先把所有时间转成以四分之一音符为基准的相对值再做量化。第四步事件序列化。按照前面说的 token 类型设计把一个 MIDI 文件转换成 token 数组加上全局 token 前缀保存成训练样本。处理之后的 token 序列长度大约在 800 到 2000 之间刚好适合 2048 的上下文窗口不需要额外的截断和拼接策略。5.3 模型训练代码核心逻辑训练代码的核心部分我直接贴出简化版本供参考class MusicTransformer(nn.Module): def __init__(self, vocab_size, d_model512, n_layers8, n_heads8): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos_embedding nn.Embedding(4096, d_model) self.layers nn.ModuleList([ TransformerDecoderLayer(d_model, n_heads, dim_feedforward2048) for _ in range(n_layers) ]) self.lm_head nn.Linear(d_model, vocab_size) def forward(self, x): seq_len x.size(1) pos torch.arange(seq_len, devicex.device).unsqueeze(0) x self.embedding(x) self.pos_embedding(pos) for layer in self.layers: x layer(x) return self.lm_head(x)这里有个值得注意的细节位置 embedding 我用了可学习的 nn.Embedding 而不是 Sinusoidal。虽然前面说 Sinusoidal 表现不差但在小规模数据集上可学习位置编码往往收敛更快。两套方案我都跑过可学习编码在相同 epoch 数下 loss 低大约 0.05所以最终选择了可学习方案。训练时的数据加载也有讲究。不能像文本一样直接按行切分而是先用一个滑动窗口把每个 MIDI 的 token 序列切分成固定长度的片段片段之间保留 1/4 的重叠。这是因为音乐的主题往往跨越多个小节硬切分会把完整的乐句切断导致模型学不到完整的和声进行。5.4 推理生成与 MIDI 重建训练完成后生成过程就是标准的自回归采样。核心代码逻辑如下def generate(model, prompt_tokens, max_length1024, temperature1.0, top_k50): model.eval() generated prompt_tokens[:] for _ in range(max_length): input_tokens generated[-2048:] input_tensor torch.tensor([input_tokens]).cuda() with torch.no_grad(): logits model(input_tensor) next_logits logits[0, -1, :] next_token sample_token(next_logits, temperature, top_k) generated.append(next_token) if next_token EOS_TOKEN: # 结束标记 break return generated生成的 token 数组转回 MIDI 时需要把 NOTE_ON、VELOCITY、TIME_SHIFT 这些 token 反向解析成音符对象列表再按时间排序写入一个新的 MIDI 文件。这里有一个容易遗漏的细节解析时要先把同一时间步的 NOTE_ON 事件都收集齐再统一生成 TIME_SHIFT否则同一个和弦的音符会被拆成先后发声的琶音。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因解决方案训练 loss 不下降学习率过大或过小检查学习率范围尝试在 1e-4 到 3e-4 之间网格搜索确认数据预处理没有产生全零 token 序列生成旋律全是单音序列化时把同时发生的音符拆得太散引入事件块或声部包编码让垂直关系在序列中保持邻近节奏混乱时值异常量化粒度选择不当检查 Time Shift 的量化步长尝试增大最小时间单位反复生成同一片段模型过拟合或采样温度过低提高温度到 1.1 以上增加 Top-K 候选数检查训练集是否存在重复文件和弦完全随机缺少调性控制 token在序列开头加入调性标记 token使用条件生成而非纯自由生成生成长度过早结束EOS token 设计不合理移除外显 EOS改用最大长度限制6.2 训练数据过拟合的经典症状音乐 Transformer 最有意思的一点是过拟合不像图像模型那样体现在生成图像的模糊或者纹理重复上而是体现在音乐的“八股化”。你会听到模型生成的乐句结构越来越像哪怕旋律不同但节奏型千篇一律和声进行永远在 I-IV-V-I 这几个和弦之间打转。排查方法很简单把训练集中随机抽几首 MIDI 转成频谱图再和模型生成的音频频谱图对比。如果生成样本的频谱能量分布高度集中在训练集常见频段基本可以确定过拟合。缓解手段包括加大 dropout我用的 0.1 起步过拟合严重时加到 0.2、扩增数据对训练 MIDI 做移调处理C 大调整体移到 D 大调增加样本多样性、以及提前停止训练。6.3 推理阶段生成质量的几个独家调试心得第一采样温度要分 token 类型设置这个技巧我说过但值得再强调一次。旋律和和弦进行可以用稍高的温度增加变化但力度标记Velocity和时值偏移Time Shift应该用低温度。因为力度和时值是音乐风格的骨架骨架乱了一切都乱了。我在实际代码里实现了对特定 token 类别应用不同温度的逻辑运行效果显著优于统一的温度参数。第二生成时给一个“根音约束”。具体做法是在每一小节的起始位置从外部指定一个和弦根音 token 插入序列。模型会基于这个根音展开后续音符这样能生成符合预期调性和声走向的音乐片段。这个小技巧比训练时强制加调性 token 的效果更直接可控适合做人机协作创作。第三要特别关注长序列生成时的累积误差。自回归生成是逐 token 进行的早期的一个小错误会被后续的 token 放大。当生成的音乐在第 30 小节突然出现不和谐时别急着调采样参数先检查是不是序列中某个位置出现了非法的事件组合——比如两个 NOTE_ON 对应的 VELOCITY 值不一致或者 TIME_SHIFT 的间隔超出了预设范围。我写了一个 post-processing 脚本专门对生成结果做合法性和乐理性校验不通过就重新采样该区域。7. 关键经验总结与后续扩展方向整个项目做下来的最大体会是音乐生成模型的瓶颈不在模型结构而在“表征”。同样的 Transformer 代码换一种 token 化方式、换一套数据清洗规则生成结果可能天差地别。如果你拿到的训练数据没有经过严格清洗模型结构再先进也白搭。后续扩展其实有两条非常自然的路径。一是加入多模态条件控制比如用自然语言描述来生成音乐——输入“一首慢速的悲伤钢琴曲”通过一个文本编码器把描述向量拼到音乐 token 序列的前缀位置。这个方向在工程上已经很成熟了本质上是把现有的条件生成机制换成文本条件。二是从 MIDI 生成扩展到音频生成直接建模波形或频谱但这一步对算力和数据量的要求会指数级上升建议先把 MIDI 层面的模型做到满意再考虑。如果只想记住一句话那就是先把数据清洗好把 token 格式定义好模型哪怕只是中小规模也能生成像样的音乐。反过来数据混乱时调参调得再漂亮出来的还是一堆噪音。