
简介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-127data2 是力度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_beat480): 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(keylambda 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, tolerance0.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, tempoNone): lines [] lines.append(X:1) lines.append(fT:{title}) lines.append(fM:{meter}) lines.append(fL:{default_len}) if tempo: lines.append(fQ:1/4{tempo}) lines.append(fK:{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_threshold30): 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, window480): # 按时间窗口聚合音符检测同时发声的音 chords [] notes_sorted sorted(notes, keylambda 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 解析器里过一遍确认没有语法错误再导入到谱面工具里做精细调整。这个“先解析再导入”的步骤帮我省了很多后悔药因为有些工具报错信息极其模糊直接导入根本不知道哪里错了。希望帮到你。本文还有配套的精品资源点击获取