☰
MIDI转ABC记谱法:从事件流到可读谱面的量化与映射实践
2026/10/15 14:07:48 网站建设 项目流程

简介:MidiToAbc 是一个用 Haskell 编写的开源工具项目,目标是把传统音乐的 MIDI 文件转换为 ABC 记谱法文本,方便在乐谱软件中编辑与分享。项目当前处于第一阶段,聚焦斯堪的纳维亚风格的单声部旋律,如波尔卡、卷轴、华尔兹等规整节奏,输入以计算机生成的 MIDI 为主;后续阶段计划处理真人钢琴演奏中连奏、节奏偏移等不规则情况。资源面向具备一定 Haskell 基础、对音乐信息检索或自动记谱感兴趣的开发者与研究者。压缩包共 41 个文件,约 43KB,包含 17 个 mid 示例音频、11 个 hs 源码、10 个 sh 构建脚本,以及说明文档与忽略配置,覆盖从输入样例到编译运行的完整链路。已有 802 人学习下载。读者可借此理解 MIDI 事件解析、节奏量化与 ABC 语法生成的整体思路,并直接复用示例曲目与脚本进行二次开发。

1. 从 MIDI 到 ABC:为什么“能听”的谱子不一定“能读”

手里有一堆 MIDI 文件,想把它变成能直接贴进谱面工具、能被人一眼读懂的 ABC 记谱法,这件事听起来像格式转换,做起来却更像一次“信息翻译”。MIDI 记录的是演奏事件——什么时刻、哪个通道、按下哪个键、力度多大、持续多久;ABC 记录的是乐谱语义——这是几分音符、属于哪个声部、调号是什么、小节线画在哪。两者不是同一层的东西,所以 MidiToAbc 这类工具真正要解决的,是把“时间轴上的按键流”还原成“纸面上的音符结构”。

我最早动这个念头,是因为要给一批传统音乐的 MIDI 做归档。这些文件大多来自老设备导出,轨道混乱、时值不齐、没有速度标记,直接扔进打谱软件里全是碎音符。后来我固定了一套流程:先解析 MIDI 事件,再量化时值,然后按声部分轨,最后生成 ABC 文本。这套流程不依赖某个特定软件,用 Python 加一个 MIDI 解析库就能跑通,适合做批量归档、教学素材整理、或者给老录音做可读谱面。

2. 拆解 MIDI 事件流:从 note_on 到可读音符

2.1 为什么不能直接“一个音符一个字符”地转

MIDI 文件里没有“四分音符”这个概念,只有 delta time(距上一事件的 tick 数)和 note_on/note_off 事件。一个持续 480 tick 的 note_on 到 note_off,在 480 PPQ(每四分音符 tick 数)的设定下才是四分音符。如果 PPQ 是 960,那它就是八分音符。所以第一步必须读出文件的 PPQ 和速度映射,否则后面所有时值都是错的。

常见做法是用mido或pretty_midi这类库把 MIDI 读成事件列表。我一般用mido,因为它保留原始 tick 信息,方便自己做量化。下面这段代码把 MIDI 读成“音符对象”列表,每个对象带音高、起始 tick、结束 tick、通道和力度。

import mido def midi_to_note_events(path): mid = mido.MidiFile(path) ppq = mid.ticks_per_beat # 每四分音符的 tick 数 notes = [] # 记录每个 (通道, 音高) 当前按下的起始 tick active = {} abs_tick = 0 for msg in mid.tracks[0]: # 先只看第一轨,多轨后面再拆 abs_tick += msg.time if msg.type == 'note_on' and msg.velocity > 0: active[(msg.channel, msg.note)] = abs_tick elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): key = (msg.channel, msg.note) if key in active: start = active.pop(key) notes.append({ 'pitch': msg.note, 'start': start, 'end': abs_tick, 'channel': msg.channel, 'velocity': msg.velocity }) return notes, ppq

