☰
用DeepSeek生成MIDI:从文本到音符的AI作曲实战指南
2026/10/5 7:09:55 网站建设 项目流程

简介:一份面向技术开发人员与AI音乐创作者的实战指南,聚焦如何借助DeepSeek模型与MIDI技术生成原创音乐,适合希望系统掌握AI作曲流程的开发者学习。内容从AI作曲技术背景和DeepSeek模型特性切入,依次讲解MIDI文件结构解析、数据清洗与特征提取、模型架构设计、训练数据准备与训练过程,并针对原创音乐生成流程给出音乐种子选择、模型推理、MIDI事件生成、构建完整MIDI数据及保存文件的完整代码示例;同时涉及音乐结构合理性、风格相似度等评估指标,以及游戏配乐、影视配乐、个性化音乐推荐等实际应用案例。资源包共1个PDF文档,26页,大小约1.97MB,内容完整,目录结构清晰,便于按章节系统学习。目前已有378人学习浏览,读者可借助这份资料快速搭建DeepSeek+MIDI的实战框架,完成从数据处理到模型训练、效果评估的完整实践,提升AI音乐创作效率与作品质量。

1. 从文本到音符:DeepSeek 写 MIDI,凭什么可行

让一个语言模型去作曲,第一反应往往是“玄学”。DeepSeek 训练时读的是文本,谱曲靠的却是 MIDI 事件流,两件事看起来八竿子打不着。但把 MIDI 拆开看,它就是一组带时间戳的符号序列:note_on、note_off、program_change、tempo,谁在什么时刻按下哪个键、力度多大、持续多久。符号序列,恰好是语言模型最擅长的输入形态。AI作曲的完整链路因此变成:把 MIDI 转成文本让 DeepSeek 读,DeepSeek 生成新的符号文本,再还原成 .mid 文件。

这篇文章对应的是一份 26 页的实战文档,覆盖了从 MIDI 解析、数据预处理、模型加载到生成与评估的完整流程。适合两类人:一类是会用 Python、想快速产出原创 MIDI 素材的开发者,另一类是已经在用 MIDI 做配乐、想用 DeepSeek 提效的内容从业者。读完你至少能搭出一条能跑通的本地作曲管线,并且知道哪些参数会翻车、翻车后去哪里查。

2. 拆开 MIDI:轨道、事件与 480 ticks 的换算规则

MIDI 是整个作曲管线的数据底座。很多人第一次接触 MIDI 时以为它是音频文件,拿到手直接双击播放,听到的其实是系统自带的软音源合成结果。MIDI 里没有一个采样点,它记录的是“演奏指令”。搞清楚这层区别,后面生成、解析、清洗才不会走偏。

2.1 MIDI 不是音频:三层结构先分清

MIDI 文件由三层组成,从外到内是文件头(Header)、轨道(Track)、事件(Event)。文件头固定以MThd开头,后面跟着长度、格式类型、轨道数量和 ticks_per_beat。格式类型有三种:0 型是单轨文件,所有事件都铺在一条轨道上;1 型是多轨文件,不同声部各占一条轨道;2 型是多轨且各轨独立起始,现在很少见,遇到直接转成 1 型处理。

轨道以MTrk开头,里面按时间顺序排着一串事件。常见的事件类型有 note_on(按下音符)、note_off(松开音符)、control_change(控制器变化,比如延音踏板)、program_change(切换乐器音色)、meta 事件里最要紧的是 tempo(速度)和 time_signature(拍号)。很多解析卡壳都出在 tempo 上——它不在音符事件里,而是藏在 meta 事件里,新手容易漏掉。

用 mido 读文件头非常直接:

import mido mid = mido.MidiFile('example.mid') print(f"格式类型: {mid.type}") print(f"轨道数量: {len(mid.tracks)}") print(f"时间划分: {mid.ticks_per_beat}")

mid.ticks_per_beat是整个文件的时间基准,表示一个四分音符分成多少个 tick。常见值是 480、480 意味着每个四分音符的时长单位是 480 tick,所有音符的时长和事件的间隔时间都以这个数为分母计算。解析前先打印这组值,能帮你迅速判断文件是不是统一过格式,避免后面特征提取时出现“时长忽大忽小”的怪问题。

