
1. MIDI文件基础从数字乐谱到程序化创作如果你玩过音乐制作或者对计算机如何生成音乐感到好奇那你一定绕不开MIDI。它不像MP3或WAV那样直接记录声音的波形更像是一份极其详尽的“数字乐谱”。这份乐谱里不包含任何实际的声音只记录了一连串的指令什么时间、哪个音符被按下、按下的力度多大、什么时候松开以及使用什么乐器音色等等。这种设计让MIDI文件体积非常小一个包含几分钟复杂交响乐的MIDI文件可能只有几十KB而同等时长的CD音质WAV文件则要几十MB。更重要的是这种指令式的结构让它天生就适合被程序读取和修改这为自动化音乐生成、算法作曲、音乐教育软件开发打开了大门。我最初接触MIDI是为了给一个简单的游戏项目添加动态背景音乐需要程序根据游戏事件实时改变音乐的节奏或旋律片段。直接用音频文件很难做到无缝切换和实时变奏而MIDI配合Python就成了绝佳的选择。市面上处理MIDI的Python库有好几个比如pretty_midi、python-midi已归档以及我们今天要深入探讨的mido。mido以其API设计清晰、文档友好、对标准MIDI文件SMF支持完善而备受青睐它让你能够以接近音乐人思维的方式用代码“演奏”和“创作”音乐。简单来说这篇内容就是为你准备的无论你是想用程序分析一首流行歌的和弦进行还是想批量处理一堆MIDI文件统一调性或是想尝试自动生成一些旋律片段你都能在这里找到从文件结构解析到实际代码操作的一条龙指南。我们会先彻底搞懂MIDI文件里到底装了啥然后手把手教你用mido库去读取、拆解、修改和创建它们。2. 深入拆解MIDI文件的结构与消息类型要熟练操作MIDI文件光知道它是“指令集”还不够得真正理解它的内部组织方式。一个标准的MIDI文件.mid由多个“轨道”Track组成你可以把它想象成交响乐的总谱第一提琴、第二提琴、长笛、双簧管各自拥有一个声部轨道。在MIDI文件中这些轨道是并行排列的。每个轨道又由一系列按时间顺序排列的“消息”Message构成。所有的音乐信息都封装在这些消息里。mido库的强大之处在于它用非常直观的Python对象代表了这些概念。在动手写代码之前我们必须先搞清楚这几类核心的消息这是理解后续所有操作的基础。2.1 音符开关与力度Note On/Off 消息这是MIDI的基石负责发声。一个音符的演奏包含两个动作按下和松开分别对应note_on和note_off消息。note_on: 告诉音源“开始演奏某个音符”。它主要包含三个参数note: 音符编号范围是0-127。例如中央CC4通常是60C#4是61以此类推。这个编号体系就是著名的“MIDI音符编号”。velocity: 力度范围也是0-127。它模拟了按下琴键的速度通常用来控制音符的音量大小和触键音色。127代表最强0代表最弱但注意note_on事件如果velocity0在某些音源中会被解释为note_off。channel: 通道0-15。用于区分不同的乐器或声部。比如你可以让通道0播放钢琴通道1播放弦乐。note_off: 告诉音源“停止演奏某个音符”。参数与note_on类似包含note,velocity释放力度通常用得少channel。在MIDI文件内部note_off消息并不总是必须的。为了节省空间常常用一个velocity0的note_on消息来替代真正的note_off。mido在读取时会帮你统一处理成note_off消息非常省心。2.2 音色控制Program Change 消息这条消息用于切换乐器音色。在MIDI标准中有128种基础音色GM标准比如0是原声大钢琴40是小提琴73是长笛等。program_change消息很简单主要参数就是program0-127和channel。它告诉音源“在这个通道上后续的音符请用第X号音色来演奏。”通常一个轨道的开头就会有一条program_change消息来设定本轨道的乐器。2.3 实时调控Control Change 消息这是MIDI的灵魂之一用于实时、连续地控制声音的各种参数让音乐充满表情。比如渐强渐弱、颤音、滑音等。control_change消息包含control: 控制器编号0-127。每个编号代表一种控制类型。最常用的有7: 通道音量Channel Volume10: 声像Pan控制声音在左右声道的平衡0最左127最右64居中1: 调制轮Modulation常用来制造颤音效果。11: 表情Expression常用于做乐句内的力度起伏。value: 控制值0-127。channel: 通道。2.4 弯音与系统消息Pitchwheel 与 Meta Messagespitchwheel: 弯音消息。用来制造滑音效果比如吉他上的推弦。它的值范围是-8192到81910代表没有弯音。meta messages: 元消息。这类消息不发送给音源而是用来存储关于乐曲本身的信息比如速度、拍号、调号、歌词、轨道名称等。它们对播放和音乐逻辑至关重要。例如set_tempo: 设置微秒每四分音符µs per quarter note。这是MIDI中定义速度的方式。数值越小速度越快。120 BPM对应的值通常是500000。time_signature: 设置拍号如4/4拍。key_signature: 设置调号如C大调、a小调。track_name: 给轨道命名。end_of_track: 标志一个轨道的结束。注意mido在处理时间上非常巧妙。在MIDI文件中消息并不是存储“绝对时间点”而是存储“距离上一条消息的间隔时间”delta time单位是“ticks”滴答数。而每四分音符有多少个ticks是由文件头里的“PPQN”Pulses Per Quarter Note决定的。mido在读取时默认会将这个delta time以ticks为单位作为消息对象的time属性。在编程时我们需要时刻注意这个time是相对时间。3. 环境准备与mido库核心操作解析现在我们开始进入实战环节。首先确保你的Python环境已经就绪然后通过pip安装mido库。如果你需要播放或生成实际的音频可能还需要安装python-rtmidi用于实时MIDI端口通信或配合其他音频库如fluidsynth软合成器来发声。但对于文件的读写和编辑mido本身已经足够。pip install mido3.1 读取与遍历打开MIDI文件的黑盒使用mido.MidiFile()可以轻松加载一个MIDI文件。我们来一步步拆解一个文件。import mido # 1. 加载MIDI文件 midi_file mido.MidiFile(your_song.mid) # 2. 查看文件基本信息 print(f文件类型: {midi_file.type}) # 0单轨1多轨同步2多轨异步 print(f轨道数: {len(midi_file.tracks)}) print(fPPQN (每四分音符滴答数): {midi_file.ticks_per_beat}) # 3. 遍历所有轨道和消息 for i, track in enumerate(midi_file.tracks): print(f\n 轨道 {i}: {track.name} ) for msg in track: print(msg)运行这段代码你会看到终端打印出海量的消息行。这就是MIDI文件的“源代码”。一开始可能会觉得眼花缭乱但结合我们上一节学到的消息类型你就能慢慢看懂了。比如你可能会先看到一条meta_message set_tempo500000接着是一条program_change channel0 program0将通道0的音色设为钢琴然后就是一系列的note_on/note_off消息了。track.name属性来自轨道内的meta_message track_namemido为我们做了便捷的封装。3.2 消息过滤与信息提取找到你想要的内容直接打印所有消息效率低下。我们通常需要针对性地提取信息。# 提取所有音符消息note_on, note_off并转换为开始时间结束时间音符力度的列表 def extract_notes(midi_file): notes [] current_time 0 # 当前绝对时间单位ticks active_notes {} # 字典记录正在播放的音符{note_number: start_time} for track in midi_file.tracks: current_time 0 # 每个轨道时间独立计算对于Type 1文件通常只有一个轨道有音符 for msg in track: current_time msg.time # 累加delta time得到绝对时间 if msg.type note_on and msg.velocity 0: # 记录音符开始 active_notes[msg.note] current_time elif msg.type note_off or (msg.type note_on and msg.velocity 0): # 音符结束生成一条记录 if msg.note in active_notes: start_time active_notes.pop(msg.note) duration current_time - start_time notes.append({ start: start_time, end: current_time, note: msg.note, velocity: msg.velocity, channel: msg.channel }) # 按开始时间排序 notes.sort(keylambda x: x[start]) return notes # 查找特定乐器的轨道 def find_track_by_instrument(midi_file, program_num): for i, track in enumerate(midi_file.tracks): for msg in track: if msg.type program_change and msg.program program_num: print(f乐器 {program_num} 位于轨道 {i}) return i return -1这个extract_notes函数是一个经典工具它将离散的note_on/note_off消息转换成了更容易分析和处理的“音符事件”列表包含了起止时间和力度这对于音乐分析至关重要。3.3 修改与创作编辑已有文件与从头构建修改现有文件思路是读取-修改消息列表-保存。# 将乐曲整体升调提高半音 mid mido.MidiFile(input.mid) for track in mid.tracks: for msg in track: if msg.type in [note_on, note_off]: msg.note 1 # 所有音符编号1即升半音 # 确保音符编号仍在有效范围内 if msg.note 127: msg.note 127 # 保存新文件 mid.save(output_transposed.mid)从头创建MIDI文件这是最体现创造力的部分。你需要自己构建轨道添加消息并精心安排时间。from mido import MidiFile, MidiTrack, Message, MetaMessage # 1. 创建MIDI文件和轨道 mid MidiFile() track MidiTrack() mid.tracks.append(track) # 2. 设置轨道名称和速度元消息 track.append(MetaMessage(track_name, nameMy Piano Track, time0)) track.append(MetaMessage(set_tempo, tempomido.bpm2tempo(120), time0)) # 120 BPM # 3. 选择乐器Program Change track.append(Message(program_change, program0, channel0, time0)) # 4. 添加音符演奏一个C大调和弦C, E, G # 同时按下三个音 notes [60, 64, 67] # C4, E4, G4 for note in notes: track.append(Message(note_on, notenote, velocity64, channel0, time0)) # 经过4拍一个全音符后松开。假设PPQN4804拍就是 4 * 480 1920 ticks for note in notes: track.append(Message(note_off, notenote, velocity64, channel0, time1920)) # 注意最后一个note_off的time是相对于上一个消息的。这里写法是同时松开。 # 5. 添加一个控制信息在音符持续期间让音量渐弱 track.append(Message(control_change, control7, value100, channel0, time0)) # 初始音量 track.append(Message(control_change, control7, value50, channel0, time960)) # 两拍后音量减半 # 6. 标记轨道结束 track.append(MetaMessage(end_of_track, time0)) # 7. 保存 mid.save(my_creation.mid) print(f文件已保存长度: {mid.length} 秒)实操心得在创建消息时time参数的处理是关键也是最容易出错的地方。它代表的是距离上一条消息的延迟时间。上面的例子中第一个note_on的time0表示立即执行第二个note_on的time0表示紧接着上一条消息执行从而实现“同时按下”。而note_off的time1920表示在最后一个note_on之后等待1920个ticks再执行。这种相对时间的系统需要仔细规划。4. 高级应用与实战项目思路掌握了基础读写和创建后我们可以尝试一些更实用的项目这些才是mido库真正发挥威力的地方。4.1 批量处理与自动化以转调为例假设你有一文件夹的MIDI伴奏需要全部从C调转到G调升5个半音。手动操作费时费力用Python几行代码就能搞定。import os from pathlib import Path import mido def transpose_folder(input_folder, output_folder, semitones): 将输入文件夹内所有.mid文件进行转调保存到输出文件夹 input_path Path(input_folder) output_path Path(output_folder) output_path.mkdir(parentsTrue, exist_okTrue) for midi_file in input_path.glob(*.mid): try: mid mido.MidiFile(midi_file) for track in mid.tracks: for msg in track: if msg.type in [note_on, note_off]: new_note msg.note semitones # 处理移调后超出范围的情况可以选择截断或八度移位 if new_note 0: new_note 0 elif new_note 127: new_note 127 msg.note new_note output_file output_path / f{midi_file.stem}_transposed{semitones}.mid mid.save(output_file) print(f已处理: {midi_file.name} - {output_file.name}) except Exception as e: print(f处理文件 {midi_file.name} 时出错: {e}) # 使用示例 transpose_folder(./original_midis, ./transposed_midis, 7) # 升纯五度4.2 音乐分析与可视化统计音符分布我们可以分析一首曲子中最常使用的音符或者绘制它的“钢琴卷帘窗”示意图这对于音乐风格研究或辅助作曲很有帮助。import matplotlib.pyplot as plt from collections import Counter def analyze_note_distribution(midi_file_path): mid mido.MidiFile(midi_file_path) note_counter Counter() for track in mid.tracks: for msg in track: if msg.type note_on and msg.velocity 0: note_counter[msg.note] 1 # 将MIDI编号转换为音名 note_names [] counts [] for note in range(21, 109): # 大致对应钢琴键盘A0到C8 count note_counter.get(note, 0) if count 0: # 一个简单的音名转换函数示例未实现完整逻辑 note_name midi_number_to_name(note) # 需要自己实现或使用第三方库 note_names.append(note_name) counts.append(count) # 绘制柱状图 plt.figure(figsize(15, 5)) plt.bar(note_names, counts) plt.xlabel(Note) plt.ylabel(Frequency) plt.title(Note Distribution in the Piece) plt.xticks(rotation90) plt.tight_layout() plt.show() # 打印最常用的10个音符 print(Top 10 most frequent notes:) for note, count in note_counter.most_common(10): print(f MIDI {note}: {count} times)4.3 简单算法作曲生成随机旋律我们可以利用一些简单的规则如限定音阶、节奏模式来让程序生成一段基本的旋律。这只是一个起点更复杂的算法可以引入马尔可夫链、神经网络等。import random def generate_random_melody(output_path, scale_notes, num_notes16, ppqn480): 在指定的音阶内生成随机旋律 mid MidiFile(ticks_per_beatppqn) track MidiTrack() mid.tracks.append(track) track.append(MetaMessage(set_tempo, tempomido.bpm2tempo(100), time0)) track.append(Message(program_change, program0, channel0, time0)) # 钢琴 current_time 0 # 定义几种可能的节奏长度单位ticks rhythm_patterns [ppqn//4, ppqn//2, ppqn, ppqn*2] # 16分音符8分4分2分 for _ in range(num_notes): # 随机选择音阶内的一个音符 note random.choice(scale_notes) # 随机选择一种节奏作为音符时值 note_duration random.choice(rhythm_patterns) # 随机选择一种节奏作为音符间隔 note_gap random.choice([0, ppqn//8, ppqn//4]) # 可以连奏也可以有短间隔 # 添加 note_on track.append(Message(note_on, notenote, velocityrandom.randint(60, 100), channel0, timenote_gap)) # 添加 note_off track.append(Message(note_off, notenote, velocity64, channel0, timenote_duration)) track.append(MetaMessage(end_of_track, time0)) mid.save(output_path) print(f随机旋律已生成: {output_path}) # 使用示例生成C大调音阶内的旋律 c_major_scale [60, 62, 64, 65, 67, 69, 71, 72] # C4 to C5 generate_random_melody(random_c_major.mid, c_major_scale)5. 常见问题、排查技巧与性能优化在实际操作中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和总结的排查思路。5.1 文件读取与保存问题问题读取MIDI文件时抛出异常ValueError: invalid variable-length integer。排查这通常意味着MIDI文件本身已损坏或者不是标准的MIDI文件可能被错误地重命名了其他音频文件。用十六进制编辑器或专业的MIDI软件如MuseScore检查文件头是否正确。问题保存后的MIDI文件无法被音乐软件正常播放或播放时杂乱无章。排查检查end_of_track元消息确保每个轨道最后都有一条MetaMessage(end_of_track, time0)。mido在保存时会自动添加但如果你在修改中删掉了它可能会导致问题。检查时间 (time) 属性确认所有消息的time值都是非负整数。time是相对时间计算错误会导致节奏完全错乱。在创建文件时建议先按time0添加所有消息然后再用一个函数去计算并填充正确的时间差这样逻辑更清晰。检查通道 (channel)确保note_on,control_change等消息分配了正确的通道0-15。不同通道可以加载不同乐器。5.2 播放与音色问题问题用mido生成的文件在电脑上播放没有声音或者音色不对。排查确认有音源mido只处理MIDI指令不产生声音。你需要一个MIDI音源来播放。可以使用专业的数字音频工作站DAW如Reaper, FL Studio, Logic Pro导入MIDI文件。使用系统自带的软波表如Windows的Microsoft GS Wavetable Synth但音质通常较差。在Python中集成fluidsynth库加载一个SoundFont (.sf2) 音色库来渲染成音频。检查program_change消息确保在音符消息之前有正确的program_change消息来指定乐器。如果没有音源会使用默认音色通常是钢琴。检查音量通道音量Control Change 7和表情Control Change 11可能被设为0导致无声。尝试在轨道开头添加Message(control_change, control7, value100, channel0, time0)。5.3 性能与效率优化当处理大量MIDI文件或非常复杂的文件时比如包含数十个轨道、数万条消息性能可能成为问题。技巧1针对性遍历如果只需要读取特定信息如所有音符避免使用for msg in track打印全部而是像我们之前写的extract_notes函数那样只处理关心的消息类型。技巧2使用track的列表特性MidiTrack本质上是一个消息列表。对于复杂的修改可以先track list(track)将其转换为普通列表进行操作因为迭代过程中直接修改原列表有时会有问题修改完成后再赋回去mid.tracks[0] MidiTrack(track)。技巧3注意内存极端情况下一个MIDI文件可能非常大。如果遇到内存不足考虑流式处理逐轨道读取处理完一部分就释放一部分。5.4 时间与节奏处理进阶MIDI的时间系统基于ticks对于音乐计算来说有时不够直观。mido提供了一些辅助函数进行转换import mido # 假设 PPQN 480 ticks_per_beat 480 # 将时间秒转换为ticks seconds 2.5 tempo 500000 # 120 BPM 对应的微秒每拍 ticks mido.second2tick(seconds, ticks_per_beat, tempo) # 将ticks转换为秒 ticks 960 seconds mido.tick2second(ticks, ticks_per_beat, tempo) # 更常见的是我们根据BPM和拍子来计算ticks bpm 120 beats 2 # 两拍 # 先根据BPM计算tempo tempo mido.bpm2tempo(bpm) # 再计算ticks: ticks beats * ticks_per_beat ticks_needed int(beats * ticks_per_beat)处理多轨道、变速Tempo Change的MIDI文件时时间计算会变得复杂因为不同时刻的tempo可能不同。这时需要沿着时间线累加每条set_tempo消息带来的变化才能准确计算出任一时刻的绝对时间秒。对于这类高级需求可能需要自己维护一个时间线状态机。最后一个非常实用的小技巧如果你对生成的MIDI文件不满意但又不知道问题出在哪可以把它导入到像MuseScore免费开源这样的乐谱软件中。乐谱视图能非常直观地显示出音符、节奏、力度等信息比单纯看消息列表或听声音更容易定位逻辑错误比如音符时值不对、休止符缺失等。这相当于为你的MIDI代码提供了一个强大的“可视化调试器”。