逻辑说明:abs_tick累加 delta time 得到绝对时间;active字典用 (通道, 音高) 做键,避免同一音高重叠时错配。参数上,ppq决定后面时值换算的分母,velocity暂时不用,但保留下来可以做力度分层。注意note_on且 velocity 为 0 等价于note_off,这是 MIDI 规范里的常见写法,漏掉会导致音符永远不结束。

2.2 时值量化:把“人弹的”变成“谱面认的”

传统音乐 MIDI 往往不是严格对齐的,尤其是现场演奏或老设备导出。直接按 tick 算时值会得到一堆 0.97 个四分音符、1.03 个八分音符这种无法记谱的结果。量化就是把这些时值吸附到最近的规范音符上。

我一般用“网格量化”:先确定最小网格,比如十六分音符,然后把每个音符的 start 和 end 分别吸附到最近的网格点。下面这个函数做两件事:把 tick 转成以四分音符为单位的浮点时值,再量化到指定分母。

def quantize_notes(notes, ppq, grid=4): """ grid=4 表示量化到十六分音符(1/4 个四分音符) grid=2 表示八分音符,grid=1 表示四分音符 """ step = ppq / grid # 一个网格对应的 tick 数 quantized = [] for n in notes: start_q = round(n['start'] / step) * step end_q = round(n['end'] / step) * step if end_q <= start_q: end_q = start_q + step # 防止零长度音符 duration_ticks = end_q - start_q # 转成以四分音符为单位的时值 duration_quarter = duration_ticks / ppq quantized.append({ 'pitch': n['pitch'], 'start_quarter': start_q / ppq, 'duration_quarter': duration_quarter, 'channel': n['channel'] }) return quantized

参数说明:grid越大网格越细,十六分音符用 4,三十二分音符用 8。传统音乐里装饰音多,网格太细会把颤音拆成碎片,网格太粗又会把十六分音符吞成八分音符。我的经验是先用 4 跑一遍,听一下生成的 ABC 回放,如果明显丢音就调到 8,如果碎音太多就降到 2。量化后duration_quarter是浮点数,后面映射到 ABC 音符时再取最接近的规范时值。

2.3 声部分轨:单轨 MIDI 怎么拆成多声部

很多传统音乐 MIDI 是单轨的,所有音符挤在一个通道里。ABC 虽然可以写单声部,但传统音乐经常有主旋律和伴奏两条线,全塞进一行会很难读。常见做法是按音高或按时间重叠关系拆声部:音高持续偏高的归旋律,偏低的归伴奏;或者用“最大重叠数”判断,同一时刻超过一个音就拆出低音声部。

我一般先用音高阈值粗分:比如以 MIDI 音高 60(中央 C)为界,60 以上进旋律,60 以下进伴奏。如果旋律里出现明显低音经过音,再手动调整。拆完后每个声部单独做量化,避免不同声部的量化网格互相干扰。

def split_voices(notes, split_pitch=60): melody = [n for n in notes if n['pitch'] >= split_pitch] bass = [n for n in notes if n['pitch'] < split_pitch] return melody, bass

这个阈值不是固定的,要看具体曲目。如果旋律本身就在低音区,阈值要往下调。拆声部没有万能公式,我的习惯是先把拆分结果生成 ABC,用文本谱面看一眼,如果旋律线断断续续就说明阈值切错了。

3. 生成 ABC 文本:音符映射、调号与小节线

3.1 音高到 ABC 音符名的映射规则

ABC 记谱法的音高表示和 MIDI 音高是两套体系。ABC 用字母 A-G 加升降号,中央 C 写作C,低八度用逗号,,高八度用撇号'。MIDI 音高 60 是中央 C,对应 ABC 的C。下面这个映射表覆盖常用范围:

MIDI 音高ABC 写法说明
60C中央 C
62D
64E
65F
67G
69A
71B
72c高八度 C
48C,低八度 C

实现时可以用一个基准表加八度偏移。下面代码把 MIDI 音高转成 ABC 音名,同时处理升降号。