2.2 用 mido 解析:从轨道里抠出音符事件

拿到文件头之后,下一步是遍历轨道、提取音符。mido 的做法是把每条轨道看成一个事件列表,每个事件带一个time字段,表示距离上一个事件的 tick 数。解析时要注意一个常见误区:msg.time是增量时间,不是绝对时间,必须用一个变量累加才能得到事件发生的绝对位置。

import mido notes = [] note_on_time = {} for track in mid.tracks: current_time = 0 for msg in track: current_time += msg.time if msg.type == 'note_on' and msg.velocity > 0: note_on_time[msg.note] = (current_time, msg.velocity) elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): if msg.note in note_on_time: start_time, velocity = note_on_time.pop(msg.note) duration = current_time - start_time notes.append({ 'pitch': msg.note, 'start': start_time, 'duration': duration, 'velocity': velocity, })

这段代码里有个关键处理:很多 MIDI 文件把力度为 0 的 note_on 当作 note_off 用,这是硬件和软件之间长期形成的惯例,不处理就会出现“音符永远不结束”的解析结果。逻辑上,先把 note_on 存进字典,等遇到对应的 note_off 再配对弹出,顺便算出时长。配对时用 note 编号作 key,因为同一时刻同一个音只能有一个开始事件,这个假设在绝大多数乐曲里成立。

2.3 时间轴换算:ticks、tempo 与 480 的默契

提取出音符事件后,要做的是把 tick 换算成“拍”或“秒”,否则你看不懂生成的曲子到底多长。换算公式只有两步:先由 ticks_per_beat 把 tick 转成拍,再乘上 tempo 得到秒。

实际项目里我一般统一目标刻度为 480,再按需换算:

TICKS_PER_BEAT = 480 def ticks_to_seconds(ticks, tempo_us): seconds_per_beat = tempo_us / 1_000_000 return ticks / TICKS_PER_BEAT * seconds_per_beat

tempo_us是每个四分音符的微秒数,MIDI 默认是 500000,也就是每分钟 120 拍。换算的意义在于:不同来源的 MIDI 文件 ticks_per_beat 可能不一样,有的是 96,有的是 480,还有的稀罕文件用 960。不统一刻度就做特征提取,训练时模型会学到一堆无意义的时长差异。所以解析阶段统一 rescale 到 480 tick,是我每次处理数据集的第一道工序。

另外一个坑藏在乐器和声部里。某些 MIDI 文件第 0 轨是钢琴,第 1 轨是贝斯,第 2 轨是鼓。鼓的 note 编号和音高语义和其他轨道完全不同,如果把鼓轨和旋律轨混在一起训练,模型会生成出“用钢琴音色打鼓点”的怪东西。预处理时要么把打击乐轨道单独切分,要么干脆剔除,取决于你想让模型学什么。

3. 把音符当语言:DeepSeek 加载、输入构造与生成参数初值

MIDI 解析完之后,就轮到 DeepSeek 出场。整条管线的核心思想是:把音乐事件编码成文本,让语言模型学习事件之间的转移规律。这里不讨论用 LSTM 或 GAN 单独重新训练一个音乐模型——文档里对比过这些路线:规则方法死板、LSTM 序列建模能力弱、GAN 调起来费劲。DeepSeek 这种大规模语言模型的好处是已经在大规模文本上学到了复杂模式,只要做好文本化编码,它能直接迁移能力到音符序列上。

3.1 为什么选 DeepSeek:语言模型天然适合符号序列

DeepSeek 的底层结构是 Transformer 的 decoder-only 变体,靠自注意力机制捕捉序列中任意两个位置之间的依赖。音乐恰好是重度依赖上下文的序列:前一个和弦决定后一个和弦的倾向,前八个小节的动机往往在后八个小节变形再现。LSTM 这类循环结构容易丢远处信息,注意力机制则能直接看到整个序列,这是选 DeepSeek 做序列生成的根本原因。

