
简介面向人工智能与音乐创作交叉领域的学习者和研究者这份资料以歌词为输入生成旋律、以旋律为输入生成伴奏作为两条主线完整演示了从深度学习环境搭建、数据预处理、模型训练到部署的流程。两个模型可分别独立训练灵活性高适合掌握Python基础并希望将NLP、序列建模应用于音乐生成的开发者参考。压缩包共85个文件、约226.19MB主要包含Python训练与推理脚本、PyTorch/TensorFlow相关配置、midi样例与歌词样例、环境说明文档及目录结构文件便于按模块复现与二次开发。目前已有409人学习下载。通过其中的源码、样例数据和Markdown说明读者可快速理解LSTM/Transformer在歌词到旋律映射中的应用以及和声、节奏建模在伴奏生成中的实现思路节省自行搭建与调试环境的时间。1. 歌词到旋律再到伴奏这条两段式生成链路为什么值得自己动手搭把歌词交给深度学习模型直接出旋律再把旋律交给另一个模型配伴奏两个模型分开训练、分开推理——这个标题描述的就是一条非常典型的“可控音乐生成”技术路线。它与端到端方案输入歌词直接输出完整歌曲最大的区别在于每一段都有明确的中间产物旋律、伴奏这意味着你可以在任意一段上单独调试、换模型、换数据而不必推倒整条流水线。对做音乐生成、虚拟歌手伴奏、短视频配乐这类业务的人来说这几乎是性价比最高的起步架构。这条路线适合两类读者一是想从零搭建音乐生成系统的工程师二是已经在用现成模型但觉得“黑匣子”太重、想自己掌控中间结果的算法同学。全文我会把模型选型、数据组织、环境搭建、训练参数和踩坑记录全部拆开讲中间步骤都给可复现的最小配置。目标是让新手能跟着把两个模型分别跑起来让熟手能看到参数边界和容易翻车的细节。先说总架构两个模型分开训练推理时串成一条流水线。第一个模型接收歌词文本输出旋律MIDI或符号序列第二个模型接收旋律输出伴奏多轨MIDI。训练时互不依赖推理时用管道串联。这个“分而治之”的选择在工程上有一个直接好处——你不需要一个庞大的“歌词→伴奏”配对数据集而是可以把“歌词-旋律”对和“旋律-伴奏”对分别搜集数据来源广得多。2. 歌词到旋律把文本变成音高序列的建模思路与最小实现2.1 为什么选符号域生成而不是直接生成音频歌词到旋律首先要回答一个问题输出是什么。直接生成波形音频域不是不可以但对个人和中小团队来说数据量要求太高、训练不稳定、调试困难。常见做法是把旋律表示为离散符号序列——音高MIDI编号 时值相对时长这样问题就变成了一个序列生成任务可以使用标准的Seq2Seq或Transformer架构。具体来说我会把旋律编码成这样的token序列每个音符由“音高-时值”两个token组成比如60_4表示MIDI 60中央C持续4个时间单位如1/16音符。歌词文本则按字切分每个字对应一个token。为什么要这样设计因为中文歌词以单字为基本韵律单元字和音符之间存在天然的对应关系——一个汉字通常对应一个或多个音符。这个对齐关系在数据预处理时不需要强制标注模型通过注意力机制自己学习字与音高的对应规律。在实际项目里我建议把歌词和旋律都序列化成“同一根时间轴”上的平行序列歌词token序列在前旋律token序列在后中间用分隔符连接。训练时模型做自回归预测预测的是旋律token。这样实现最简单而且效果不比复杂的对齐模型差。2.2 搭建歌词到旋律模型数据准备与训练代码第一步把MIDI旋律文件转成token序列。这里需要用到一个关键库mido来读取MIDI然后自己写一个解析函数。下面这个脚本会把单轨MIDI音乐转换为token序列import mido from mido import MidiFile def midi_to_tokens(midi_path, time_unit120): mid MidiFile(midi_path) # 只取第一个轨道假设旋律在第一条轨道上 track mid.tracks[0] notes {} current_time 0 token_list [] for msg in track: current_time msg.time if msg.type note_on and msg.velocity 0: # 记录音符起始时间 notes[msg.note] current_time elif msg.type note_off or (msg.type note_on and msg.velocity 0): start notes.pop(msg.note, None) if start is not None: duration current_time - start # 时值按 time_unit 取整 dur_units max(1, round(duration / time_unit)) token_list.append(f{msg.note}_{dur_units}) return .join(token_list) # 示例用法 tokens midi_to_tokens(melody.mid) print(tokens[:200])这段代码的核心逻辑是遍历MIDI轨道记录每个note_on事件的开始时间遇到对应的note_off事件时计算时值编码成音高_时值格式。注意我用了time_unit120作为量化单位——如果你发现生成的旋律时值总是不准确多半是这个量化精度没调好。MIDI文件里的tick精度通常是480或960我这里统一按120做量化等于把音符时长归一到1/32音符级别足够表达常见旋律。接下来是训练脚本的主体部分使用PyTorch实现一个轻量Transformerimport torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader class LyricMelodyDataset(Dataset): def __init__(self, data_pairs, vocab_size, max_len128): # data_pairs: [(歌词token序列, 旋律token序列)] self.data data_pairs self.vocab_size vocab_size self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): lyrics, melody self.data[idx] # 拼接歌词 sep 旋律 seq lyrics [sep] melody [eos] # 截断或填充到固定长度 if len(seq) self.max_len: seq seq[:self.max_len] else: seq seq [pad] * (self.max_len - len(seq)) input_ids [self.vocab[v] for v in seq] # 训练目标预测旋律部分歌词部分不计算loss labels [-100] * len(seq) for i, tok in enumerate(seq): if tok not in (pad, sep) and i 0: labels[i-1] input_ids[i] return torch.tensor(input_ids), torch.tensor(labels) class MelodyTransformer(nn.Module): def __init__(self, vocab_size, d_model256, nhead8, num_layers4): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos_embed nn.Embedding(512, d_model) encoder_layer nn.TransformerEncoderLayer(d_modeld_model, nheadnhead, batch_firstTrue) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.output_layer nn.Linear(d_model, vocab_size) def forward(self, x): # x: [batch, seq_len] positions torch.arange(x.shape[1], devicex.device).unsqueeze(0) emb self.embedding(x) self.pos_embed(positions) out self.encoder(emb) logits self.output_layer(out) return logits注意这里的标签构造方式我把sep之后的token作为预测目标歌词部分的标签设成-100PyTorch交叉熵损失会自动忽略这个值。这样做的好处是模型只学习“看到歌词后生成旋律”的映射不会去预测歌词本身。训练参数上我第一版跑通用的是d_model256, nhead8, num_layers4约2500万参数。数据集只有2000首MIDI旋律歌词对batch size设32学习率用1e-3配合余弦退火。训练15个epoch后生成的旋律已经能保持“歌词字数≈音符数”的基本对齐但旋律走向还比较平。后来把数据量扩到1万对d_model提到384效果才有明显改观——所以这个任务的瓶颈真的在数据模型结构用基础Transformer就够。2.3 解码策略为什么贪心解码会让旋律越走越死训练完之后推理时的解码策略对结果影响巨大。我踩过的坑是直接使用贪心解码每一步选概率最高的token生成的旋律两三个小节后就陷入“原地踏步”——总是重复同一个音或者只在两三个音高间来回跳。这是因为旋律的局部概率分布往往是平坦的最高概率 token 的置信度不高贪心选择会丢失多样性。解决方法是使用温度采样temperature sampling在softmax时除以一个温度参数def sample_with_temperature(logits, temperature1.2): logits logits / temperature probs torch.softmax(logits, dim-1) return torch.multinomial(probs, num_samples1) # 推理循环示例 with torch.no_grad(): for step in range(max_steps): logits model(input_ids) next_logits logits[:, -1, :] next_token sample_with_temperature(next_logits, temperature1.2) input_ids torch.cat([input_ids, next_token], dim-1)temperature1.2是我常用的起点——偏高会让旋律随机感太强偏低会趋于重复。另外我还加了top-p采样top_p0.92把概率累计超过92%的最小候选集保留下来再采样进一步防止低概率噪音音高混进来。这两个参数加起来生成的旋律可听性提升非常明显。3. 旋律到伴奏让模型学会“顺着旋律配器”3.1 伴奏生成的任务定义与数据组织旋律到伴奏比歌词到旋律多了一个难点伴奏是多轨的。常见的做法是定义几个声部轨道——钢琴、贝斯、鼓、弦乐每轨一个独立序列。模型需要同时生成多个轨道的音符序列。这比单序列生成复杂因为各轨之间有时间对齐关系和和声约束。我的做法是把伴奏也摊平成token序列但用轨ID做区分每个token是轨ID_音高_时值。比如1_40_2表示轨道1贝斯上的MIDI 40持续2个时间单位。推理时仍然自回归生成但每次生成都带上轨ID前缀。实践证明这种“展平轨ID”方案在小模型上比多分支输出的多头结构更稳定。数据集准备上常见做法是用Bach Chorales、Lakh MIDI数据集或自己扒的流行钢琴伴奏MIDI。这里有个关键预处理必须把和弦信息过滤掉只保留和旋律时间轴对齐的伴奏轨。如果你在准备数据时发现midi轨道混乱一个轨里混着旋律和伴奏不用花太多时间做分离——找个质量高的纯伴奏库比如流行钢琴MIDI库直接用效果反而更好。3.2 最小可复现的伴奏生成训练从旋律token到多轨伴奏token直接复用上一章的Transformer结构只需要把输入输出维度调整一下。核心区别在数据和loss构建上。下面给出数据构造代码def flatten_accompaniment(midi_path, track_mapping): 将多轨MIDI展平成token序列 track_mapping: {轨道号: 轨ID}例如 {1:0, 2:1, 3:2, 10:3} 返回旋律token串 伴奏token串 mid MidiFile(midi_path) melody_tokens [] accomp_tokens [] current_time 0 notes {} # 先用统一时间轴收集所有轨道的note_on/note_off events [] for i, track in enumerate(mid.tracks): t 0 for msg in track: t msg.time if msg.type note_on and msg.velocity 0: events.append((t, i, msg.note, on)) elif msg.type note_off or (msg.type note_on and msg.velocity 0): events.append((t, i, msg.note, off)) events.sort(keylambda x: x[0]) active_notes {} for t, track_idx, note, ev_type in events: if ev_type on: active_notes[(track_idx, note)] t else: start active_notes.pop((track_idx, note), None) if start is None: continue dur t - start dur max(1, round(dur / 120)) if track_idx in track_mapping: track_id track_mapping[track_idx] token f{track_id}_{note}_{dur} if track_id 0: melody_tokens.append(token) else: accomp_tokens.append(token) return melody_tokens, accomp_tokens这段代码的关键是先把所有轨道的音符按时间排序统一计算时值再按轨道映射区分旋律轨和伴奏轨。实际使用中track_mapping需要根据MIDI文件的轨道信息手动指定——这里最容易踩坑因为很多MIDI轨道的乐器和实际音色对不上必须用耳朵检查采样结果不能用轨道序号直接当轨ID。Loss构建上和前一章几乎一样只不过标签的目标是伴奏token部分。对于歌词到旋律和旋律到伴奏这两个模型我建议用同一套代码框架只改数据加载和输入输出维度。这样工程维护成本最低——你只需要维护一份train.py和一个数据配置文件{ task: melody_to_accompaniment, vocab_size: 4096, d_model: 256, nhead: 8, num_layers: 4, batch_size: 32, learning_rate: 1e-3, epochs: 30, data_path: ./data/accompaniment_pairs.json }配置文件驱动的好处是两个模型用了同一个训练入口你要切换任务只需改cfg不用复制整份代码。3.3 让伴奏“跟得上”旋律条件注意力的作用纯把旋律token和伴奏token拼起来训练会出现一个现象伴奏生成了但和旋律的和声色彩经常冲突——比如主旋律是C大调伴奏却出现了F#这种离调音。这不是模型笨而是简单的序列拼接没有显式告诉模型“旋律是条件必须严格参考它”。我在第二版升级中加入了条件注意力机制把旋律token序列单独做一次编码然后将编码结果作为伴奏生成时的Key/Value向量传入Transformer解码器的cross-attention层。这个改动在代码上就是加一个encoder_outputs参数class AccompanimentTransformer(nn.Module): def __init__(self, vocab_size, d_model256, nhead8, num_layers4): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) # 旋律编码器 self.melody_encoder nn.TransformerEncoder( nn.TransformerEncoderLayer(d_modeld_model, nheadnhead, batch_firstTrue), num_layers2 ) # 伴奏解码器 self.decoder_layer nn.TransformerDecoderLayer(d_modeld_model, nheadnhead, batch_firstTrue) self.decoder nn.TransformerDecoder(self.decoder_layer, num_layers4) self.output_layer nn.Linear(d_model, vocab_size) def forward(self, melody_tokens, accomp_tokens): mel_emb self.embedding(melody_tokens) mel_enc self.melody_encoder(mel_emb) accomp_emb self.embedding(accomp_tokens) out self.decoder(accomp_emb, mel_enc) return self.output_layer(out)加了cross-attention之后伴奏离调音出现频率显著下降。原因是模型现在能明确访问旋律的每个时间步信息在生成伴奏时自然地参考旋律音高做和声选择。注意一下melody_encoder不需要很深2层足够太重反而会让训练变慢、容易过拟合小数据集。4. 环境搭建详单从零到两模型可训练的完整依赖配置4.1 Python环境与CUDA先定版本再动手避免“最后一刻才翻车”环境搭建这个环节90%的翻车事故都发生在“版本不匹配”上。这里给出一套我验证过稳定运行两个月没出兼容问题的组合Python3.103.11部分依赖库还没有适配不推荐CUDA11.8对应驱动版本建议≥520PyTorch2.1.0带cu118后缀的安装包依赖库mido1.3.2、numpy1.26.4、midi2audio0.1.1可选用于转音频试听创建conda环境并安装依赖conda create -n music_gen python3.10 conda activate music_gen # 安装PyTorchCUDA 11.8对应版本 pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118 # 安装音乐处理相关依赖 pip install mido1.3.2 numpy1.26.4 midi2audio0.1.1 # 验证CUDA可用 python -c import torch; print(torch.cuda.is_available()); print(torch.__version__)注意第二行安装PyTorch时必须指定cu118的源如果你直接pip install torch大概率会装到CPU版本或最新的cu12x版本——后者在旧显卡驱动下无法使用CUDA。检查输出第一行是否为True这是最基础的验收标准。4.2 训练时显存不够怎么办混合精度与梯度累积的取舍训练环境搭好了接下来遇到的高频问题就是显存。d_model256, num_layers4, batch_size32这个配置在8GB显存上勉强能跑但如果你把序列长度拉长到256显存就会爆。这里给两个实用手段手段一混合精度训练AMP。用FP16存储梯度和中间激活值显存占用直接减半速度还能提升30%左右。代码上只需要加几行scaler torch.cuda.amp.GradScaler() for batch in dataloader: optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(batch[input_ids]) loss criterion(logits.view(-1, vocab_size), batch[labels].view(-1)) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意用了AMP之后如果你的loss变成nan先检查学习率是不是太大——AMP对梯度的动态范围更敏感学习率3e-4以下通常是安全的。手段二梯度累积。如果batch size 32跑不动改成batch size 8 梯度累积4步等效这是在不改模型逻辑的情况下用时间换空间的常见做法。实现时注意每4步才optimizer.step()和optimizer.zero_grad()一次。4.3 避坑/常见问题排查环境与训练中的五个高频故障故障一MIDI文件读不出来报错OSError: [Errno 22]现象把MIDI路径传给MidiFile()时报非法参数错误。 原因MIDI文件本身是SMF格式但有些文件扩展名是.mid实际是RIFF包装的RMID格式mido不支持直接解析。 解决先用file命令查看文件真实类型如果是RMID用midi2audio或fluidsynth转成标准MIDI。更直接的做法是换一批干净的数据源不要浪费时间在格式转换上。故障二训练loss下降但生成的旋律全是同一个音现象训练完成后解码输出总是音高60中央C反复出现。 原因数据集里音高分布极度不均衡——比如C大调的曲子占多数模型学到的先验是“中央C安全”。 解决做数据增强把每首MIDI做移调处理transpose随机上下移2~5个半音等于把音高分布抹平。这是处理旋律生成任务时性价比最高的数据增强手段。故障三AMP训练时loss变成nan现象开启混合精度后第几步开始loss变成nan。 原因生成模型的logits在FP16下溢出因为输出层之前的线性层数值范围过大。 解决把d_model从256升到384往往能缓解或者在输出层之前手动加一个LayerNorm。终极手段是关AMP改梯度累积反正两者效果等效。故障四验证loss低但生成的伴奏轨道间时间轴错位现象生成的伴奏各轨单独听都对合在一起节奏是乱的。 原因训练数据本身轨道时间轴没对齐。MIDI文件里不同轨道的起始时间不一致有的轨道第一小节有延迟。 解决数据预处理时强制对齐——把所有轨道事件的起始时间统一减去最小时间偏移。这个逻辑应该在flatten_accompaniment里实现排序前先做归一化。故障五训练到一半显存OOM重启后加载checkpoint却报维度不匹配现象模型结构改了比如把d_model从256改成384加载旧checkpoint报size mismatch。 原因nn.Embedding和线性层的参数维度变了。 解决改模型结构时把旧的checkpoint删掉重新训练。没有捷径最多用迁移学习——只加载编码器部分参数输出层从头训。5. 训练数据与参数调优把旋律质量和伴奏协调度分开来刷5.1 数据集的三大来源与清洗流程歌词-旋律对的数据集常见来源有三个公开的MIDI扒谱库、人工标注的歌词-简谱对、以及用现有商用引擎反向制作的对齐数据。我推荐以第一个为主、第三个为辅的策略。公开MIDI库比如Lakh MIDI虽然有数万首但质量参差不齐——很多MIDI只有主旋律没有歌词需要人工过滤。我的清洗流程是先用程序过滤掉总音符数少于32、平均音高超过80的MIDI这些通常是纯伴奏或高音域电子音乐再按“旋律轨是否清晰可辨”做一次人工抽查。抽查10首如果6首以上能听出明确的旋律线就留下这批数据否则换一批。请记住清洗数据时“听一遍”的花费远比训练一遍低但带来的质量提升是决定性的。歌词从哪来目前公开的歌词-旋律对数据不多我常用的替代方案是用简谱数据当“伪歌词”——把简谱里的词歌词与数字谱旋律对齐这等于天然自带歌词标注。简谱数据集在中文音乐领域不难找老歌的简谱质量相当高字和音符的对应关系是人工标注过的比自己去扒歌词可靠得多。5.2 两个模型的独立调参要点歌词到旋律模型的调参重点在“韵律对齐”——生成的旋律音高走向要贴合歌词的四声调。如果发现“字对上了音但听着像念经”把注意力头数从8提到16模型能更精细地捕捉字的上下文如果发现旋律突然跑调到很远的音高把top_p往下调从0.92降到0.85压缩采样空间。旋律到伴奏模型的调参重点在“和声色彩”——伴奏与旋律的调性是否统一。这里有个实用技巧在loss中增加“调性一致性”约束。做法是用key抽取算法比如Krumhansl算法获取旋律的调性标签在训练loss中加一个辅助损失惩罚伴奏中出现调性外音的token。辅助损失的权重设0.1即可太高会让伴奏变得过于保守、缺乏变化。6. 推理串联与验证从两个独立模型到一个可用的生成系统6.1 在两模型之间加一道“旋律质量阀”两个模型各自训练完成后推理时直接串起来使用是最朴素的做法。但我的经验是在旋律模型输出和伴奏模型输入之间加一个简单的过滤规则能显著提升最终成品的可用率。规则不复杂检查旋律是否包含连续的重复音超过3次、音域是否超过20个半音、总时长是否在合理范围比如8~32小节。不合格的旋律不进入伴奏模型重新采样一次。def melody_quality_check(melody_tokens, min_range12, max_range24): 检查旋律token序列的基本可用性 notes [int(t.split(_)[0]) for t in melody_tokens] max_jump max(abs(notes[i] - notes[i-1]) for i in range(1, len(notes))) pitch_range max(notes) - min(notes) repeated sum(1 for i in range(2, len(notes)) if notes[i] notes[i-1] notes[i-2]) if pitch_range min_range or pitch_range max_range: return False, 音域异常 if max_jump 19: # 超过两个八度视为跳跃异常 return False, 音高跳变过大 if repeated len(notes) * 0.3: return False, 重复音过多 return True, 通过注意这段检查代码需要在token层面做不需要转回MIDI成本几乎为零。实际使用中采样5次旋律一般能通过2~3次这比生成后听完再重新生成高效得多。6.2 把MIDI转成音频试听FluidSynth与音色库搭配调试生成的旋律和伴奏时直接用MIDI播放器听效果远不如转成音频来得直观。我这里用fluidsynth配合一个免费的SoundFont音色库比如GeneralUser GS转成wav后再保存成mp3试听# 安装fluidsynthUbuntu/Debian sudo apt install fluidsynth # 转音频指定音色库文件和输入MIDI fluidsynth -a alsa -g 1.0 -F output.wav -i /path/to/soundfont.sf2 input.mid转成wav后直接用播放器播放。注意如果你在无图形界面的服务器上调试把-a alsa改成-a file并配合-F参数这样只输出文件不尝试播放避免alsa设备报错。我在无头服务器上踩过这个坑第一次转wav时因为没装音频驱动报错加上-a file就好了。6.3 进阶玩法把两个模型合并成“歌词到伴奏”的端到端蒸馏两个模型都稳定工作之后有一个值得做的进阶方向用蒸馏的方式把“歌词→旋律→伴奏”三段合并成一个模型。具体做法是用训练好的两个模型生成大量“歌词-伴奏”配对数据再用这些数据训练一个单独的歌词到伴奏的模型。这样推理时只需一次前向传播延迟减半中间环节也少了一个可能出错的地方。但我的建议是先保持两段式架构跑一段时间确认业务场景真的对延迟敏感之后再做合并。两段式最大的好处是“后悔药”——你觉得旋律不够好就只换旋律模型伴奏不够好就只换伴奏模型合并之后就没有这种自由了。最后分享一个习惯每当调整训练数据或模型参数我都会同时保留旧版本的checkpoint和生成样例存成带日期和参数备注的文件夹。这样如果新版效果反而变差随时可以回滚对比。音乐生成这种偏主观的任务自动评估指标只能帮你发现“明显变差”细微的乐感差异还是要靠人听来判断。保留历史采样就是给判断留了依据。希望这篇笔记能帮你把歌词到旋律、旋律到伴奏这条链路完整搭起来少走我走过的弯路。本文还有配套的精品资源点击获取