NOTE_NAMES = ['C', '^C', 'D', '^D', 'E', 'F', '^F', 'G', '^G', 'A', '^A', 'B'] def pitch_to_abc(pitch): name = NOTE_NAMES[pitch % 12] octave = pitch // 12 - 1 # MIDI 60 是 C4,对应 ABC 中央 C if octave >= 5: return name.lower() + "'" * (octave - 5) elif octave == 4: return name else: return name + "," * (4 - octave)

逻辑说明:pitch % 12取音名,pitch // 12 - 1取八度。ABC 里中央 C 所在八度用大写字母,高八度用小写加撇号,低八度用大写加逗号。参数上,NOTE_NAMES里用^表示升号,降号可以用_,但传统音乐里升号更常见,我一般统一用升号,遇到降号调再手动改。

3.2 时值映射:浮点时长怎么落到 ABC 的分数体系

ABC 的时值用分数表示:四分音符不写数字,八分音符写/2,十六分音符写/4,附点用3/2这种。量化后的duration_quarter是浮点数,需要映射到最近的规范时值。我一般建一个候选表,取误差最小的。

DURATION_MAP = [ (4.0, '4'), # 全音符 (3.0, '3'), # 附点二分 (2.0, '2'), # 二分 (1.5, '3/2'), # 附点四分 (1.0, ''), # 四分 (0.75, '3/4'), # 附点八分 (0.5, '/2'), # 八分 (0.25, '/4'), # 十六分 ] def duration_to_abc(dur): best = min(DURATION_MAP, key=lambda x: abs(x[0] - dur)) return best[1]

参数说明:DURATION_MAP按从长到短排列,min取绝对误差最小的。如果误差超过 0.1,说明量化网格可能选错了,需要回头调grid。附点时值在传统音乐里很常见,但 ABC 的附点写法容易和连音线混淆,生成后要检查一遍。

3.3 调号推断与小节线插入

ABC 文件头需要K:字段指定调号。MIDI 本身不带调号信息,常见做法是统计所有音符的 pitch class 分布,看哪个大调或小调音阶覆盖最多。简单实现:统计每个音名出现次数,和 C 大调音阶比对,取匹配度最高的。

def guess_key(notes): from collections import Counter pc = Counter(n['pitch'] % 12 for n in notes) major_profile = [0, 2, 4, 5, 7, 9, 11] # C 大调音阶 best_key = 'C' best_score = -1 for root in range(12): scale = [(root + i) % 12 for i in major_profile] score = sum(pc[p] for p in scale) if score > best_score: best_score = score best_key = NOTE_NAMES[root].replace('^', '') return best_key

小节线按拍号插入:每累计到拍号分子对应的时值就加一个|。拍号可以从 MIDI 的 time signature meta 事件读,读不到就默认 4/4。下面把前面几步串起来,生成完整 ABC 文本。

def notes_to_abc(notes, ppq, title="Untitled"): quantized = quantize_notes(notes, ppq, grid=4) key = guess_key(quantized) lines = [f"X:1", f"T:{title}", "M:4/4", "L:1/4", f"K:{key}"] bar_duration = 4.0 # 4/4 拍,每小节 4 个四分音符 current_bar = 0.0 bar_notes = [] for n in sorted(quantized, key=lambda x: x['start_quarter']): abc_pitch = pitch_to_abc(n['pitch']) abc_dur = duration_to_abc(n['duration_quarter']) bar_notes.append(abc_pitch + abc_dur) current_bar += n['duration_quarter'] if current_bar >= bar_duration: lines.append(' '.join(bar_notes) + ' |') bar_notes = [] current_bar = 0.0 if bar_notes: lines.append(' '.join(bar_notes) + ' |]') return '\n'.join(lines)

逻辑说明:L:1/4表示默认音符长度是四分音符,所以四分音符不写数字。bar_duration按拍号算,4/4 就是 4.0。每累计满一小节就输出一行并加|,最后用|]收尾。参数上,grid和bar_duration要匹配,如果拍号是 3/4,bar_duration改成 3.0。

4. 避坑与排查:量化、连音、多轨的五个血泪经验

4.1 量化后音符“粘”在一起,听感像机器人

