☰
Transformer搭建音乐生成模型:从MIDI Token到AI续写钢琴曲
2026/10/19 4:25:36 网站建设 项目流程

把一段旋律丢给程序,让它接着往下写,还要补上像模像样的和声与节奏——AI谱曲这个方向,过去几年换过好几批“主角”,从隐马尔可夫到RNN,再到如今各种音乐生成Demo背后的真正主力:Transformer。这篇是这个系列的第五篇,前面几期聊过MIDI格式、数据清洗、RNN/LSTM方案的局限性,这期直接进入正题,拆一个基于Transformer的音乐大模型该怎么搭、怎么训、怎么让它产出一段真正能听的钢琴片段。

如果你有Python基础、自己跑过一点深度学习代码,完全可以照着这套思路把管线搭起来;如果你之前主要做NLP,那更合适,这篇相当于一份“语言模型如何迁移到符号音乐”的实操笔记。整个项目的关键点其实只有三个:Token化设计、结构选型、采样策略。把这三件事想明白,后续往多轨、和弦、可控生成这些方向扩展都会顺很多。

1. 整体设计:为什么把MIDI变成“乐谱语言”喂给Transformer

先说一个很多新手容易绕进去的问题:为什么放着现成的音频不用,非要把音乐转成MIDI token?直接对波形建模不是更“AI”吗?

理论上确实可以,但实操里很难受。音频是连续信号,一个小时的钢琴曲采样率高得惊人,模型要学的东西里有一大半是声学细节,而不是音乐结构。而且生成出来的音频你很难控制,想改某个音、想让它从某个和弦继续接下去,几乎没有可操作性。MIDI不一样,它本身就是离散符号:哪个音、什么时候响、响多久、多大力。这天然就是语言模型最擅长的“序列预测”问题。把MIDI事件变成token,再把“预测下一个token”当成训练目标,标准Transformer那一套东西就能直接搬过来用。

1.1 为什么不是RNN/LSTM:音乐需要跨越长距离的结构

音乐不是一串随机音符,而是有主题、有发展、有段落复现的结构。比如回旋曲的主题在开头出现,隔了十几小节又回来;一首歌的主歌和副歌往往共享素材,只是换了情绪和编排。这种长距离依赖,恰恰是RNN最不擅长的事。

RNN的问题在于它靠一个“隐状态”一路往下传,前面的信息传了十几个小节早就衰减得差不多了,除非从头到尾反复“背诵”,否则很难在副歌出现时精准响应开头那几句旋律。LSTM加了个门控机制,能比RNN好一点,但本质还是线性扫描,训练也只能一个时间步一个时间步地串行推,又慢又憋屈。

Transformer的自注意力机制不一样,它让序列里任意两个位置的token都可以直接建立联系。模型在第500个token的位置,依然能“看到”第20个token那里出现的旋律动机,然后主动做呼应。用生活里的例子类比:RNN像是一个人用临时记忆念长句子,念到后半句早忘了开头说的啥;Transformer像是把整张乐谱摊在桌上写作业,想回看哪小节就回看哪小节。

所以做音乐生成,尤其是需要跨小节的动机发展、主题复现这类结构的任务,Transformer几乎是“天生对口”的方案。这也是为什么“基于Transformer的音乐大模型”能成为当前AI谱曲事实上的主流技术路线。

1.2 整体管线:从MIDI到Token再回到音频

整个系统的闭环并不复杂,核心是从“生成音频”转换成“生成token序列”。实际项目里我一般分成五个阶段:

  1. 数据准备:拿到一批MIDI文件,解析出来,再做清洗——去掉鼓轨、修正错误音长、统一节拍分辨率。
  2. Token化:把一个一个音符事件转换成离散token序列。这是最关键的设计环节,直接影响模型能不能学到音乐的逻辑。
  3. 训练:把token序列喂给一个因果Transformer(Decoder-only架构),用“预测下一个token”的交叉熵损失去训练。
  4. 生成:给定一小段旋律作为“种子”,让模型一步步预测后续token,直到预测出结束符号或达到设定长度。
  5. 后处理:把生成的token序列解析回音符事件、写出MIDI文件,再通过音源渲染成音频或导入DAW里看谱。

