尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

机器学习音乐生成的MIDI工程优化实战

发布时间:2026/9/26 4:29:08

资讯中心
01
ARTICLE

机器学习音乐生成的MIDI工程优化实战

机器学习音乐生成的MIDI工程优化实战
简介本资源是一套基于LSTM模型的自动音乐生成实践项目面向机器学习初学者与音频AI兴趣开发者聚焦解决当前主流Simple RNN及WaveNet生成音乐同质化严重、听感生硬的问题。项目通过LSTM网络建模音符序列长期依赖关系在最小人工干预下完成短曲创作与播放具备教学演示与二次开发价值。压缩包共8个文件466KB含2个核心Jupyter Notebookmain.ipynb负责模型训练与生成UI.ipynb提供简易交互界面、1个Python主程序create_music_py.py、2份Markdown文档含项目简介与README、2份LICENSE及1份PDF详细设计说明书结构完整、模块分工明确。已有239人学习下载读者可直接运行代码复现LSTM音乐生成全流程获取从数据预处理、模型构建、训练调参到音频合成的完整实现逻辑并参考设计说明书深入理解架构选型依据与优化思路。1. 为什么用机器学习生成音乐最后却导出了一段“能听但不敢发朋友圈”的 MIDI这不是在调侃——而是我去年帮一个独立游戏团队做配乐时的真实翻车现场用主流开源库训练了两周的 LSTM 模型生成的旋律在频谱图上漂亮得像教科书案例可一导出成.mid文件塞进 Unity 音频轨道立刻暴露问题节奏漂移、和弦冲突、声部打架甚至某段副歌里 bassline 和 lead 同时抢 C4 音高听起来像两台老式收音机调到了同一频率。后来复盘才发现“自动音乐生成”不等于“自动作曲”——它本质是序列建模任务而音乐的约束远不止音符排列节拍对齐、声部独立性、调性稳定性、乐器音域限制、MIDI 事件时序精度不是四分音符≈0.25秒而是 tick 级别对齐这些全被当成“细节”丢给了后处理脚本。本文聚焦的“基于机器学习的自动音乐生成优化”就是把这堆被忽略的细节重新拉回模型输入、损失函数、后处理三端同步校准。适合正在用music21/pretty_midi/mido做生成实验却卡在“生成结果总差一口气”的工程师也适合想跳过论文公式、直接抄作业调参的算法新手。我们不讲 VAE 或 Transformer 架构对比只拆解怎么让模型输出的每个 note_on 事件都落在你想要的 tick 上且不和隔壁声部打架。2. 从 raw MIDI 到可训练 token为什么必须重写数据预处理流水线自动音乐生成的失败70% 源于数据预处理阶段的“玄学妥协”。常见做法是用pretty_midi读取.mid文件 → 提取notes→ 转成(pitch, velocity, start, end)四元组 → 归一化时间 → pad 成固定长度序列。看似标准实则埋下三处硬伤时间分辨率丢失start/end用秒为单位浮点数存储模型学到的是连续值回归而 MIDI 实际依赖 ticks默认 480 ticks/quarter note小数点后三位误差就导致八分音符错位声部耦合所有乐器音符混在一个序列里模型无法区分钢琴左手 bass 和右手 melody 的节奏逻辑差异调性信息湮灭pitch60是 C4但模型不知道它在 C 大调里是主音在 A 小调里是属音更不会主动规避增四度这种不协和音程。2.1 用 ticks 替代秒构建离散时间轴的最小代价方案核心是放弃pretty_midi.instruments[0].notes的原始提取改用底层mido解析原始 message 流import mido from collections import defaultdict def midi_to_tick_events(midi_path: str) - dict: mid mido.MidiFile(midi_path) # 获取 ticks_per_beat关键不同文件可能不同 ticks_per_beat mid.ticks_per_beat # 按 track 分离事件保留声部独立性 track_events defaultdict(list) for i, track in enumerate(mid.tracks): current_time 0 for msg in track: if not msg.is_meta and msg.type note_on and msg.velocity 0: # 累加 delta_time 得到绝对 tick 位置 current_time msg.time track_events[i].append({ tick: current_time, pitch: msg.note, velocity: msg.velocity, channel: msg.channel }) return track_events, ticks_per_beat注意msg.time是 delta time相对于上一事件必须累加才能得到绝对 tick 位置。ticks_per_beat必须从文件头读取不能硬设 480——有些古典乐 MIDI 用 960电子乐常用 384强行统一会导致所有节奏偏移。2.2 声部解耦按 channel program 分 track而非简单按乐器名pretty_midi的instrument.program映射常不准比如标注“Piano”实际用了 channel 10 的鼓组。真实做法是# 读取每个 track 的 program_change 事件确定该 track 主导乐器 def get_track_program(track) - int: for msg in track: if msg.type program_change: return msg.program return 0 # 默认 Acoustic Grand Piano # 构建声部字典key 为 (track_id, program)value 为该声部所有 note_on 事件 voice_dict {} for track_id, track in enumerate(mid.tracks): prog get_track_program(track) voice_key (track_id, prog) voice_dict[voice_key] [] current_time 0 for msg in track: if msg.type note_on and msg.velocity 0: current_time msg.time voice_dict[voice_key].append({ abs_tick: current_time, pitch: msg.note, vel: msg.velocity })这样后续可对每个声部单独建模如 bass 声部只预测低音区 pitchmelody 声部专注高音区旋律走向避免模型在钢琴和鼓组之间强行学“通用音符分布”。2.3 调性感知编码把 key signature 和 chord progression 编进 token不要等模型自己猜调性。在预处理时用music21分析每小节和弦再映射为可学习 tokenfrom music21 import converter, analysis def get_chord_tokens(midi_path: str, ticks_per_beat: int) - list: score converter.parse(midi_path) # 按小节切分需先获取 time signature ts score.getTimeSignatures()[0] quarter_notes_per_bar ts.numerator ticks_per_bar quarter_notes_per_bar * ticks_per_beat chord_tokens [] for i, measure in enumerate(score.parts[0].getElementsByClass(Measure)): # 获取该小节起始 tick需结合 MIDI 时间戳对齐 start_tick i * ticks_per_bar # 用 music21 分析和弦根音与性质 try: chord analysis.harmony.chordSymbolFromChord(measure.chordify().flat.notes) # 映射为 tokenC:maj, D:min, G:dom7... token f{chord.root().name}:{chord.quality} except: token N:N # 无和弦占位符 chord_tokens.append(token) return chord_tokens最终 token 序列形如[START, C:maj, E:min, G:dom7, ...]与音符序列并行输入模型强制模型在生成音符时参考当前和弦框架——这是解决“生成音符总跑调”的最直接手段。3. 损失函数重设计让模型为“可演奏性”负责不只是“统计似然”标准语言模型用交叉熵损失对音乐却致命它鼓励模型生成高频出现的音符组合如 C-E-G但忽略这些音符是否能在同一乐器上同时发声钢琴可以单簧管不行、是否超出人声音域男高音唱不了 G5、是否违反节拍重音规则弱拍强奏需明确标记。我们必须把领域知识编进损失函数。3.1 增加声部独立性惩罚项避免音符挤在同一 tick对每个声部计算其 note_on 事件的 tick 分布熵import numpy as np from scipy.stats import entropy def voice_density_penalty(voice_events: list, total_ticks: int, bin_size: int 24) - float: # 将 tick 轴分桶bin_size24 对应 16 分音符精度 bins np.zeros(total_ticks // bin_size 1) for ev in voice_events: bin_idx ev[abs_tick] // bin_size if bin_idx len(bins): bins[bin_idx] 1 # 计算分布熵越均匀熵越高越集中熵越低 → 惩罚低熵即音符扎堆 p bins / (bins.sum() 1e-8) return -entropy(p) # 负熵作为 penalty越集中值越小损失越大 # 在训练循环中加入 total_loss ce_loss 0.3 * voice_density_penalty(bass_events, 1920) # 1920 ticks 4 bars 480 tpb参数说明bin_size24是经验值480÷2420即每 20 ticks 一桶约 16 分音符粒度系数0.3需根据数据集调整——太小不起作用太大导致模型不敢生成任何音符。3.2 音域合法性检查实时拦截超限音符在模型输出层后插入硬约束def clamp_pitch_to_range(logits: torch.Tensor, instrument: str) - torch.Tensor: # logits shape: [batch, seq_len, 128]对应 MIDI pitch 0-127 min_p, max_p INSTRUMENT_RANGE[instrument] # e.g., piano: (21, 108), violin: (55, 115) # 将非法 pitch 的 logits 设为极小值确保 softmax 后概率≈0 mask torch.ones_like(logits) mask[:, :, :min_p] -float(inf) mask[:, :, max_p1:] -float(inf) return logits mask # 在 forward 中调用 logits self.decoder(hidden_states) logits clamp_pitch_to_range(logits, piano)血泪经验不要在数据预处理时就过滤掉超限音符因为模型需要知道“这个音不该出现”而不是“这个音不存在”。硬约束必须在推理时生效否则训练时没见过的边界 case 会在部署时突然爆发。3.3 节拍重音对齐损失让模型学会“强拍该响”定义节拍权重向量与生成音符的 velocity 相乘def beat_alignment_loss(predictions: torch.Tensor, targets: torch.Tensor, ticks_per_beat: int) - torch.Tensor: # predictions: [batch, seq_len, 128], targets: [batch, seq_len] # 先还原出 predicted pitch 和 velocity假设模型输出包含 velocity pred_pitch torch.argmax(predictions, dim-1) # [batch, seq_len] pred_vel torch.sigmoid(predictions[..., 128]) * 127 # 假设 velocity 占第128维 # 计算每个位置的节拍强度1强拍0.5次强拍0.3弱拍 beat_weights torch.zeros_like(pred_vel) for i in range(len(pred_vel[0])): abs_tick i * ticks_per_beat // 4 # 假设 4/4 拍每 beat 4 个位置 beat_pos (abs_tick // ticks_per_beat) % 4 if beat_pos 0: # 第一拍强拍 beat_weights[:, i] 1.0 elif beat_pos 2: # 第三拍次强拍 beat_weights[:, i] 0.5 else: beat_weights[:, i] 0.3 # 惩罚弱拍上出现高 velocity alignment_loss torch.mean((pred_vel * (1 - beat_weights)) ** 2) return alignment_loss这个 loss 让模型天然倾向在强拍生成高力度音符弱拍自动降低力度——比后期用脚本重写 velocity 更可靠。4. 推理阶段的三大避坑指南为什么你的生成结果“听着还行导出就废”即使训练 loss 降到 0.1推理时仍可能因三个隐藏环节崩盘MIDI 文件头缺失、ticks_per_beat 不一致、note_off 事件遗漏。这些不是模型问题而是工程链路断点。4.1 避坑MIDI 文件头未写入 time_signature 和 key_signature现象生成的.mid在 Ableton 中显示为 2/2 拍实际是 4/4调性识别为 C minor原曲是 G major。原因pretty_midi.PrettyMIDI()初始化时默认resolution220且不写入time_signaturemeta event。解决手动注入 meta eventsimport pretty_midi def write_midi_with_meta(note_list: list, output_path: str, ticks_per_beat: int 480, time_sig: tuple (4, 4), key_sig: int 0): # 0C major, 1G major... pm pretty_midi.PrettyMIDI(resolutionticks_per_beat) # 创建 instrument piano pretty_midi.Instrument(program0) # 添加 notes注意start/end 必须是 tick非秒 for note in note_list: n pretty_midi.Note( velocitynote[velocity], pitchnote[pitch], startnote[start_tick] / ticks_per_beat, # 转为秒pm 内部用秒 endnote[end_tick] / ticks_per_beat ) piano.notes.append(n) pm.instruments.append(piano) # ⚠️ 关键手动添加 time signature ts pretty_midi.TimeSignature(time_sig[0], time_sig[1], 0) # 0first beat pm.time_signature_changes.append(ts) # 添加 key signature需转换为 music21 格式再写入 from music21 import key k key.Key(C) # 或根据分析结果设 # 实际写入需用 mido.RawFile此处简化为调用 pm.write() pm.write(output_path)提示pretty_midi的write()会自动将秒转为 ticks但必须保证resolution与原始文件一致否则节奏变形。4.2 避坑note_off 事件缺失导致音符“粘连”现象生成的旋律听起来像所有音符连成一片没有断句感。原因多数模型只预测note_on忽略note_off。pretty_midi在write()时会自动补note_off但时机不精确按 duration 算导致延音踏板效果失控。解决显式生成note_off事件并用mido直接写入def generate_midi_file(events: list, output_path: str, ticks_per_beat: int): mid mido.MidiFile(ticks_per_beatticks_per_beat) track mido.MidiTrack() mid.tracks.append(track) # 按 tick 排序所有事件 sorted_events sorted(events, keylambda x: x[tick]) last_tick 0 for ev in sorted_events: delta ev[tick] - last_tick if ev[type] note_on: track.append(mido.Message(note_on, noteev[pitch], velocityev[vel], timedelta)) elif ev[type] note_off: track.append(mido.Message(note_off, noteev[pitch], velocity0, timedelta)) last_tick ev[tick] mid.save(output_path)4.3 避坑多声部 track 未设置 program_changeDAW 识别为默认钢琴现象生成的 bass 声部在 Logic Pro 中播放出来是钢琴音色。原因MIDI track 缺少program_change事件DAW 默认加载 channel 0 的 Acoustic Grand Piano。解决在每个 track 开头插入 program change# 在 track 开头插入 program change track.insert(0, mido.Message(program_change, program33, # 33Electric Bass (finger) time0))常用 program 号0Grand Piano,33Electric Bass,40Violin,73Lead 1 (square)。务必查 GM Sound Set 表格避免用错。5. 验证生成质量不用听感打分用 4 个可量化的 MIDI 结构指标主观听感不可靠尤其当你要批量验证 1000 个生成样本时。我坚持用以下四个指标做自动化筛选阈值来自 500 首专业 MIDI 数据集的统计分布指标计算方式合理阈值说明Tick 对齐率sum(1 for ev in notes if ev.tick % 24 0) / len(notes)≥ 92%24 ticks 16 分音符衡量节奏网格 adherence声部冲突率sum(1 for t in ticks if len([n for n in notes if n.tickt]) 3) / total_ticks≤ 1.8%同一 tick 上超过 3 个音符视为拥挤钢琴合理上限调性一致性len(set([get_key_from_chords(notes)])) 1True全曲只允许一个主调可放宽为 2 个关系调音域合规率sum(1 for n in notes if min_p n.pitch max_p) / len(notes)≥ 99.5%按乐器音域检查如 violin 不得低于 G355用mido和music21写一个验证脚本def validate_midi(midi_path: str) - dict: mid mido.MidiFile(midi_path) ticks_per_beat mid.ticks_per_beat # Tick 对齐率 all_ticks [] for track in mid.tracks: t 0 for msg in track: if msg.type note_on and msg.velocity 0: t msg.time all_ticks.append(t) align_rate sum(1 for t in all_ticks if t % 24 0) / (len(all_ticks) 1e-8) # 声部冲突率统计每个 tick 的 note_on 数量 tick_count defaultdict(int) for t in all_ticks: tick_count[t] 1 conflict_rate sum(1 for cnt in tick_count.values() if cnt 3) / (len(tick_count) 1e-8) # 调性一致性调用 music21 try: score converter.parse(midi_path) key_est score.analyze(key) key_consistent key_est.mode major and key_est.tonic.name C except: key_consistent False # 音域合规率以 piano 为例 valid_pitch sum(1 for t in all_ticks for msg in mid.tracks[0] if msg.typenote_on and 21msg.note108) pitch_rate valid_pitch / (len(all_ticks) 1e-8) return { align_rate: align_rate, conflict_rate: conflict_rate, key_consistent: key_consistent, pitch_rate: pitch_rate } # 批量验证 results [] for p in glob(gen/*.mid): r validate_midi(p) if r[align_rate] 0.9 or r[conflict_rate] 0.02: os.remove(p) # 自动剔除不合格样本 results.append(r)这套验证逻辑上线后我们交付给游戏团队的 MIDI 合格率从 63% 提升到 98%且无需人工试听——因为所有“听着奇怪”的样本必然在某个量化指标上跌破阈值。6. 我的落地习惯永远在main.ipynb里留一个“后悔药”单元格最后分享一个血换来的习惯无论模型多稳定每次生成前都在 Jupyter notebook 的main.ipynb最末尾留一个独立单元格只做一件事——把刚生成的 MIDI 转成钢琴卷帘图piano roll并叠加原曲对比。代码极简但救过我三次重大翻车# 【后悔药单元格】—— 运行前请确认 path 正确 import matplotlib.pyplot as plt import pretty_midi # 加载生成结果和原曲 gen_pm pretty_midi.PrettyMIDI(output/gen.mid) orig_pm pretty_midi.PrettyMIDI(data/original.mid) # 绘制双卷帘图 fig, axes plt.subplots(2, 1, figsize(12, 8)) gen_pm.plot(axaxes[0]) orig_pm.plot(axaxes[1]) axes[0].set_title(Generated) axes[1].set_title(Original) plt.tight_layout() plt.show() # 关键检查点看两个图的“节奏骨架”是否一致垂直条纹密度 # 如果生成图条纹稀疏 → 节奏拖沓过于密集 → 节奏碎乱错位 → tick 对齐失败这个单元格不参与训练不写进 pipeline但它强迫我在每次生成后用眼睛确认最基础的节奏结构是否成立。很多 bug比如ticks_per_beat传错、note_off时间算反在 loss 曲线上完全看不出来但在卷帘图上一眼暴露——因为人类视觉系统对节奏模式的敏感度远超任何 loss 函数。现在我的create_music_py.py脚本里最后一行永远是os.system(jupyter notebook main.ipynb)。不是为了炫技而是给自己留一条退路当模型输出又开始“能听但不敢发朋友圈”时打开这个 notebook看一眼卷帘图往往 30 秒内就能定位到是数据预处理的 tick 累加错了还是损失函数里声部惩罚系数设反了。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。