1. 项目概述:从数字乐谱到可编程声音
如果你玩过音乐游戏,或者用过电子琴的自动伴奏功能,那你已经接触过MIDI了。它不像MP3或WAV那样直接记录声音的波形,而更像是一份极其详尽的“数字乐谱”。这份乐谱里不包含任何实际的声音,只记录了一系列指令:什么时间、哪个音符被按下(Note On)、力度多大、什么时候松开(Note Off),以及音色、音量、弯音等控制信息。这种设计让MIDI文件体积非常小,一个几分钟的复杂乐章可能只有几十KB,并且修改起来异常灵活——你可以轻松地把钢琴曲换成吉他音色,或者改变整个曲子的速度,而无需重新录制。
MIDI的全称是Musical Instrument Digital Interface(乐器数字接口),它诞生于上世纪80年代,初衷是为了让不同厂家的电子乐器能够互相“对话”。今天,它的应用早已超出专业音乐制作的范畴,渗透到游戏音效、手机铃声、多媒体演示乃至嵌入式系统的提示音中。对于程序员和创作者来说,MIDI的魅力在于它的“可编程性”。你可以用代码来生成、解析、修改这些乐谱数据,从而实现自动作曲、音乐可视化、算法生成背景音乐等创意项目。
Python的mido库,就是我们操作这份“数字乐谱”的瑞士军刀。它不像一些庞大的音频处理库那样复杂,而是专注于MIDI协议本身,提供了清晰、Pythonic的API来读写.mid文件和处理MIDI消息。无论是想批量修改一批MIDI文件的音量,还是从零生成一段旋律,抑或是做一个根据你的键盘打字节奏来生成音乐的趣味程序,mido都是一个绝佳的起点。接下来,我会带你深入MIDI的结构,并用mido库进行实战操作,分享一些我处理音乐数据时踩过的坑和总结的技巧。
2. MIDI文件结构深度解析
要熟练地用代码操作MIDI,不能只停留在“它是一组指令”的概念上,必须理解其内部是如何组织的。一个标准的MIDI文件(SMF)就像一部音乐剧的完整剧本,它包含了所有演员(音符)的出场时间、台词(音高)、情绪(力度)以及舞台灯光和布景的变换(控制信息)。
2.1 核心构成:轨道与消息
一个MIDI文件主要由两部分构成:轨道(Track)和消息(Message)。
你可以把整个MIDI文件想象成一个多轨录音机。文件头(Header Chunk)定义了全局信息,比如格式(是单轨、多轨同步还是多轨独立)、轨道数量以及时间基准(Ticks per quarter note, 即一个四分音符包含多少个“时间滴答”,这决定了时间的精度)。
紧接着的是一个个轨道块(Track Chunk)。通常,第一个轨道(Track 0)是特殊的,它不包含音符,只存放元消息(Meta Message),比如曲目标题、版权信息、速度(Tempo, 单位是微秒每拍)和拍号(Time Signature)等。从Track 1开始,才是真正的音乐数据轨道,比如钢琴轨、鼓轨等。
每个轨道由一系列按时间顺序排列的消息组成。这是MIDI的精髓。消息分为几大类:
- 通道消息(Channel Messages):这是最主要的部分,需要指定一个通道(0-15)。最常见的就是:
note_on: 音符开启。参数包括channel(通道),note(音高编号,60代表中央C),velocity(触键力度,0-127)。note_off: 音符关闭。参数包括channel,note,velocity(释放力度,通常也用来表现触感)。control_change: 控制改变。用于调制轮、音量(CC7)、声像(CC10)、延音踏板(CC64)等。参数是channel,control(控制器编号),value(值,0-127)。program_change: 音色改变。用来切换乐器音色,例如从钢琴(program 0)切换到弦乐(program 48)。
- 系统消息(System Messages):与特定通道无关,如系统专用信息(SysEx),常用于传输厂商特定的数据。
- 元消息(Meta Messages):如前所述,存放文本、速度、拍号、音轨结束等非演奏信息。
这里有一个关键概念:增量时间(Delta Time)。MIDI文件中的消息并不是存储绝对时间,而是存储距离上一条消息过去了多少“滴答数”(ticks)。这种相对时间的存储方式非常高效。例如,一条消息的time为120,意味着从上一条消息执行后,等待120个ticks再执行本条消息。
2.2 时间与节奏的量化:Ticks与Tempo
理解时间系统是避免音乐播放速度混乱的关键。文件头中的ticks_per_beat(PPQ)是分辨率。假设ticks_per_beat = 480,那么一个四分音符就占据480个ticks。
而实际播放速度由速度元消息(set_tempo)决定,它的单位是微秒每四分音符(microseconds per quarter note)。一个常见的默认速度是tempo = 500000,即50万微秒(0.5秒)一个四分音符,换算过来就是每分钟120拍(BPM = 60,000,000 / 500,000)。
所以,一条time=240的消息,在ticks_per_beat=480且tempo=500000的情况下,它的实际延迟时间是:(240 / 480) * (500000 / 1,000,000) = 0.5 * 0.5 = 0.25秒。
注意:很多新手会混淆
time字段的含义。在从文件读取时,message.time是增量时间(单位是ticks)。但在构建消息准备写入时,mido允许你直接使用time作为增量时间,或者使用MidiFile的tick相对时间。在实时播放或生成文件时,必须确保时间系统的一致性。
3. 使用mido库进行MIDI文件操作
安装mido非常简单:pip install mido。它还有一个可选依赖python-rtmidi用于实时MIDI端口输入输出,如果不需要实时功能,仅读写文件则不需要安装。
3.1 读取与解析:窥探MIDI的细节
让我们从一个简单的读取例子开始,看看如何获取文件的全局信息和遍历所有消息。
import mido # 1. 读取MIDI文件 midi_file = mido.MidiFile('example.mid') # 2. 打印文件头信息 print(f"文件类型: {midi_file.type}") print(f"轨道数: {len(midi_file.tracks)}") print(f"时间分辨率 (ticks per beat): {midi_file.ticks_per_beat}") # 3. 遍历所有轨道和消息 for i, track in enumerate(midi_file.tracks): print(f"\n--- 轨道 {i}: {track.name if hasattr(track, 'name') else '未命名'} ---") # 累计绝对时间(单位:ticks),用于分析 absolute_time = 0 for msg in track: absolute_time += msg.time # 格式化输出,显示绝对时间和消息内容 print(f"[{absolute_time:6d} ticks] {msg}")这段代码会输出类似这样的内容:
文件类型: 1 轨道数: 3 时间分辨率 (ticks per beat): 480 --- 轨道 0 --- [ 0 ticks] <meta message track_name name='Piano' time=0> [ 0 ticks] <meta message time_signature numerator=4 denominator=4 clocks_per_click=24 notated_32nd_notes_per_beat=8 time=0> [ 0 ticks] <meta message set_tempo tempo=500000 time=0> [ 0 ticks] <meta message end_of_track time=0> --- 轨道 1 --- [ 0 ticks] <meta message track_name name='Piano Right Hand' time=0> [ 0 ticks] <meta message program_change channel=0 program=0 time=0> [ 0 ticks] <control_change channel=0 control=7 value=100 time=0> [ 120 ticks] <note_on channel=0 note=60 velocity=90 time=120> [ 360 ticks] <note_off channel=0 note=60 velocity=64 time=240> ...通过遍历,你可以清晰地看到音乐是如何被“搭建”起来的。track.name属性通常由第一个track_name元消息设置。program_change消息将通道0的音色设置为0(通常是原声钢琴)。control_change消息将通道0的音量(CC7)设置为100。随后,在120 ticks后,中央C(note 60)以90的力度被按下,再经过240 ticks后松开。
3.2 创建与编辑:从零构建你的旋律
创建新的MIDI文件给了你最大的自由度。下面我们创建一个简单的C大调音阶。
import mido from mido import MidiFile, MidiTrack, Message, MetaMessage # 1. 创建MIDI文件和轨道 mid = MidiFile() track = MidiTrack() mid.tracks.append(track) # 2. 在轨道0(第一个轨道)设置元信息 # 设置轨道名称 track.append(MetaMessage('track_name', name='My Scale', time=0)) # 设置速度:120 BPM (500000 microseconds per beat) track.append(MetaMessage('set_tempo', tempo=500000, time=0)) # 设置拍号:4/4拍 track.append(MetaMessage('time_signature', numerator=4, denominator=4, time=0)) # 3. 选择音色(Program Change),0是原声钢琴 track.append(Message('program_change', channel=0, program=0, time=0)) # 4. 添加音符消息(C大调音阶:C4, D4, E4, F4, G4, A4, B4, C5) notes = [60, 62, 64, 65, 67, 69, 71, 72] ticks_per_note = mid.ticks_per_beat # 一个四分音符的长度,默认480 current_time = 0 for i, note in enumerate(notes): # 音符开启,力度100 track.append(Message('note_on', channel=0, note=note, velocity=100, time=current_time)) # 音符持续一个四分音符(480 ticks)后关闭 track.append(Message('note_off', channel=0, note=note, velocity=64, time=ticks_per_note)) # 重置当前时间为0,以便下个note_on紧接着上一个note_off发生 current_time = 0 # 5. 必须添加轨道结束标志 track.append(MetaMessage('end_of_track', time=0)) # 6. 保存文件 mid.save('my_c_scale.mid')关键点解析:
time参数的理解:在append消息时,time参数表示距离上一条消息的延迟。在上面的循环中,第一个note_on的time=0表示立即触发。note_off的time=480表示在note_on之后480ticks触发。下一个note_on的time=0,意味着它紧跟着上一个note_off发生,从而形成连贯的音阶。- 必须添加
end_of_track:没有它,一些播放器可能无法正确识别文件结束。 - 通道选择:通道0-15,通常通道9保留给打击乐(在GM标准下)。其他通道可以自由分配不同音色。
编辑现有文件是另一个常见需求。例如,将所有音符的力度整体提高20%,但不超过127:
def boost_velocity(midi_file_path, output_path, factor=1.2): mid = MidiFile(midi_file_path) for track in mid.tracks: for msg in track: if msg.type == 'note_on' and msg.velocity > 0: # 注意velocity=0的note_on有时用作note_off new_vel = int(msg.velocity * factor) msg.velocity = min(new_vel, 127) mid.save(output_path)实操心得:在修改
note_on消息时,一定要小心处理velocity=0的情况。在MIDI标准中,一个力度为0的note_on事件等价于note_off事件。有些文件会用它来结束音符。如果你盲目地将所有note_on的力度都乘以系数,会把原本为0的力度(代表音符结束)也改变,导致音符无法正常结束,产生持续的鸣响。一个健壮的修改器应该判断if msg.type == 'note_on' and msg.velocity > 0。
3.3 高级操作:合并、切片与信息提取
合并多个轨道:如果你有两个独立的MIDI文件,想把它们的钢琴轨合并到一起,你需要处理时间同步问题。更简单的情况是在同一个文件内合并两个音乐轨道。
def merge_tracks(source_midi, track_index1, track_index2, output_path): mid = MidiFile(source_midi) track1 = mid.tracks[track_index1] track2 = mid.tracks[track_index2] # 创建一个新轨道,将所有消息按时间顺序合并是一项复杂任务。 # 更简单直接的方法:新建一个文件,将两个轨道作为独立轨道加入(类型需为1或2) new_mid = MidiFile(type=1, ticks_per_beat=mid.ticks_per_beat) # 添加元信息轨道 meta_track = MidiTrack() meta_track.extend([msg for msg in mid.tracks[0] if msg.is_meta]) # 复制原文件的元信息 meta_track.append(MetaMessage('end_of_track', time=0)) new_mid.tracks.append(meta_track) # 添加第一个音乐轨道 new_mid.tracks.append(track1) # 添加第二个音乐轨道 new_mid.tracks.append(track2) new_mid.save(output_path)提取旋律轮廓(音高序列):这对于音乐分析或机器学习非常有用。
def extract_note_sequence(midi_file_path, track_num=1, channel=0): """提取指定轨道和通道的音符序列(音高, 开始时间, 持续时间)""" mid = MidiFile(midi_file_path) track = mid.tracks[track_num] notes = [] # 存储 (note, start_tick, duration_tick) active_notes = {} # 记录正在响应的音符 {note_number: start_tick} current_tick = 0 for msg in track: current_tick += msg.time if msg.type == 'note_on' and msg.velocity > 0 and msg.channel == channel: # 记录音符开始 active_notes[msg.note] = current_tick elif (msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0)) and msg.channel == channel: # 音符结束,计算持续时间 if msg.note in active_notes: start_tick = active_notes.pop(msg.note) duration = current_tick - start_tick notes.append((msg.note, start_tick, duration)) return notes, mid.ticks_per_beat这个函数返回一个列表,包含了每个音符的音高、开始的绝对时间(ticks)和持续的ticks数。结合ticks_per_beat和tempo信息,你可以轻松地将它们转换成秒。
4. 实战应用场景与代码示例
掌握了基础操作后,我们可以看看mido能实现哪些有趣的应用。
4.1 场景一:自动生成随机旋律或伴奏
利用随机数生成符合音乐理论的简单模式。例如,生成一个在C大调内随机漫步的旋律:
import random import mido from mido import MidiFile, MidiTrack, Message, MetaMessage def generate_random_walk_melody(output_path, num_notes=50, key_root=60): """生成一个随机漫步旋律,限制在某个调式内""" # C大调音阶内的音高(相对于根音) scale_intervals = [0, 2, 4, 5, 7, 9, 11] # C, D, E, F, G, A, B mid = MidiFile() track = MidiTrack() mid.tracks.append(track) track.append(MetaMessage('track_name', name='Random Walk', time=0)) track.append(MetaMessage('set_tempo', tempo=500000, time=0)) track.append(Message('program_change', channel=0, program=48, time=0)) # 弦乐合奏 current_note = key_root + random.choice(scale_intervals) # 从调内随机一个音开始 for _ in range(num_notes): # 随机决定下一步:向上、向下或重复一个步进(-1, 0, 1) step = random.choice([-1, 0, 1]) current_index = (scale_intervals.index((current_note - key_root) % 12) + step) % len(scale_intervals) current_note = key_root + scale_intervals[current_index] # 随机力度和时长 velocity = random.randint(70, 110) duration = random.choice([120, 240, 480]) # 八分、四分、二分音符 track.append(Message('note_on', channel=0, note=current_note, velocity=velocity, time=0)) track.append(Message('note_off', channel=0, note=current_note, velocity=64, time=duration)) track.append(MetaMessage('end_of_track', time=0)) mid.save(output_path) generate_random_walk_melody('random_walk.mid')4.2 场景二:音乐可视化数据预处理
将MIDI文件转换为按时间排列的事件列表,供前端(如p5.js)或图形库(如matplotlib)绘制。
def midi_to_timeline(midi_file_path, resolution_ms=10): """将MIDI转换为一个时间线列表,每个时间单位包含当前正在发声的音符""" mid = MidiFile(midi_file_path) # 首先,我们需要解析出所有音符的起止时间(单位:毫秒) # 这是一个简化版本,假设整个文件只有一个速度 tempo = 500000 # 默认120 BPM for msg in mid.tracks[0]: if msg.type == 'set_tempo': tempo = msg.tempo break ticks_per_beat = mid.ticks_per_beat ms_per_tick = (tempo / 1000.0) / ticks_per_beat # 每tick的毫秒数 notes = [] # (start_ms, end_ms, note, velocity) active_notes = {} current_tick = 0 # 遍历所有轨道(跳过元信息轨道) for track in mid.tracks[1:]: current_tick = 0 for msg in track: current_tick += msg.time current_ms = current_tick * ms_per_tick if msg.type == 'note_on' and msg.velocity > 0: active_notes[msg.note] = (current_ms, msg.velocity) elif (msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0)): if msg.note in active_notes: start_ms, velocity = active_notes.pop(msg.note) notes.append((start_ms, current_ms, msg.note, velocity)) # 按开始时间排序 notes.sort(key=lambda x: x[0]) # 生成时间线(这里只是一个示例,输出每个时间片段内的音符数) if not notes: return [] end_time = max(note[1] for note in notes) timeline = [] t = 0 while t <= end_time: active_count = sum(1 for start, end, note, vel in notes if start <= t < end) timeline.append((t, active_count)) t += resolution_ms return timeline, notes # 使用matplotlib简单绘制一个钢琴卷帘视图 import matplotlib.pyplot as plt import matplotlib.patches as patches def plot_piano_roll(notes): """绘制简单的钢琴卷帘图""" if not notes: print("没有音符数据") return fig, ax = plt.subplots(figsize=(12, 6)) for start_ms, end_ms, note, velocity in notes: # 将音高转换为Y轴位置,时间转换为X轴 height = 1 width = end_ms - start_ms rect = patches.Rectangle((start_ms, note - 0.5), width, height, linewidth=1, edgecolor='black', facecolor=plt.cm.plasma(velocity/127.0), alpha=0.7) ax.add_patch(rect) ax.set_xlabel('时间 (毫秒)') ax.set_ylabel('音高') ax.set_title('钢琴卷帘视图') ax.set_ylim(30, 90) # 显示一个常见的音区 ax.grid(True, which='both', axis='y', linestyle='--', linewidth=0.5) plt.tight_layout() plt.show() # 使用 timeline, note_list = midi_to_timeline('example.mid') plot_piano_roll(note_list[:50]) # 只绘制前50个音符以免过于密集4.3 场景三:批量处理与文件格式转换
你可能有一堆游戏音效MIDI文件需要统一降低音量,或者想将Type 0(单轨)文件转换成Type 1(多轨)以便在DAW中编辑。mido非常适合这种脚本化的批量操作。
import os from pathlib import Path def batch_adjust_volume(input_folder, output_folder, volume_cc=7, multiplier=0.8): """批量调整MIDI文件的音量控制信息""" input_path = Path(input_folder) output_path = Path(output_folder) output_path.mkdir(parents=True, exist_ok=True) for midi_file in input_path.glob('*.mid'): try: mid = MidiFile(midi_file) for track in mid.tracks: for msg in track: if msg.type == 'control_change' and msg.control == volume_cc: msg.value = int(msg.value * multiplier) new_name = output_path / f"vol_adjusted_{midi_file.name}" mid.save(new_name) print(f"已处理: {midi_file.name}") except Exception as e: print(f"处理 {midi_file.name} 时出错: {e}") def convert_to_type1(input_file, output_file): """将MIDI文件转换为Type 1(多轨)格式。 注意:这是一个概念性示例。简单地将Type 0文件的所有消息塞进一个轨道并设为Type 1可能不准确, 因为Type 0所有通道消息都在一个轨道里。更严谨的做法需要按通道分离消息到不同轨道。""" mid = MidiFile(input_file) if mid.type == 0: # 创建一个新的Type 1文件 new_mid = MidiFile(type=1, ticks_per_beat=mid.ticks_per_beat) # 创建元信息轨道 meta_track = MidiTrack() # 尝试从原文件复制全局元信息(如速度、拍号) # 这里简化处理,直接使用默认或从第一个消息中提取 meta_track.append(MetaMessage('set_tempo', tempo=500000, time=0)) meta_track.append(MetaMessage('time_signature', numerator=4, denominator=4, time=0)) meta_track.append(MetaMessage('end_of_track', time=0)) new_mid.tracks.append(meta_track) # 将原Type 0的唯一轨道作为第一个音乐轨道加入 new_mid.tracks.append(mid.tracks[0]) new_mid.save(output_file) print(f"已转换并保存为: {output_file}") else: print("文件不是Type 0格式,无需转换。")5. 常见问题、排查技巧与性能优化
在实际使用mido处理MIDI文件,尤其是来源复杂、由不同软件生成的文件时,会遇到各种奇怪的问题。这里记录了一些典型坑点和解决方案。
5.1 文件读取与播放异常
问题1:读取文件时报错“无效的MIDI文件”或“损坏的文件头”。
- 排查:首先用十六进制编辑器或
xxd命令查看文件头几个字节。标准MIDI文件应以“MThd”开头。常见原因是文件被截断、或根本不是MIDI文件(可能是误改了扩展名)。 - 解决:尝试用专业音频软件(如Ableton Live, MuseScore)或MIDI编辑器打开并重新导出。如果文件来自网络,重新下载。
问题2:用某些播放器播放时无声或音色错乱。
- 排查:
- 检查音色号:
program_change消息的音色号(program)是否超出了GM(General MIDI)标准范围(0-127)。一些播放器只支持GM音色表。 - 检查通道:鼓组通常固定在通道10(GM标准下是通道9,因为从0开始计数)。如果你的音乐数据误用了通道10播放旋律,就会听到打击乐声。
- 检查音量:确认是否有
control_change消息将音量(CC7)设置为0,或表达式(CC11)过低。 - 检查音符结束:是否有
note_on消息没有对应的note_off消息,导致音符一直持续?可以用我们之前写的extract_note_sequence函数检查是否有音符的持续时间异常地长。
- 检查音色号:
- 解决:写一个诊断脚本,打印出所有非元消息,检查通道、音色、控制器值。
def diagnose_midi(file_path): mid = MidiFile(file_path) print(f"诊断文件: {file_path}") for i, track in enumerate(mid.tracks): print(f"\n轨道 {i}:") for msg in track: if not msg.is_meta: # 打印关键信息 print(f" {msg}") # 特别关注特定消息 if msg.type == 'program_change': print(f" -> 音色改变为: {msg.program}") elif msg.type == 'control_change' and msg.control in [7, 10, 11]: ctrl_name = {7: '音量', 10: '声像', 11: '表达式'}.get(msg.control, f'CC{msg.control}') print(f" -> {ctrl_name}: {msg.value}")5.2 时间与节奏处理陷阱
问题3:自己生成的MIDI文件播放速度不对,或者音符时长感觉不对。
- 原因:混淆了增量时间(Delta Time)和绝对时间(Absolute Time),或者错误理解了
ticks_per_beat与tempo的关系。 - 黄金法则:
- 读取时:
msg.time是距离上一条消息的增量ticks。 - 写入/构建时:当你
append一条消息,你设置的time参数是它距离你添加的上一条消息的延迟ticks。 - 计算实际时间:
实际秒数 = (累计ticks / ticks_per_beat) * (tempo / 1,000,000)。
- 读取时:
- 技巧:在构建复杂节奏时,可以先用绝对时间(以ticks为单位)计算好每个事件的时刻,再转换为增量时间列表。
def build_from_absolute_times(absolute_times, messages): """根据绝对时间列表和消息列表构建轨道""" track = MidiTrack() last_time = 0 for abs_time, msg in zip(absolute_times, messages): delta = abs_time - last_time msg.time = delta track.append(msg) last_time = abs_time track.append(MetaMessage('end_of_track', time=0)) return track问题4:合并或剪切MIDI片段后,开头有奇怪的停顿或速度突变。
- 原因:每个轨道开头可能有一个初始的
time值(即第一条消息的delta time),如果这个值很大,就会产生停顿。另外,速度元消息set_tempo可能被错误地复制或丢失。 - 解决:在合并轨道时,确保正确处理轨道开头的初始
time。通常,在拼接时,第二个轨道的第一个消息的time应该设为0(或根据需要调整),并将其delta time加到后续消息的时间上。同时,确保最终文件只有一个全局速度轨道(通常是第一个轨道),或者所有轨道有统一的速度设置。
5.3 性能与内存优化
问题5:处理非常大的MIDI文件(如包含整个交响乐乐谱)时速度慢或内存占用高。
mido在读取文件时会一次性将所有消息加载到内存的MidiTrack对象中。对于超大型文件,这可能成为瓶颈。- 优化策略:
- 流式处理:如果不需要随机访问所有消息,可以边读取边处理。
mido的MidiFile在迭代时就是流式的,但track对象已经加载完毕。对于极端情况,可以考虑使用更低层的库(如python-midi,但已不维护)或直接解析二进制文件。 - 选择性加载:如果你只关心特定通道或特定类型的消息,可以在遍历时快速跳过不关心的消息。
- 使用
copy()方法:当需要修改消息时(如调整力度),直接修改msg.velocity会改变原始对象。如果后续还需要原始数据,使用msg.copy()来创建副本进行操作。 - 避免深度嵌套循环:如果需要对音符进行复杂的关联分析(如查找和弦),先将音符数据提取到更高效的数据结构(如Pandas DataFrame或列表的列表)中,再进行操作,比在庞大的消息列表上多次遍历要快得多。
- 流式处理:如果不需要随机访问所有消息,可以边读取边处理。
# 示例:流式处理,仅统计C5以上高音音符的数量 def count_high_notes(file_path, threshold_note=72): mid = MidiFile(file_path) high_note_count = 0 for track in mid.tracks: for msg in track: if msg.type == 'note_on' and msg.velocity > 0 and msg.note >= threshold_note: high_note_count += 1 return high_note_count5.4 与其他库的协作
问题6:如何将MIDI数据用于机器学习或更复杂的音乐分析?
mido提供了底层的MIDI消息访问。对于更高层的音乐表示(如音符序列、和弦、节奏模式),通常需要转换成其他格式。- 常用方案:
- Music21:一个强大的音乐学分析库,可以直接读取MIDI文件,进行乐理分析(调性、和弦、音阶等)。你可以用
mido做预处理,再用music21做分析。 - PrettyMIDI:另一个优秀的库,专注于将MIDI文件转换为易于操作的音符起止时间列表(单位是秒),并提供了生成、操作这些数据的接口,非常适合音乐信息检索(MIR)和机器学习。
- 自定义表示:如前文
extract_note_sequence函数所示,你可以将MIDI转换成(pitch, start_time, duration, velocity)的列表,或者进一步量化为基于固定时间网格的钢琴卷帘矩阵(一个二维数组,行是音高,列是时间步),这是很多深度学习模型(如MusicVAE, MuseNet)的输入格式。
- Music21:一个强大的音乐学分析库,可以直接读取MIDI文件,进行乐理分析(调性、和弦、音阶等)。你可以用
# 使用pretty_midi库进行转换示例 import pretty_midi import numpy as np def midi_to_piano_roll_matrix(midi_path, fs=100): """将MIDI转换为钢琴卷帘矩阵(每秒fs帧)""" pm = pretty_midi.PrettyMIDI(midi_path) # 将所有乐器合成一个钢琴卷帘 piano_roll = pm.get_piano_roll(fs=fs) # 形状为 (128, 时间帧数) # 钢琴卷帘的值是力度,可以二值化或保留力度值 binary_pr = (piano_roll > 0).astype(np.float32) return binary_pr, fs处理MIDI文件就像在指挥一个数字乐队,mido给了你指挥棒和乐谱。从简单的文件信息读取到复杂的音乐生成算法,理解其消息机制和时间系统是关键。我个人的体会是,在开始一个音乐编程项目前,先用mido写个小脚本把目标MIDI文件的结构和内容打印出来仔细看看,往往能发现一些意想不到的细节(比如那些奇怪的控制器事件),这比直接闷头写代码要高效得多。最后,多听多试,把生成的文件丢进DAW或播放器里实际播放,是检验代码效果最直接的方法。