你会发现,这套流程的本质,是把音乐生成重新定义成了一个自回归语言模型问题。所以NLP里面积累的那些生成技巧——温度采样、top-p截断、重复惩罚——几乎都能平移到音乐生成上,省掉大量重复造轮的功夫。

1.3 模型不必“大”到离谱:先搞清楚瓶颈在哪

这两年“大模型”三个字被炒得很热,一听到音乐大模型,很多人第一反应是“是不是得上几十亿参数”。我可以负责任地说:如果你只是用公开MIDI数据集做一个Demo,几千万参数的Transformer已经能生成很完整、很舒服的钢琴段落了。

原因有两个。第一,MIDI序列本身的信息密度没有自然语言文本那么高。一首钢琴曲的token序列,展开来看结构其实很规律,音符之间的模式和词法远比文本更容易被中等规模的模型掌握。第二,模型太大但数据不够时,会迅速过拟合,生成出来的结果全是复读机式的碎片和“背课文”式的片段。反而是尺寸适中的模型配合好的Token化设计,效果让人惊喜。

我自己的实验参考是:embedding维度384、Transformer层数6到8层、注意力头数8个,序列长度512个token。这个规模在单卡上训练很轻松,生成效果已经足够支撑一个像样的自动作曲小工具。真正值得花时间的,是下面的Token化设计,那才是决定模型天花板的环节。

2. 核心环节拆解:从Token化到采样生成的关键实操

2.1 Token化:让MIDI变成Transformer能读的“单词”

Token化是整个项目里最容易被低估的一步。很多新手直接把“音高”当作token,一个音高对应一个编号,生成出来就是一堆单调的音符,毫无音乐性。问题在于:音乐里一个音符不只是“哪个音”,还包括力度、时值、在节拍里的位置。这些属性拆得越细致,模型能学到的音乐感就越丰富。

我用的是一种事件式编码,大致类似论文里常见的REMI思路。一个音符不是单个token,而是拆成几个不同的属性token:

Token类型含义示例
Pitch音高编号,0到127Pitch_60表示中央C
Velocity力度,0到127Velocity_96表示较强奏
Duration时值,以tick为单位Duration_240表示一个四分音符(按480 tick/拍)
TimeShift距离上个事件推进的时间TimeShift_120表示等待120个tick
Bar小节边界BAR
EOS序列结束EOS

比如“中央C、力度80、持续四分之一拍”这个事件,编码出来就是大约这样一串token:

Pitch_60, Velocity_80, Duration_120, TimeShift_120

为什么不直接把“音高+力度+时值”合成一个token编码?因为组合空间太大,数据会稀疏到没法学。拆成独立属性token之后,模型可以分别学习音高的分布、力度的分布、时值的分布,以及它们之间的组合规律。这就像英文单词用词根词缀拆开学,比把每个变形都当成独立词要聪明得多。

还有一个很多人第一次写时容易忘的事情:休止符。休止不是额外的一种“事件”,它本质上就是TimeShift堆叠。如果一个片段里你从不生成TimeShift,那模型写出来的音乐就是“一串音符从头到尾不断气”,毫无呼吸感。所以Token化方案里一定要让TimeShift足够灵活,让模型自己学会在适当的时候“停一拍”或者“停半拍”。

附一个把音符事件转成token的伪代码示意:

# 伪代码:将MIDI事件转为token列表 def events_to_tokens(events): tokens = [] for ev in events: if ev["type"] == "note": tokens.append(f"PITCH_{ev['pitch']}") tokens.append(f"VELOCITY_{round(ev['velocity'] / 16) * 16}") tokens.append(f"DURATION_{ev['duration_ticks']}") elif ev["type"] == "time_shift": tokens.append(f"TIMESHIFT_{ev['shift_ticks']}") elif ev["type"] == "bar": tokens.append("BAR") tokens.append("EOS") return tokens

这样整个音乐就被翻译成了一维的token序列,Transformer读起来和读句子没有区别。

2.2 位置编码与注意力结构:让模型“知道音符怎么排”

Transformer没有天生的“顺序感”。你把同一组token顺序打乱喂进去,注意力的计算结果其实差不多,因为自注意力本身是“位置无关”的。所以必须把位置信息显式加进去。