实操层面还有一个更现实的好处:DeepSeek 支持通过transformers加载,也能用官方 API 调用。本地部署方便调试和批量生成,API 调用省去 GPU 成本。如果你只想快速验证链路,先用 API 跑通,再决定要不要本地化部署。文档里给的加载代码是 transformers 路线,这也是我觉得最适合开发者的入门方式——依赖少、显存可控、参数可见。

3.2 加载模型:API 或本地 transformers 两条路

先说本地路线。安装依赖只要两个库,PyTorch 和 transformers:

pip install torch transformers

加载模型和分词器:

from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("deepseek-ai/deepseek-chat") model = AutoModelForCausalLM.from_pretrained( "deepseek-ai/deepseek-chat", device_map="auto", torch_dtype="auto", )

这里的deepseek-ai/deepseek-chat需要替换成你实际能访问的模型标识。device_map="auto"让 transformers 自己分配 CPU/GPU,torch_dtype="auto"自动选择半精度以省显存。如果 GPU 显存不够,可以把torch_dtype改成torch.float16,并用 vLLM 这类推理框架做量化部署;这不是必需步骤,但算是本地跑大规模模型的常规手段。

API 路线更省事,官方接口走 OpenAI 兼容协议,用openai库就能调:

from openai import OpenAI client = OpenAI(api_key="你的key", base_url="https://api.deepseek.com") resp = client.chat.completions.create( model="deepseek-chat", messages=[ {"role": "user", "content": "生成一段C大调、120BPM、4/4拍的钢琴旋律,格式:音符 起始拍 时长拍 力度"}, ], temperature=0.8, ) print(resp.choices[0].message.content)

两条路线的选择标准很朴素:训练数据和生成量都在几百 MB 以内、想做细粒度控制,就走本地;只是偶尔生成几段找灵感,API 足够。我一般先用 API 验证 prompt 格式,再切本地做批量生成,因为本地批处理不需要逐条计费。

3.3 把 MIDI 文本化:输入格式与分词

DeepSeek 只认文本,所以 MIDI 特征要序列化成字符串。核心是让每个音符的信息完整且可逆——即从文本能无损还原回 MIDI 事件。我常用的编码格式是四元组:

def midi_to_text(note_features): lines = [] for pitch, start, duration, velocity in note_features: lines.append(f"P:{pitch} S:{start} D:{duration} V:{velocity}") return "\n".join(lines)

每个音符一行,包含四个要素:音高 P、起始位置 S、时长 D、力度 V。单位统一用 tick,起始位置用绝对 tick 而不是增量 tick,这样模型不用自己维护累加状态,生成错误率低很多。分隔符用换行加空格,词表干净,分词器不需要额外扩展。

分词环节需要注意的是,DeepSeek 自带的分词器不认识P:60这种格式,它会按通用规则切分。这不一定有害,但会浪费 token。更可控的做法是事先把所有音符映射到固定词表,把P:60 S:0 D:480 V:64整体作为词元。如果嫌改造分词器麻烦,至少要在文本里留出分隔符,并且生成完用正则做一次合法性校验。

3.4 生成参数初值:先让输出稳定,再谈风格

生成时参数设置直接决定输出质量。文档给的参数组合是max_length=100, num_beams=5, no_repeat_ngram_size=2, early_stopping=True,这是一个偏保守的组合,适合先跑通。我自己调参的结果是:如果是短旋律,beam search 效果不错;如果想生成更长的多声部结构,beam 会开始重复,这时候换采样会更自然。

参数初值作用说明
max_length100~200生成的 token 上限,短旋律 100 够用,多轨段落要 300+
num_beams5beam search 的束宽,越大越保守,重复也越明显
temperature0.7~0.9采样温度,0.7 偏稳,0.9 偏自由
top_p0.9核采样阈值,只保留累计概率前 90% 的候选
no_repeat_ngram_size2禁止 2-gram 重复,避免“同一个两音符组合无限循环”
early_stoppingTruebeam 生成时提前停,省时但不一定最优
prompt = """P:60 S:0 D:480 V:72 P:64 S:480 D:480 V:72 P:67 S:960 D:960 V:70""" input_ids = tokenizer.encode(prompt, return_tensors="pt").to(model.device) output = model.generate( input_ids, max_new_tokens=200, do_sample=True, temperature=0.8, top_p=0.9, no_repeat_ngram_size=2, ) generated = tokenizer.decode(output[0], skip_special_tokens=True) print(generated)