现象:生成的 ABC 回放时每个音都卡在网格上,没有弹性,传统音乐的呼吸感全没了。原因:网格量化把微小的时值偏差全部抹平,尤其是乐句结尾的渐慢。解决:对乐句结尾的音符不做量化,或者用“量化强度”参数,只把偏差超过阈值的事件吸附,阈值内的保留原值。我一般设阈值 0.15 个四分音符,超过才量化。

4.2 连音线丢失,长音被拆成多个短音

现象:MIDI 里一个持续两拍的长音,转成 ABC 后变成两个四分音符。原因:MIDI 里同一个音高可能被分成多个 note_on/note_off 对,尤其是老设备导出时。解决:在解析阶段合并相邻的同音高音符,如果前一个的 end 和后一个的 start 差距小于一个网格,就合并成一个。合并后再量化,长音就不会断。

4.3 多轨 MIDI 只读了第一轨,伴奏全丢

现象:生成的 ABC 只有旋律,伴奏声部完全不见。原因:代码里只遍历了mid.tracks[0]。解决:遍历所有轨道,按通道或按音高范围分声部,每个声部单独生成 ABC 行,用V:字段标记声部。注意不同轨道的 PPQ 是一样的,但速度映射可能不同,要统一到同一个时间轴。

4.4 调号猜错,整首谱子全是临时升降号

现象:ABC 里每个音符都带^或_,读起来很累。原因:调号推断只统计了音高分布,没有考虑传统音乐里的调式。解决:除了大调,也跑一遍小调音阶,取匹配度更高的。另外可以读 MIDI 的 key signature meta 事件,如果有就直接用。没有的话,人工听一遍前几个小节,手动指定K:字段。

4.5 速度变化没处理,时值全错

现象:MIDI 中间有渐快渐慢,生成的 ABC 时值忽长忽短。原因:代码只用了 PPQ 换算,没有考虑 tempo 事件。解决:解析时记录每个 tempo 变化点的绝对 tick,换算时值的时候按当前 tempo 分段计算。如果嫌麻烦,可以先把 MIDI 用打谱软件统一速度再导出,但这样会丢失渐快渐慢信息。我的习惯是保留 tempo 映射,在 ABC 里用Q:字段标注速度变化。

5. 进阶技巧:用 ABC 做可读谱面校验与批量归档

生成 ABC 只是第一步,真正让这套流程可靠的是“回环校验”:把生成的 ABC 用abc2midi或类似工具转回 MIDI,和原始 MIDI 对比音符数量、总时长、音高分布。如果差异超过 5%,说明量化或分声部出了问题。这个回环不需要人工听,写个脚本比对统计量就行。

def validate_roundtrip(original_notes, abc_text): # 统计原始 MIDI 的音符数和总时长 orig_count = len(original_notes) orig_duration = sum(n['end'] - n['start'] for n in original_notes) # 这里假设 abc_text 已经用外部工具转回 MIDI 并解析成 notes # 实际使用时替换成回读逻辑 print(f"原始音符数: {orig_count}, 原始总 tick: {orig_duration}") # 对比回读结果的音符数和总 tick,差异超过 5% 就报警

参数上,音符数差异通常来自连音合并和量化,5% 以内可以接受;总时长差异超过 10% 说明 tempo 处理有问题。我一般把校验结果写进日志,批量处理时只关注报警的文件。

另一个技巧是给 ABC 加%%score字段控制声部显示,或者用%%MIDI program指定回放音色。传统音乐里不同声部用不同音色,回放时更容易分辨。批量归档时,我习惯按“曲名_调号_拍号.abc”命名,调号和拍号从推断结果来,这样文件列表一眼就能看出哪些调号猜错了。

最后说个我踩过的坑:早期我图省事,把所有 MIDI 都按 4/4 拍处理,结果 3/4 的曲子小节线全错位,生成的谱子没法看。后来改成先读 time signature meta 事件,读不到再默认 4/4,并且把拍号写进文件名。这个习惯帮我省了很多返工。希望帮到你。

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

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

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

立即咨询