位置编码的选择上,我推荐相对位置编码,而不是普通的绝对位置编码。道理也很朴素:对音乐来说,一个音比前一个音高了多少、隔了多少拍,比“这个音在整首曲子的第380个token处”重要得多。绝对位置编码会把模型绑死在某个调性或某个绝对时间上,移调后泛化很差;相对位置编码则让模型更关注间隔和相对高差,这正是音乐结构里最核心的信息。

至于结构选型,这个项目直接采用Decoder-only的因果Transformer就对了。所谓“因果”,就是每个token只能看到自己和之前位置的信息,看不到未来。这恰好匹配生成过程:预测下一个token时,你手里只有过去的内容。用Encoder-Decoder或者双向注意力也不是不行,但会引入额外的复杂度,对于“生成”这个任务来说没必要。

训练完之后,有一个很值得做的直观检验:把某个生成样本的attention矩阵画出来。我经常在副歌出现的位置看到模型“回头”去注意开头的主题区域,这种可视化是模型确实学到结构的最直接证据。

2.3 采样策略:不要一个劲套用NLP默认参数

模型训练完了,生成时是从概率分布里采样的,不是每次都选最大概率。如果用贪心解码,生成结果几乎一定有严重的重复问题。音乐本身是大量重复的艺术,模型很容易觉得“既然前面重复过,后面继续重复一定没错”,结果就是第八小节还在复读第一小节的音型。

我在生成时最常用的三个参数是:

  • Temperature(温度):控制概率分布的平滑度。温度越低越保守,越高越随机。音乐生成建议在0.8到1.1之间,太低复读,太高音符乱跳。
  • Top-p(核采样):只从累计概率超过p的候选token里采样,通常0.9到0.98。
  • Repetition Penalty(重复惩罚):对已经出现过的token打分时除以一个惩罚系数,推荐从1.2起步,可以往上调到1.5。

下面是一个简化版的采样逻辑,融合了这三个机制:

def sample_token(logits, past_tokens, temperature=1.0, top_p=0.95, rep=1.2): # 温度缩放 logits = logits / temperature # 重复惩罚:把已经出现过的token分数压低 for token in set(past_tokens): logits[token] /= rep # 按概率取累计达到top_p的最小集合 sorted_logits, sorted_idx = sort_descending(logits) cumulative_probs = softmax(sorted_logits).cumsum(-1) remove_idx = cumulative_probs > top_p remove_idx[1:] = remove_idx[:-1].clone() remove_idx[0] = False logits[sorted_idx[remove_idx]] = -float("inf") probs = softmax(logits) return multinomial(probs, num_samples=1)

有个细节需要注意:remove_idx[0] = False这一行一定要留,它保证概率最高的那个token永远会被保留,不会因为核采样把最合理的候选意外删掉。

实际使用的经验是:先固定top_p,再去调温度和重复惩罚,不要三个参数同时乱动。否则出了问题你也分不清是哪一项引起的。我跑下来比较稳的起点是temperature=0.9, top_p=0.95, rep=1.3,之后根据试听效果微调。

3. 完整复现路径:数据清洗、训练配置与MIDI渲染

3.1 数据准备:宁可少而干净,不要多而混乱

数据是这种项目最大的分水岭。网上能找到的公开MIDI数据集不少,优先选单钢琴轨、音乐结构清晰的曲目,古典钢琴曲尤其合适。合成器音效轨、摇滚多轨、鼓点密集的MIDI更适合做别的项目,在这里只会让模型学出一堆杂乱音堆。

拿到MIDI文件后,清洗清单可以照着这个执行:

  1. 去掉鼓轨、歌词事件、控制器消息,只保留真正的音符事件。
  2. 修正音高越界、负时值、重叠音等非法数据。
  3. 统一tick分辨率,比如每四分音符设为480 tick,这样后面Token化时Duration和TimeShift的单位是统一的。
  4. 过滤掉超长曲目,比如超过几百小节的直接切出或丢弃,否则输出序列长度波动太大。
  5. 做移调增强:把曲子随机上下移动三到五个半音。这样同样一首曲子能生成C大调、D大调等多个调性的版本,数据量一下子多起来,模型的调性泛化能力也更好。

我踩过最深的坑是完全不做筛选,把能找到的MIDI一股脑全喂进去。结果生成结果里经常出现“左手低音区突然蹦出个刺耳高音”这种奇怪现象,原因很简单:数据里混了大量MIDI内录文件,轨道设计根本不符合正常演奏逻辑。后来我学乖了,清洗完先随机抽几十首MIDI,直接用DAW打开看piano roll,肉眼确认是能读的谱子再进训练管线。这一步不能省。