注意一个隐藏规则:num_beams大于 1 时,temperature和top_p通常不生效,两者是互斥路径。想采样式生成就do_sample=True并去掉 beams。这段代码里我用了max_new_tokens而不是max_length,两者区别是前者只限制新生成的 token,不会连带算 prompt 长度,批量生成时更不容易因为 prompt 长短不一而截断。

4. 让模型吃数据:清洗、特征提取与训练配置

生成效果的瓶颈往往不在模型,而在数据。文档里花了大量篇幅在数据预处理上,这是对的——MIDI 来源五花八门,有的带一堆控制事件,有的鼓轨和旋律轨混在一起,有的 tempo 乱标。模型学习的是数据里的统计规律,垃圾进垃圾出,DeepSeek 再强也救不回脏数据。

4.1 数据清洗:去冗余、统刻度、修正错音

清洗分三步:去除冗余事件、统一格式、修正错误音符。冗余事件最常见的是连续重复的 control_change,比如延音踏板每个 tick 都在发 64 号控制事件,删掉不影响听感。统一格式的核心是 ticks_per_beat 归一化,上面提过,统一到 480。修正错误音符包括音高越界、时长为 0、力度为 0 但仍然挂着 note_on 的事件。

一段可落地的清洗函数:

import mido def clean_midi(input_path, output_path, target_tpb=480): mid = mido.MidiFile(input_path) mid.ticks_per_beat = target_tpb cleaned_tracks = [] for track in mid.tracks: new_track = [] last_msg = None for msg in track: if msg.type == 'control_change' and last_msg == msg: continue # 连续重复的同值控制事件,丢弃 if msg.type == 'note_on' and msg.velocity == 0: msg.type = 'note_off' # 统一 note_on(0) 到 note_off if msg.type == 'note_on' and not (0 <= msg.note <= 127): continue new_track.append(msg) last_msg = msg cleaned_tracks.append(new_track) mid.tracks = cleaned_tracks mid.save(output_path)

代码里两个容易踩的点:last_msg == msg判断的是“同类型且同值”,mido 的 Message 对象支持这种相等比较,不用手动逐字段对比;把 velocity=0 的 note_on 改写成 note_off 是标准做法,但注意有些文件会用 note_on(0) 表示“触发一个无声事件”,直接改会破坏轨道语义,稳妥做法是先统计一下这种行为占比再决定。

4.2 特征提取:音高、时长、力度与和弦

清洗完的特征提取决定模型能看到什么。单个音符至少提取三个特征:音高、时长、力度。除此之外,节奏特征最好也要,因为音乐不是孤立的音符串,而是有重音和节拍的。文档里提到把音符转成节拍类型(2/4、3/4、4/4)以及节奏型分类,实操中这些可以从 time_signature 和音符起始位置的模数关系推算出来。

和弦特征更适合在段落级别提取,而不是逐音符嵌入。我的做法是用滑动窗口把同一时刻(±30 tick)内的音符聚成一个和弦,再判断它属于哪种和弦类型。这步用 pretty_midi 比手写方便:

import pretty_midi pm = pretty_midi.PrettyMIDI('example.mid') for instrument in pm.instruments: notes = instrument.notes # 按起始时间排序后,聚类同时发声的音符 chord_candidates = [] for note in notes: chord_candidates.append((note.pitch, note.start, note.end)) print(f"轨道 {instrument.program}, 音符数 {len(chord_candidates)}")

pretty_midi把 MIDI 解析成了更接近音乐语义的对象,instrument.notes直接就是带 start/end 的音符列表,不用自己配对 note_on/note_off。它的代价是解析速度比 mido 慢,但特征提取阶段无所谓性能,准确性更重要。要注意instrument.program是音色编号,0 是钢琴、40 是小提琴,不同程序号的轨道语义完全不同,特征提取和分析要按轨道分别做。

