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 的自注意力是显式建模任意两两位置的关系,只要位置编码合理、数据足够,长程依赖的建模能力上限高得多。
第二,训练可以并行。音乐序列动辄上千 token,RNN 的串行计算让训练效率非常低。我自己在单卡上跑实验,同样规模的数据量,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_step=0.25): notes = extract_notes(midi_file) # 提取音符列表 notes.sort(key=lambda 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(f"TIME_SHIFT_{time_gap}") current_time = start_tick tokens.append(f"NOTE_ON_{note.pitch}") tokens.append(f"VELOCITY_{quantize_velocity(note.velocity)}") tokens.append(f"NOTE_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, temperature=1.0, top_k=50): logits = logits / temperature filtered_logits = top_k_filter(logits, top_k) probs = softmax(filtered_logits) return random_choice(len(probs), p=probs)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。
第三步,量化与时间标准化。先把所有音符的时间信息转成 tick(MIDI 的最小时间单位),然后按设定的量化粒度做取整。这里有个容易踩的坑:直接按浮点数时间取整会导致节奏漂移,因为 MIDI 文件内部的 tick 分辨率各不相同。正确的做法是先把所有时间转成以四分之一音符为基准的相对值,再做量化。
第四步,事件序列化。按照前面说的 token 类型设计,把一个 MIDI 文件转换成 token 数组,加上全局 token 前缀,保存成训练样本。
处理之后的 token 序列长度大约在 800 到 2000 之间,刚好适合 2048 的上下文窗口,不需要额外的截断和拼接策略。
5.3 模型训练代码核心逻辑
训练代码的核心部分,我直接贴出简化版本供参考:
class MusicTransformer(nn.Module): def __init__(self, vocab_size, d_model=512, n_layers=8, n_heads=8): 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_feedforward=2048) 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, device=x.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_length=1024, temperature=1.0, top_k=50): 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 格式定义好,模型哪怕只是中小规模也能生成像样的音乐。反过来,数据混乱时调参调得再漂亮,出来的还是一堆噪音。