3.2 训练配置:普通单卡也能跑出像样效果

在这个项目里,“大模型”的“大”主要体现在序列长度和训练数据,而不是参数量。我个人推荐以下这套起步参数组合,适配大部分8到15分钟曲库规模的数据集:

超参数建议值说明
序列长度512 token约覆盖十几到几十小节的钢琴片段
Embedding维度384中等容量,单卡无压力
Decoder层数6数据量小不建议加深
注意力头数8标准比例,head维度为48
前馈层宽度1536约为embedding的4倍
参数量约25M单卡可轻松训练
学习率3e-4配合约1000步的warmup
Batch Size64显存不够时用梯度累积
训练轮数10到20观察验证集loss早停

为什么序列长度定512 token?在事件式编码下,一个音符平均要三到四个token表示,512个token大约是一段中等长度钢琴曲的十几小节。这个长度足以覆盖“主题+发展+再现”的基本结构,又不会让训练数据切出来的样本过少。你要是把序列长度拉到1024,显存压力翻倍,普通消费级显卡就开始吃紧了,但音乐结构的提升并不一定成正比。

训练时还要注意一个细节:同一batch里不同曲子长度不一致,需要padding到相同长度。交叉熵损失计算时必须对padding位置用ignore_index=-100之类的方式屏蔽掉,否则模型会去学习预测那些没有任何意义的padding token,验证集loss会非常难看。

3.3 生成与渲染:从“预测下一个token”到可听的MIDI

训练完成之后,生成阶段反而是最简单但最有成就感的环节。整个过程是这样的:

  1. 准备种子:拿一首先前清洗过的曲子,转成token后截取开头几十个token,作为生成起点;或者手工写一小段旋律转成token当种子。
  2. 循环采样:把种子序列丢进模型,预测下一个token,用我们前面说到的采样策略选token,再把它拼接到序列末尾,继续下一次前向计算。
  3. 终止条件:生成到EOS token,或生成长度达到预设上限。
  4. 解码:把token序列转回音符事件,TimeShift累加得到绝对时间,Duration得到音符长度,Pitch和Velocity直接还原。
  5. 渲染:输出标准MIDI文件,用开源合成器挂钢琴音源导成WAV或MP3;或者直接拖进DAW里打开看谱子、微调编曲。

下面是一个简单的token解码伪代码逻辑:

# 伪代码:把token序列转回note事件 def tokens_to_events(tokens): events = [] tick = 0 current_pitch = None current_velocity = 0 for token in tokens: token_type = token.split("_")[0] if token_type == "PITCH": current_pitch = int(token.split("_")[-1]) elif token_type == "VELOCITY": current_velocity = int(token.split("_")[-1]) elif token_type == "DURATION": if current_pitch is not None: events.append({ "pitch": current_pitch, "velocity": current_velocity, "start": tick, "duration": int(token.split("_")[-1]), }) elif token_type == "TIMESHIFT": tick += int(token.split("_")[-1]) return events

实际代码里还要处理重音符、和弦、TimeShift与音符事件顺序的细节,但核心逻辑就是上面这段。

生成质量的评估,我强烈建议不要只看loss。最可靠的办法是:把生成的MIDI导入DAW,把钢琴卷帘打开,肉眼加耳朵一起过一遍。看音符是否合理地落在节拍强位上,看旋律有没有起伏,看结尾有没有收束感。音乐是好是坏,很多时候人类一听就能判断,比任何数值指标都直观。

4. 踩坑实录:训练与生成中的高频问题排查

4.1 Loss不降速查:先别调模型,查数据和Token

训练Loss一直不降,新手第一反应是模型结构改错了,但其实十有八九是数据或Token化的bug。

先做一件事:用一小批干净数据过拟合。比如只取20条清洗过的曲子,训练几轮,看模型能不能把训练集loss压到很低。如果过拟合都做不到,大概率是Token化或数据解析出了问题,比如TimeShift累加出错、Duration单位不统一、Pitch范围超出词表。这时候调模型结构纯属浪费时间。