4.3 微调配置:损失函数、优化器与训练循环

数据准备好了,接下来是微调。DeepSeek 本身是通用模型,直接生成音符文本能出活,但要稳定产出特定风格(比如“16 世纪圣咏”或“日系游戏配乐”),就得在自有 MIDI 数据集上做有监督微调。任务定义为标准语言建模:输入一段音符文本前缀,预测下一个 token。

损失函数用交叉熵,优化器用 AdamW,学习率从 2e-5 起步,batch size 按显存定,一般 4~8。用 transformers 的Trainer最省心:

from transformers import TrainingArguments, Trainer, AutoModelForCausalLM training_args = TrainingArguments( output_dir="./midi-deepseek-finetuned", num_train_epochs=3, per_device_train_batch_size=4, per_device_eval_batch_size=4, learning_rate=2e-5, evaluation_strategy="epoch", save_strategy="epoch", logging_dir="./logs", fp16=True, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, ) trainer.train()

evaluation_strategy="epoch"的意思是每个 epoch 结束在验证集上测一次困惑度,用来判断过拟合;fp16=True是半精度训练,显存不够时必须开。数据集构造要注意:文本化之后每条样本是一首曲子的完整编码,不能简单按 token 截断,否则模型会学到“半截音符”。我一般按“完整旋律句”切分,保证每段样本都有完整的乐句边界。

训练时盯两个指标:训练 loss 和验证 loss。训练 loss 降而验证 loss 不降,说明过拟合了,加大数据量或调低 epoch;两个 loss 都纹丝不动,基本可以断定数据编码有问题,回去查文本化环节。

5. 完整生成链路与排查:五个翻车点,一次给足后悔药

数据、模型、生成参数都准备完后,把整条链路串起来就只剩最后一步:生成事件序列并还原成 .mid 文件。这一段是翻车高发区,我把自己踩过的坑逐一列出来,每一条都是现象、原因、解决三件套。

5.1 生成到落盘:事件序列如何还原成 .mid

先看完整流程:构造 seed prompt → 模型生成 → 解析文本 → 构造 MIDI 事件 → 保存文件。seed 可以是一段旋律,也可以是一串和弦进行。生成结果如果格式正确,文本里就是一行行的P:x S:y D:z V:v,解析成正则就能还原事件:

import re import mido def parse_generated_text(text): events = [] pattern = re.compile(r"P:(\d+) S:(\d+) D:(\d+) V:(\d+)") for line in text.splitlines(): m = pattern.search(line) if m: pitch, start, dur, vel = map(int, m.groups()) events.append((pitch, start, dur, vel)) return events def render_midi(events, output_path, tpb=480): mid = mido.MidiFile(ticks_per_beat=tpb) track = mido.MidiTrack() last_time = 0 for pitch, start, dur, vel in sorted(events, key=lambda x: x[1]): delta = start - last_time track.append(mido.Message('note_on', note=pitch, velocity=vel, time=delta)) track.append(mido.Message('note_off', note=pitch, velocity=0, time=dur)) last_time = start mid.tracks.append(track) mid.save(output_path)

这里有个简化处理:note_off的time直接填 duration,两者之和就是绝对时间轴,前提是事件已经按 start 排好序。parse_generated_text里的正则只匹配数字,任何模型吐出来的多余字符都会被忽略,这是第一道防线。render 阶段把音符事件转成 note_on/note_off 序列,last_time维护当前绝对位置。

5.2 翻车点一:生成文本里混进乱码

现象:模型生成的内容里有P:60 S:abc、Dur:这类半截字段,解析器直接报错或丢弃大半音符。原因:模型在训练时没见过严格格式化的音符文本,自由生成阶段容易跑偏。解决:加规则约束。先正则抽字段,再有选择地丢弃不完整行,而不是报错退出。如果一行里四个字段缺一个,丢弃整行;解析后音符数如果少于 seed 长度的 1/10,视为生成失败,重新采样一次。

5.3 翻车点二:note_on 和 note_off 配不上对

