简介:MidiToAbc 是一个用 Haskell 编写的开源工具项目,目标是把传统音乐的 MIDI 文件转换为 ABC 记谱法文本,方便生成可读、可编辑的曲谱。项目当前处于第一阶段,主要面向计算机生成的、节奏规整的单声部旋律,尤其针对斯堪的纳维亚风格的传统舞曲,如波尔卡、卷轴等,适合对 MIDI 处理、ABC 记谱或 Haskell 编程感兴趣的开发者与音乐技术爱好者。资源包共 41 个文件,包含 17 个 mid 示例音频、11 个 hs 源码、10 个 sh 构建脚本,以及说明文档与配置文件,压缩后约 43KB,结构紧凑。已有 802 人学习下载。读者可借此了解 MIDI 到 ABC 的转换思路、Haskell 工程组织方式与批量测试脚本,并参考示例曲目验证转换效果,为后续处理真人演奏 MIDI 的启发式算法打下基础。
1. 从 MIDI 到 ABC:为什么你的传统音乐数字化总在“最后一公里”翻车
你手里有一堆 MIDI 文件,可能是从老磁带转录的、从某个古早音源导出的,也可能是用键盘弹完存下来的。你想把它们变成能读、能改、能放进谱面工具里继续编辑的 ABC 记谱法文本。结果一打开转换工具,要么音符全挤在一起,要么调号全乱,要么连音高都对不上。这不是你操作有问题,而是 MIDI 和 ABC 之间隔着一层“演奏语义”和“记谱语义”的鸿沟。
MidiToAbc 这个方向要解决的核心问题,就是把“机器演奏事件”翻译成“人类可读乐谱”。MIDI 记录的是“什么时候按了哪个键、按了多久、力度多大”,ABC 记录的是“这是什么音、什么时值、属于哪个调、第几小节”。前者是时间轴上的事件流,后者是二维谱面上的符号系统。适合谁?做传统音乐数字化的、搞民乐谱整理的、需要批量把演奏数据转成可编辑谱面的开发者。这一篇不讲空泛概念,直接拆开讲怎么落地、参数怎么调、哪里最容易翻车。
2. MIDI 事件流怎么变成 ABC 音符串:从 Note On 到谱面符号的映射逻辑
2.1 先搞清楚 MIDI 里到底存了什么
一个标准 MIDI 文件(SMF)里,真正跟音高有关的就两类事件:Note On 和 Note Off。Note On 的 data1 是音高编号(0-127),data2 是力度;Note Off 或者力度为 0 的 Note On 表示该音结束。除此之外还有 Program Change 决定音色、Control Change 决定踏板和音量、Tempo 决定速度。但 ABC 不关心你用什么音色弹的,它只关心音高、时值、调号和拍号。
所以转换的第一步不是直接写 ABC,而是先把 MIDI 事件流“拍平”成一个个音符对象。每个音符对象至少要有:起始 tick、持续 tick、音高编号、力度。这里有个血泪经验:很多 MIDI 文件里同一个音高会在极短时间内出现多个 Note On,这是演奏时的重复触发,不是真的重复音。如果不做合并,转出来的 ABC 会满屏都是 32 分音符,根本没法看。
import mido def extract_notes(midi_path, ticks_per_beat=480): mid = mido.MidiFile(midi_path) 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 里,先强制结束前一个 if msg.note in active: start = active.pop(msg.note) notes.append({ 'pitch': msg.note, 'start': start, 'duration': abs_tick - start, 'velocity': msg.velocity }) active[msg.note] = abs_tick elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): if msg.note in active: start = active.pop(msg.note) notes.append({ 'pitch': msg.note, 'start': start, 'duration': abs_tick - start, 'velocity': msg.velocity }) # 收尾:还没结束的音符按最后一个 tick 截断 for pitch, start in active.items(): notes.append({ 'pitch': pitch, 'start': start, 'duration': abs_tick - start, 'velocity': 64 }) notes.sort(key=lambda n: (n['start'], n['pitch'])) return notes, mid.ticks_per_beat这段代码的逻辑很直白:遍历 MIDI 事件,遇到 Note On 就记下起始位置,遇到 Note Off 就生成一个音符对象。参数ticks_per_beat是 MIDI 文件自己的分辨率,常见值是 480 或 960,这个值决定了后面时值换算的精度。注意我在 Note On 分支里加了一个“同音高强制结束”的处理,这就是为了避免重复触发导致的碎片化。力度velocity暂时保留,后面判断是否要加装饰音或重音记号时会用到。
2.2 时值量化:为什么你的 ABC 里全是附点
MIDI 的时值是精确到 tick 的,但 ABC 的时值只能是 1、2、4、8、16 分音符加上附点。如果你直接把 tick 数换算成时值,会得到一堆像5/16这种没法表示的分数。所以必须做量化:把每个音符的持续 tick 数映射到最近的合法时值上。
常见做法是先把 tick 转成“几分音符”的浮点值,然后在一个候选时值表里找最接近的。候选表一般包括:全音符、附点二分、二分、附点四分、四分、附点八分、八分、十六分、三十二分。量化误差超过一定阈值的音符,要么合并到相邻音符,要么标记出来人工处理。
def quantize_duration(duration_ticks, ticks_per_beat, tolerance=0.15): # 以四分音符为 1.0 的基准 quarter_ticks = ticks_per_beat ratio = duration_ticks / quarter_ticks candidates = [ (4.0, '4'), (3.0, '3'), (2.0, '2'), (1.5, '3/2'), (1.0, '1'), (0.75, '3/4'), (0.5, '1/2'), (0.375, '3/8'), (0.25, '1/4'), (0.125, '1/8') ] best = None best_err = float('inf') for value, abc_len in candidates: err = abs(ratio - value) / value if err < best_err: best_err = err best = (value, abc_len) if best_err > tolerance: return None # 量化失败,交给人工或后续处理 return best参数tolerance设成 0.15 意思是允许 15% 的时值偏差。这个值不能太小,否则大量音符会被判为量化失败;也不能太大,否则三连音会被错误地量化成附点。我一般会先把 tolerance 设成 0.2 跑一遍,看看失败率,再逐步收紧到 0.1 左右。如果失败率超过 5%,说明这个 MIDI 的演奏 timing 太自由,需要先做全局的节奏对齐。
2.3 调号推断:不要相信 MIDI 文件里的调号元事件
很多 MIDI 文件里会带一个 Key Signature 元事件,但那个东西经常是错的,或者是制作时随手填的。真正可靠的调号推断应该从音符集合反推。基本思路是:统计所有音符的 pitch class 分布,然后跟 12 个大调、12 个小调的音阶模板做匹配,取匹配度最高的。
但传统音乐经常用五声音阶、七声音阶,甚至微分音,直接套大小调模板会翻车。更稳的做法是:先检测是否属于常见五声调式(宫商角徵羽),如果是就按五声调号处理;否则再走大小调匹配。ABC 里调号用K:字段表示,比如K: D表示 D 大调,K: Ador表示 A 多利亚调式。
def infer_key(notes): pitch_classes = [n['pitch'] % 12 for n in notes] from collections import Counter pc_count = Counter(pitch_classes) # 大调音阶模板(以 C 为 0) major_template = [0, 2, 4, 5, 7, 9, 11] minor_template = [0, 2, 3, 5, 7, 8, 10] best_key = None best_score = -1 for root in range(12): major_set = set((root + i) % 12 for i in major_template) minor_set = set((root + i) % 12 for i in minor_template) major_score = sum(pc_count[pc] for pc in major_set) minor_score = sum(pc_count[pc] for pc in minor_set) if major_score > best_score: best_score = major_score best_key = ('major', root) if minor_score > best_score: best_score = minor_score best_key = ('minor', root) return best_key这段代码返回一个元组,比如('major', 2)表示 D 大调。实际写 ABC 时还要把 root 映射成音名字母,并且根据调号决定哪些音要加临时记号。注意:如果音符总数少于 20 个,调号推断的置信度会很低,这时候建议直接输出K: C并让用户手动改。
3. 把量化结果写成 ABC:字段顺序、小节线和连音处理
3.1 ABC 头部字段的填写顺序有讲究
ABC 记谱法的头部字段不是随便排的,虽然解析器通常能容忍乱序,但为了兼容性和可读性,建议按这个顺序写:X(曲目编号)、T(标题)、M(拍号)、L(默认时值)、Q(速度)、K(调号)。其中L字段特别关键,它决定了后面音符不写时值时的默认长度。我一般设成L: 1/8,这样八分音符可以省略时值数字,写起来更干净。
拍号M可以从 MIDI 的 Time Signature 元事件里读,如果没有就默认M: 4/4。速度Q可以从 Tempo 元事件换算,公式是Q: 1/4 = BPM。但注意:ABC 里的速度标记不影响播放,只影响显示,所以如果只是做谱面存档,Q字段可以省略。
def build_abc_header(title, meter, default_len, key, tempo=None): lines = [] lines.append("X:1") lines.append(f"T:{title}") lines.append(f"M:{meter}") lines.append(f"L:{default_len}") if tempo: lines.append(f"Q:1/4={tempo}") lines.append(f"K:{key}") return "\n".join(lines)参数default_len就是L字段的值,常见选择是1/8或1/4。如果选1/8,那么四分音符要写成2,二分音符写成4,全音符写成8。如果选1/4,四分音符不写数字,二分音符写2。我倾向于1/8,因为传统音乐里八分音符和十六分音符很常见,默认1/8可以让大部分音符不带数字,谱面更干净。
3.2 小节线不是按时间等分的,是按拍号强拍的
很多新手会按固定 tick 间隔插入小节线,比如每 1920 tick 加一个|。这是错的。小节线应该落在拍号的强拍位置上,而且如果 MIDI 演奏有 rubato(弹性速度),强拍位置会偏移。正确做法是:先根据拍号和 tempo 算出每个小节的理论起始 tick,然后找最近的音符起始位置作为小节线插入点。
更稳的做法是:先把所有音符按起始 tick 排序,然后从第一个音符开始累加时值,每凑够一个完整小节的时值就插入一个小节线。但这里有个坑:如果某个音符跨越了小节的边界(比如一个二分音符从第 4 拍开始),ABC 里需要用连音线-把它拆成两个音符,分别放在两个小节里。
def insert_barlines(notes, beats_per_bar, beat_unit): # beat_unit 是分母,比如 4 表示四分音符为一拍 bar_ticks = beats_per_bar * (ticks_per_beat * 4 / beat_unit) result = [] current_bar_end = bar_ticks for note in notes: if note['start'] >= current_bar_end: result.append('|') current_bar_end += bar_ticks result.append(note_to_abc(note)) return ' '.join(result)这段代码的逻辑是:维护一个current_bar_end表示当前小节的结束 tick,当音符起始位置超过它时就插入小节线并推进到下一小节。注意bar_ticks的计算里有个4 / beat_unit,这是因为ticks_per_beat通常是以四分音符为基准的,如果拍号分母是 8,需要做换算。
3.3 连音、断音和装饰音:MIDI 力度和重叠能告诉你什么
MIDI 里两个相邻音符如果有重叠(前一个的 Note Off 晚于后一个的 Note On),在 ABC 里通常要写成连音线-或者圆滑线()。如果两个音符之间有明显间隙,可能要加断音记号.。力度信息可以用来判断重音>或弱音。
但这里有个玄学:很多 MIDI 的重叠是因为演奏者手指没抬干净,不是真的连音。我一般会设一个阈值,比如重叠超过 30 tick 才认为是连音,否则忽略。力度方面,如果某个音符的 velocity 比前后音符平均值高 30% 以上,可以加>重音记号。
def detect_articulation(prev_note, curr_note, overlap_threshold=30): if prev_note is None: return '' gap = curr_note['start'] - (prev_note['start'] + prev_note['duration']) if gap < -overlap_threshold: return '-' # 连音 elif gap > overlap_threshold: return '.' # 断音 return ''参数overlap_threshold设成 30 tick 是个经验值,在 480 ticks_per_beat 下大约是 1/16 拍。如果你的 MIDI 分辨率是 960,这个值要翻倍。实际使用时,我建议先跑一遍看看连音和断音的比例,如果连音超过 60%,说明阈值太松,需要收紧。
4. 避坑指南:MidiToAbc 转换中最容易翻车的五个地方
4.1 现象:转出来的 ABC 全是 32 分音符,根本没法读
原因:MIDI 演奏的 timing 抖动太大,每个音符的起始和结束都不在标准网格上,量化时被迫往最小的时值靠。解决:先做全局节奏对齐,用第一个音符的起始 tick 作为基准,把所有音符的起始位置往最近的 16 分音符网格上吸附,然后再做时值量化。如果还是不行,把tolerance从 0.15 放宽到 0.25,牺牲精度换可读性。
4.2 现象:调号推断出来是 C 大调,但谱面上全是临时记号
原因:传统音乐里有很多调式音阶,比如 D 多利亚、G 混合利底亚,这些调式用大小调模板匹配会失败。解决:在调号推断之前先做调式检测,把常见的七种教会调式模板都加进去。如果检测到是 D 多利亚,ABC 里写K: Ddor,这样临时记号会少很多。
4.3 现象:小节线位置全错,强拍跑到弱拍上
原因:MIDI 文件里没有 Time Signature 元事件,或者元事件是错的。解决:不要相信元事件,直接从音符的力度分布推断拍号。强拍位置的音符力度通常比弱拍高 10%-20%,统计所有音符的力度按 tick 位置做直方图,峰值间隔就是拍长。
4.4 现象:连音线满天飞,谱面像蜘蛛网
原因:重叠阈值设得太松,把演奏时的自然重叠全判成了连音。解决:把overlap_threshold从 30 tick 提高到 60 tick,或者改成动态阈值——取所有音符间隙的中位数,只有重叠超过中位数 2 倍的才判连音。
4.5 现象:转出来的 ABC 在某个谱面工具里能显示,在另一个里报错
原因:ABC 标准有多个版本,不同工具对字段顺序、连音线写法、装饰音语法的支持不一样。解决:输出时尽量用最保守的子集——头部字段按X T M L Q K顺序,连音线只用-不用(),装饰音只用{}不用~。如果目标工具支持,再逐步加高级语法。
5. 进阶技巧:用 ABC 的装饰音和和弦标记还原传统音乐韵味
传统音乐里有很多 MIDI 表达不了的东西,比如滑音、颤音、加花。但 ABC 的装饰音语法可以部分还原这些。比如{g}表示倚音,~表示颤音,!trill!表示颤音记号。如果你在 MIDI 里看到某个音符前面有一个极短(小于 1/32 拍)的装饰音,可以在 ABC 里写成{g}A而不是两个独立音符。
和弦标记方面,ABC 支持"Am"这样的和弦符号。如果你有 MIDI 的伴奏轨,可以从中提取和弦根音和性质,然后插到主旋律的对应位置。但注意:MIDI 的和弦轨通常是多音同时按下,直接转成 ABC 和弦会很难看,建议只提取根音和和弦性质,写成"Am"这样的标记。
def extract_chord_marks(notes, window=480): # 按时间窗口聚合音符,检测同时发声的音 chords = [] notes_sorted = sorted(notes, key=lambda n: n['start']) i = 0 while i < len(notes_sorted): group = [notes_sorted[i]] j = i + 1 while j < len(notes_sorted) and notes_sorted[j]['start'] - notes_sorted[i]['start'] < window: group.append(notes_sorted[j]) j += 1 if len(group) >= 3: pitches = sorted(set(n['pitch'] % 12 for n in group)) chord_name = identify_chord(pitches) if chord_name: chords.append((notes_sorted[i]['start'], chord_name)) i = j return chords这段代码把起始时间相差小于window的音符归为一组,如果组内音高种类大于等于 3 就尝试识别和弦。window设成 480 tick(一拍)是个保守值,实际可以根据曲速调整。识别出的和弦标记可以插到 ABC 的对应位置,写成"Am"这样的形式。
最后一个技巧:ABC 的%%MIDI指令可以让你在 ABC 文件里嵌入 MIDI 播放参数,比如%%MIDI program 1指定音色。如果你想让转换后的 ABC 既能看又能听,可以在头部加一行%%MIDI program 1,这样用支持 MIDI 播放的 ABC 工具打开时,音色不会跑偏。我自己的习惯是:转换完先在一个轻量级 ABC 解析器里过一遍,确认没有语法错误,再导入到谱面工具里做精细调整。这个“先解析再导入”的步骤帮我省了很多后悔药,因为有些工具报错信息极其模糊,直接导入根本不知道哪里错了。希望帮到你。
本文还有配套的精品资源,点击获取