如果过拟合没问题,但验证集loss一直横盘,那就是数据本身的噪声太大,或者序列切分方式不对。检查一下训练集和验证集是不是有重复的曲子、有没有混入轨道极其混乱的MIDI、Padding的loss是否屏蔽掉了。

学习率也需要排查。学习率太大会让loss曲线像心电图一样反复跳,太小声收敛得像蜗牛。我习惯用3e-4配合warmup起步,如果明显不稳定降到1e-4。

4.2 生成“复读机”:重复和空洞的深层原因

音乐生成里最高频的问题是:开头还行,越到后面越像原地踏步。这个现象的根源有两个。

第一个原因纯粹是采样参数设置不对。温度太低、重复惩罚太低,都会让模型倾向于重复概率高的历史token。先把rep调到1.3以上、温度调到0.9到1.0,再试听,绝大多数复读问题能缓解一半。

第二个原因比较隐蔽:模型本身太大,训练数据又不够多,导致它把训练集中的整段旋律“背”下来了。生成时会按照训练数据的习惯,把某一段旋律原封不动地复读出来。这种情况光调采样参数没用,需要减小模型容量、增加数据量、加正则化(dropout和weight decay),或者干脆换更早的checkpoint——早停的模型往往比训满epoch的模型生成更有创造性。

4.3 听感乱、节奏无律动:问题多半出在Token粒度与顺序

如果你生成的钢琴曲听感不是“复读机”,而是“一堆音符乱七八糟地砸下来”,节奏感全无,那问题大概率出在TimeShift粒度的设计上。

我刚开始用每拍一个tick的粗粒度,生成的曲子全是“机器人式”的齐整音符,没有一个附点、没有一处延后,听感非常机械。后来把时间分辨率改细到“每拍32个tick”,模型才终于学会了一点“人类演奏”的微浮动。TimeShift粒度需要在词表大小和节奏精细度之间找平衡,四分音符分解成32份左右是比较稳妥的选择。

另一个容易忽视的是Token顺序。Pitch、Velocity、Duration、TimeShift这几个token的排列顺序不能乱。常见的事件顺序是:先出现Pitch和Velocity,然后是Duration,最后用TimeShift把时间推进到下一个事件。如果你把TimeShift放在Duration前面,模型很难理解“这个时长到底属于哪个音”,生成的音符时长就会错位。保持一个稳定一致的编码顺序,比模型调参重要得多。

4.4 显存与速度问题:几个立竿见影的土办法

因为项目用的是Decoder-only Transformer,训练和生成时序列长度直接决定显存占用。如果单卡跑不动,优先级从高到低是:

  • 序列长度先减半,比如从512降到384甚至256,看效果掉多少再决定要不要加回去。不要一上来就追求1024。
  • 梯度累积:显存不够时把batch size拆成几小步,效果几乎一致。
  • 半精度训练:主流框架都支持bf16/fp16,对Transformer收益非常明显,显存减半、速度翻倍。
  • sequence packing:把多个短序列拼成一个长序列,省掉padding开销,训练吞吐更高。代价是注意力mask处理稍复杂,第一版可以不做。

如果这些都做完了还慢,检查是不是在生成时没有复用历史token的KV缓存。音乐生成的序列不长,KV cache优化可以后面再做,但如果你想跑长序列的多轨生成,这一步迟早要补上。

最后整理一个快速排查表:

症状优先排查方向备选方案
Loss不降Token化/数据解析bug调整学习率、warmup
生成复读调高rep、温度减小模型、早停checkpoint
节奏死板TimeShift粒度太粗丰富Velocity/Duration变化
显存爆序列长度过长梯度累积、半精度训练
听感混乱多轨数据混杂先单轨训练、加休止符

这五个问题基本覆盖了一个Transformer音乐生成项目从第一版到能稳定出活儿的全过程。每一条我都自己踩过不止一遍,写出来也是希望大家少走点弯路。

最后分享一点个人体会:在音乐生成这个方向,模型的参数规模远没有看起来那么重要。我反复实验下来,真正拉开效果差距的往往是最开始那个不起眼的Token化设计,以及采样时你给模型戴上的那几条“规矩”。如果你也想像做一个能续写旋律的小工具,建议先把这套管线在单轨钢琴上跑通,再考虑加入多轨、和弦与可控生成。后面那几步,几乎所有问题都可以从这一篇的思路里扩展出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询