Agogic: Performance-Timed Music Tokens for LLM-Native Text-to-Symbolic-Music Generation这组概念放在一起要解决的问题并不只是“让大语言模型能写 MIDI”。文本到符号音乐生成在技术上已经能生成结构正确的音符序列但生成的音乐往往缺少一种关键信息表演者在时间上留下的微差。Agogic 是音乐术语指通过速度的细微伸缩来塑造表现力Performance-Timed Music Tokens 则是把这种时间微差编码成离散 token 的方法。把两者放进 LLM-Native 的文本到符号音乐生成链路意思是让模型在预测音高、时值的同时也预测每个音符相对标准节拍提前或延后多少、实际持续多久、力度如何变化。下面会沿着一条完整链路拆解先理解为什么普通音符 token 不够再设计 performance-timed music token 的结构然后用 Python 写一个最小 tokenizer 示例接着讨论训练、验证、排错和工程落地。读完应该能回答三个问题表演时序如何离散化LLM 为什么适合直接预测这种 token生成结果该怎么验证和排查。1. 从符号音乐生成到 Performance-Timed Token先理解问题1.1 LLM 做文本到符号音乐生成的基本链路当前比较常见的文本到符号音乐生成链路是用户输入一段文本描述例如“一首 4/4 拍、速度 90、C 大调的安静钢琴曲”LLM 将文本编码为输入条件然后用自回归方式生成一串离散音乐事件 token。这串 token 经过解码变成 MIDI 或 MusicXML最后交给合成器渲染成可听的音频。这条链路能成立核心原因是符号音乐本身就是离散的音符有音高、有起始时间、有持续时间、有力度的 MIDI 速度值。这些信息都能映射成有限词表中的 token。LLM 天然擅长学习离散符号序列的统计规律所以文本到符号音乐生成在架构上并不突兀。但这里有一个容易被忽略的差别“符号正确”和“演奏正确”是两回事。普通 token 序列通常长这样[BOS] [TEXT] [BPM90] [TIME_SIG4/4] [NOTE_ON pitch60] [TIME_SHIFT0.5] [NOTE_OFF pitch60] ...音高对、时值对、节拍对但是所有音符都精确落在量化网格上。这样的音乐听起来像节拍器缺少人类演奏中的呼吸感。Agogic 方向想补的正是这一层信息。1.2 Agogic 时序在音乐术语里到底指什么“Agogic”来自音乐术语通常指通过轻微延长或缩短音值来强调某个音而不是通过加大力度。广义理解可以扩展到整个乐句的速度弹性某一拍的音符稍微早到一点某一句尾音稍微拖长一点形成类似说话时轻重缓急的效果。在 MIDI 层面这些效果表现为可测量的时间偏差。例如名义上的四分音符从 1.000 秒开始持续到 1.500 秒。真实演奏可能从 0.982 秒开始持续到 1.513 秒。也就是说onset 提前了 18msduration 延长了 13ms。单个音符的听感差异可能不明显但整段音乐持续累积后听众能明显感知到“有人在演奏”而不是“程序在播放”。这种时间偏差就是 performance-timed music tokens 要捕获的目标。它不是把 MIDI 转成连续音频波形而是把演奏过程中的时间微差转成离散符号让语言模型能直接预测。1.3 为什么普通离散音高 token 不够普通音乐 token 在表示时间时通常采用两种方式。第一种是固定网格量化。把时间切分成十六分音符或三十二分音符的网格所有音符起点和长度都必须落在网格上。这种方式简单但小于网格粒度的偏移会全部丢失。无论模型训练多久只要 token 表示不支持偏移就永远学不出微妙的 agogic 变化。第二种是在 token 之间插入 TIME_SHIFT 事件表示相对于上一个事件的时间跨度。TIME_SHIFT 可以量化到很小粒度但如果模型只预测名义时值同一个音符在乐谱上写多长生成出来就是多长依然缺少真实演奏中常见的伸缩。更本质的问题在于同一段乐谱可以有很多种合法演奏方式普通 token 训练出来的模型只能学到这些演奏方式的平均结果。平均之后偏移互相抵消速度变化趋于平滑最终产出的是“最安全”但也最无趣的机械音乐。要让模型学会生成“像人弹的”必须在 token 层面显式加入表演时序信息。1.4 核心思路把表演时序编码进 token 序列Agogic 方法的直接目标是把每个音符的表演时序参数离散化为独立 token与音高、时值 token 并列。常见做法是为每个音符增加三类信息ONSET_DELTA实际起始位置相对标准网格的偏移。DURATION_RATIO实际持续时间与名义时值的比例。VELOCITY力度等级。这些信息都是离散值所以可以直接放进现有 LLM 词表用 next token prediction 训练。这是它和连续回归方案最大的区别不需要额外的回归头不需要改变自回归训练方式所有预测仍然是一个分类问题。可以用一个表格对比普通符号 token 和 performance-timed token维度普通音符事件 tokenperformance-timed music token音高有有名义时值有有onset 相对网格偏移通常不支持有量化为 bin实际持续时间等于名义时值有用量化比例表示力度可选有训练复杂度较低稍高但仍是自回归序列核心思路并不复杂但后续的 token 结构设计、数据对齐和评估方式会决定这个方法能不能真正生效。2. 环境准备与依赖选型跑通最小生成管线需要哪些组件2.1 明确目标学习环境与生产环境要分开配置在动手前先分清目标否则容易陷入“装了一堆库最后不知道先跑什么”的误区。学习环境的目标是快速验证 tokenizer 和训练流程。通常用一台带 NVIDIA GPU 的机器或者 CPU 跑一个小型模型数据量可以很小重点是把代码链路跑通。生产环境的目标则要复杂得多数据版本管理、模型服务、并发控制、监控、回滚机制、tokenizer 与模型版本的一致性每一项都要额外考虑。环境主要用途依赖重点需要额外处理的点学习环境跑通 tokenizer小模型验证Python、PyTorch、pretty_midi、mido无开发环境调模型、调数据加 jupyter、tensorboard、debugger数据版本、模型实验记录生产环境对外提供服务模型服务框架、推理优化、监控并发、延迟、回滚、安全不建议一开始就在生产环境服务器上反复调试 tokenizer。先在本机把输入输出和误差范围确认清楚再往服务化方向走。2.2 Python、深度学习框架与音乐处理库基础环境建议使用 Python 3.10 或更高版本用虚拟环境隔离项目依赖。核心库包括torch训练和推理深度学习模型。transformers加载预训练模型、实现自回归生成。pretty_midi 或 mido读取和处理 MIDI 文件。music21用于乐谱层面的拍号、调号、音高理论校验。numpy、pandas数据处理和 token 序列统计。最小安装命令可以按这个顺序执行python -m venv venv source venv/bin/activate pip install torch pip install transformers pretty_midi mido music21 numpy pandas这里要注意torch 的安装方式会因为 CUDA 版本不同而变化。如果用的是 NVIDIA GPU先确认 CUDA 版本再选择对应的 PyTorch 安装命令如果只是学习验证CPU 版也够用。不要在没确认依赖版本的情况下直接生产环境部署。2.3 数据格式准备MIDI、MusicXML 与对齐问题MIDI 和 MusicXML 是符号音乐最常用的两种格式但对 performance-timed token 来说它们的信息价值完全不同。MIDI 是基于时间的音符事件流天然包含音符起止时间、力度、速度等参数。真实演奏生成的 MIDI 文件里每个音符的起始位置和持续时间都带有表演偏差这是训练 performance-timed token 最直接的数据来源。MusicXML 是乐谱表示记录的是谱面上的音符、时值、拍号、调号通常不包含真实演奏中的时间偏移。从 MusicXML 出发只能得到名义时值无法得到 agogic 信息。所以准备数据时第一件事是确认数据来源。如果只有乐谱文件需要先做对齐和标注或者找到对应的真实演奏 MIDI 版本。一个简单的检查方式是用 pretty_midi 读取文件统计音符数量和时间范围import pretty_midi pm pretty_midi.PrettyMIDI(example.mid) all_notes [] for inst in pm.instruments: if not inst.is_drum: all_notes.extend(inst.notes) print(notes:, len(all_notes)) print(start:, min(n.start for n in all_notes)) print(end:, max(n.end for n in all_notes))如果数据集中大量音符的 start 时间刚好落在固定等分点上可能说明数据被量化过agggic 信息已经丢失。这类文件不适合直接作为训练目标。2.4 模型选型从头训练、微调还是先做 tokenizer模型选型没有统一答案取决于数据量和硬件资源。常见方案有三种。方案一在现有音乐生成模型或通用语言模型基础上微调。优点是收敛快缺点是原模型词表里没有 performance token需要扩展 embedding 层并重新初始化新 token 的向量。方案二从头训练一个小型 decoder-only Transformer。优点是 tokenizer、词表、数据格式完全可控缺点是需要足够数据量和较长训练时间。方案三先不训练模型写一个固定规则或统计模型来生成 performance offset作为 baseline。它可以帮助验证 token 设计是否合理也能和后续 LLM 生成结果做对比。推荐顺序是先做 tokenizer 和数据管线再训练或微调模型。tokenizer 如果不正确后面所有实验都建立在错误基础上排查起来非常困难。3. 设计 Performance-Timed Music Token编码结构与参数含义3.1 最小 token 序列结构一个 performance-timed token 序列需要同时包含音乐符号信息和表演时间信息。最直接的序列结构是在每个音符事件后面紧跟它的表演参数 token[BOS] [TEXT] [TEMPO80] [NOTE_ON pitch60] [ONSET_DELTA-1] [DUR_RATIO1.02] [VELOCITY72] [NOTE_OFF pitch60] [TIME_SHIFT0.5] [NOTE_ON pitch64] [ONSET_DELTA0] [DUR_RATIO0.95] [VELOCITY80] [NOTE_OFF pitch64] ...其中NOTE_ON pitch60表示音高。ONSET_DELTA-1表示实际 onset 比标准网格提前一个 bin。DUR_RATIO1.02表示实际时值是名义时值的 102%。VELOCITY72表示力度值。NOTE_OFF pitch60表示音符结束。TIME_SHIFT0.5表示下一个音符相对当前时刻的名义时间跨度。这种编码方式很接近普通 MIDI token 序列只是在每个音符上增加了几个表演参数 token。对已有的 LLM 来说需要扩充词表并调整 embedding 维度训练方式仍然保持不变。3.2 从 MIDI 中抽取演奏时序信息要从 MIDI 中计算 ONSET_DELTA 和 DUR_RATIO必须先有“标准网格”。标准网格可以由 MIDI 文件中的拍号、速度和量化分辨率计算得到。以 pretty_midi 为例默认会将时间转换为秒但性能时序计算在 tick 域更稳定。假设使用每四分音符 480 tickPPQ480一个四分音符可以切成多个网格单元。对于每个音符计算当前小节内的位置找出它应该落在哪个网格点。用grid_tick round(start_tick / grid_size) * grid_size得到名义网格 tick。用onset_delta start_tick - grid_tick得到偏移。用duration_ratio actual_duration_tick / nominal_duration_tick得到时值比例。这里有一个关键点名义时值不一定是网格间距。同一个四分音符可以被写成两个八分音符也可以被延音线连接。如果直接从 onset 网格差推断名义时值容易出现错误。实际项目中应当先解析 MIDI 的拍号和小节结构再结合完整音符事件序列判断每个音符的名义时值。下面的示例只说明思路简化处理为基于四分音符网格。3.3 相对时间、绝对时间与 delta 编码的取舍在 token 序列设计中时间表示方式会影响模型学习难度和生成序列长度。绝对 tick 编码会把每个事件的绝对时间作为 token 值。优点是直接缺点是值域大曲目越长token 取值越分散模型很难学到稳定的时间分布。而且绝对时间对移调或速度变化不敏感同一个乐句换成快速度后token 分布完全不同。相对时间编码是主流做法也就是用一个 TIME_SHIFT token 表示距离上一个事件的时间跨度。TIME_SHIFT 通常使用离散的 duration token例如 0.125、0.25、0.5 拍。它比绝对时间稳定也能让模型学习节奏模式但 TIME_SHIFT 只表示名义时间不表示实际偏差。所以 performance-timed token 需要在 TIME_SHIFT 之外再增加 ONSET_DELTA 和 DUR_RATIO。TIME_SHIFT 决定乐谱层面的相对关系ONSET_DELTA 和 DUR_RATIO 决定演奏层面的细微变化。两者配合才能同时保证结构正确和听感自然。编码方式表示内容优点缺点绝对 tick每个事件的绝对时间实现简单值域大泛化差TIME_SHIFT相对上一事件的名义时间结构稳定适合节奏建模无法表达演奏偏差TIME_SHIFT ONSET_DELTA DUR_RATIO名义时间加实际偏差能表达 agogic 信息token 数增加词表更大3.4 偏移、时值比例、力度、演奏法参数速查表设计 performance token 时最关键的参数是量化粒度和边界。ONSET_DELTA 通常按固定毫秒数分档例如每 10ms 一档。如果 MIDI 数据来自高精度录音转写偏移分布可能在正负几十毫秒内如果来自量化过的文件偏移几乎全为 0。实际使用时要先统计训练数据中的 offset 分布再决定 bin 范围和间距。DUR_RATIO 建议使用离散比例而不是连续比例例如 0.80、0.85、0.90、0.95、1.00、1.05、1.10、1.15、1.20。这样能表达从 staccato 到 legato 的变化又不至于让词表过大。VELOCITY 是 MIDI 标准力度值范围 0 到 127。可以直接保留 128 档也可以压缩到 32 档甚至 16 档。压缩会损失力度细节但能减少训练负担。参数含义建议离散化粒度更细的影响粒度更粗的影响ONSET_DELTAonset 相对网格的偏移每 10-20ms 一档更真实但 token 数量和训练难度增加听感机械但序列更短DUR_RATIO实际时值 / 名义时值0.80 到 1.20间隔 0.05能表达更多 articulation丢失连断细节VELOCITY力度16-32 档力度更细腻风格信息弱ARTICULATION连奏、断奏等分类标签描述更明确但标注成本高通用但不够精确这些参数没有标准答案需要根据数据集的标注能力、MIDI 分辨率和模型容量来定。最忌讳的是直接套别人的超参数却不理解数据分布是否符合。4. 最小可运行示例把 MIDI 转成带表演时序的 token 流4.1 示例目标这个示例不训练模型只完成一件事读取一个 MIDI 文件提取音符计算每个音符的 onset 偏移和时值比例最后输出一组 performance-timed token。它用于验证 tokenizer 设计是否合理也为后续 LLM 训练提供数据格式。示例中假设 MIDI 文件采用 480 PPQ拍号为 4/4量化网格是十六分音符。如果你的 MIDI 文件结构不同需要调整 grid_size 和名义时值计算方式。4.2 定义音符事件数据类使用 Python 的 dataclass 保存每一个带表演时序信息的音符from dataclasses import dataclass dataclass class TimedNote: pitch: int velocity: int start_tick: int end_tick: int onset_grid_tick: int duration_grid_tick: int onset_delta_bin: int duration_ratio_bin: int def to_tokens(self, vel_bins: int 32): velocity_bin min(self.velocity // (128 // vel_bins), vel_bins - 1) return [ fNOTE_ON pitch{self.pitch}, fONSET_DELTA{self.onset_delta_bin}, fDUR_RATIO{self.duration_ratio_bin}, fVELOCITY{velocity_bin}, ]onset_delta_bin表示量化后的偏移档位duration_ratio_bin表示量化后的时值比例档位。后续将所有音符的 token 拼接起来就形成训练样本。4.3 用 PrettyMIDI 读取音符并计算网格偏移下面这段代码演示核心转换逻辑import pretty_midi PPQ 480 GRID_SIZE PPQ // 4 # 以十六分音符为网格一拍分为 4 份 def midi_to_timed_notes(midi_path, delta_bin_size10, ratio_binsNone): if ratio_bins is None: ratio_bins [0.80, 0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15, 1.20] pm pretty_midi.PrettyMIDI(midi_path) notes [] for inst in pm.instruments: if not inst.is_drum: notes.extend(inst.notes) notes.sort(keylambda n: n.start) timed_notes [] for note in notes: start_tick int(note.start * PPQ) end_tick int(note.end * PPQ) grid_tick round(start_tick / GRID_SIZE) * GRID_SIZE onset_delta_tick start_tick - grid_tick onset_delta_bin round(onset_delta_tick / delta_bin_size) # 简化处理假设名义时值等于 grid_size nominal_duration GRID_SIZE actual_duration max(end_tick - start_tick, 1) duration_ratio actual_duration / nominal_duration duration_ratio_bin min(ratio_bins, keylambda r: abs(r - duration_ratio)) timed_notes.append( TimedNote( pitchnote.pitch, velocitynote.velocity, start_tickstart_tick, end_tickend_tick, onset_grid_tickgrid_tick, duration_grid_ticknominal_duration, onset_delta_binonset_delta_bin, duration_ratio_binratio_bins.index(duration_ratio_bin), ) ) return timed_notes这里用round来计算网格 tick会丢失小于半个网格的离散信息。GRID_SIZE 越小得到的偏移越精细但 token 也越多。duration_ratio_bin使用最近邻方式映射到预设比例表边界值需要额外处理。4.4 生成 LLM 训练样本转换完成后把 TimedNote 转成 token 列表def build_training_sequence(timed_notes, max_notesNone): tokens [[BOS], [TEXT] piano piece] for note in timed_notes[:max_notes]: tokens.extend(note.to_tokens()) tokens.append(TIME_SHIFT0.5) tokens.append([EOS]) return tokens输出示例[[BOS], [TEXT] piano piece, NOTE_ON pitch60, ONSET_DELTA-1, DUR_RATIO1, VELOCITY18, TIME_SHIFT0.5, ...]这个文本序列可以直接作为训练数据经过 tokenizer 转换为 id 后送入 LLM。需要注意的是TIME_SHIFT 在示例中固定为 0.5实际项目应使用每个音符相对上一个音符的真实时间跨度。4.5 验证输出并反向还原生成 token 后必须验证几点音符数量是否一致、onset_delta 是否落在合理范围、duration_ratio 是否有极端值、token 是否能被还原为可播放的 MIDI。可以用断言和统计来做初步检查def validate_timed_notes(timed_notes): assert len(timed_notes) 0, no notes assert min(n.onset_delta_bin for n in timed_notes) -20 assert max(n.onset_delta_bin for n in timed_notes) 20 assert all(0 n.duration_ratio_bin len(ratio_bins) for n in timed_notes) print(validation ok) validate_timed_notes(timed_notes)在实际项目中还需要写一个从 token 恢复到 MIDI 的 decode 函数并把原始 notes 和还原后的 notes 做对齐比较。这样能发现 tokenizer 是否存在信息丢失。5. 训练与微调让 LLM 学会预测 performance-timed token5.1 训练目标仍是 next token prediction有了 token 序列训练方式和普通文本语言模型没有本质区别。模型输入一段 performance-timed token 序列预测下一个 token损失函数用交叉熵。为什么这种方法适合 performance token因为 ONSET_DELTA、DUR_RATIO、VELOCITY 都是离散值模型只需要在有限类别中做分类不需要预测连续数值。相比为偏移单独加一个回归 head自回归 token 预测更简单也更容易和其他音乐事件一起联合学习。训练循环可以用 Hugging Face Trainer 简化from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments model AutoModelForCausalLM.from_pretrained(your-base-model) tokenizer AutoTokenizer.from_pretrained(your-tokenizer-with-performance-tokens) training_args TrainingArguments( output_dir./exp, per_device_train_batch_size4, gradient_accumulation_steps8, num_train_epochs10, logging_steps50, save_steps500, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, ) trainer.train()如果你的基础模型词表里没有 performance token需要先扩展词表并重新随机初始化这些 token 的 embedding。否则模型会因为没有对应索引而报错。5.2 数据对齐与增强策略数据对齐是 performance-timed token 训练中最