现象:还原出来的 MIDI 播放时持续出现刺耳的长音,或者音符密集到像在放杂音。原因:模型生成的事件里 note_off 缺失、时长字段异常大,渲染时事件顺序错乱。解决:渲染时加保险逻辑——强制所有 note_off 的 time 不超过 480×16(16 拍),超过就截断;如果一个音符的 note_on 在序列里没配到 note_off,在下一个 note_on 到来前自动补一个 note_off。两种方式都能拦住“无限延音”的灾难。

5.4 翻车点三:生成的曲子忽快忽慢

现象:播放速度完全失控,有时一个音符拖十几秒,有时一把音符一秒钟全放完。原因:训练数据里 tempo 事件没有被统一,不同文件的 ticks_per_beat 不一致,导致同一段文本在不同文件里时长语义差好几倍。解决:清洗阶段强制所有数据统一到 480 tick,并删掉或重写每个文件里的 tempo 事件,把它固定成你想要的 BPM。文本化时不要编码 tempo 变化,让模型专注音符序列,速度由渲染阶段统一控制。

5.5 翻车点四:损失降了,听感原地踏步

现象:训练 loss 一路向下,验证 loss 也正常,但生成结果听来听去都是同一套和弦模板,换个 seed 也只在小范围内变化。原因:数据集风格单一,比如全是 C 大调钢琴曲,模型学到的分布太窄;也可能生成参数太保守,beam search / 低温度把多样性压没了。解决:先扩充数据多样性,至少混入不同调号、不同速度、不同乐器轨的数据;再调生成侧,temperature=0.9、top_p=0.95,必要时在每 16 拍截断处随机换调。

5.6 翻车点五:本地推理显存 OOM

现象:模型加载完还能跑,一生成就报 CUDA out of memory。原因:max_new_tokens=200时,KV cache 占用随序列长度线性增长,prompt 又长,显存直接爆掉。解决:先用torch.float16,再压max_new_tokens到 128,batch 设为 1;还不够就用 vLLM 或 llama.cpp 这类框架做量化。如果想省事,直接把长 prompt 截到前 128 个 token,短旋律生成足够用,代价是丢失一点点上文信息。

6. 效果验证与迭代:从“能生成”到“能听”的三个杠杆

生成出 MIDI 只算跑通了一半,另一半是“怎么知道它好不好、怎么让它更好”。文档里给了三个评估维度:结构合理性、风格相似度、情感表达准确性。前两个可以量化,第三个主要靠耳朵。

结构合理性我用的指标是音符间距分布和重复率。合理的曲子音符间距不会全是同一种长度,重复率太高说明模型陷入循环。风格相似度可以训练一个简单分类器,把原数据集和生成集分别打标签,看分类器的区分度;区分度越低说明生成越接近原风格。这个做法不用主观判断,适合批量验证。

耳朵验收的常规路子是把 MIDI 合成成 WAV 再听。fluidsynth 是最省事的工具,一行命令完成合成:

fluidsynth -ni SoundFont.sf2 example.mid -F output.wav

SoundFont.sf2是音色库文件,没有音色库可以用系统自带的替代,或者用 freepats 这类开源音色。合成这一步是必须的,因为直接听 MIDI 的软音源会掩盖很多事件层的问题,只有转成 WAV 才能暴露时长异常、音符重叠之类的毛病。从那以后我每次生成完都强制走一遍:解析文本数一遍音符量、fluidsynth 合成、然后只看音轨波形和音符分布图,先过客观关,再决定要不要人工细听。这套流程帮我过滤掉至少一半的“无效生成”,省下的调试时间远多于这点额外开销。

迭代时最有用的三个杠杆依次是数据增强、规则约束、生成参数对抗。数据增强最简单也最有效——把每首曲子移调、变速、随机裁剪再重组,等于白送三五倍训练样本。规则约束适合收尾:比如限制音高范围、强制在乐句边界落和弦,这些写进解析器比靠模型学更可靠。到最后你会在“模型自由发挥”和“规则兜底”之间找到平衡点,这个平衡点就是你的风格参数。希望这套链路帮到你,少走几段我走过的弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询