生成音乐的谱面可视化:从 MIDI 到标准五线谱的自动转换
生成音乐的谱面可视化从 MIDI 到标准五线谱的自动转换一、AI 生成的 MIDI 是一堆数字而音乐人要的是五线谱AI 音乐生成模型输出的是 MIDI 数据——一串音符的开始时间、结束时间、音高、力度。这些数字对算法是完美的但对音乐人是不可读的。音乐人需要五线谱——清晰的音符位置、准确的节奏分组、合适的调号和拍号。MIDI 到五线谱的自动转换不是简单的把 MIDI 音高映射到五线谱的位置。真正的难点在于节奏记谱一个时长 0.75 秒的音符是附点四分音符还是三连音中的一个、调号推断根据出现的音符确定最合理的调号来最小化临时升降记号、分声部钢琴左右手怎么自动分开多声部音乐怎么表示。这些问题的核心是信息的不确定性。MIDI 是一串精确的数值而五线谱是人对这些数值的音乐理解。两者的转换需要规则 启发式算法甚至需要一定的音乐知识。二、底层机制与原理剖析谱面生成的五个核心算法节奏量化Quantization将 MIDI 中的浮点时间值映射到最近的节奏网格。例如MIDI 中一个音符持续 0.749 秒在 BPM120 下对应 1498 ticks1 tick 0.5ms。最近的音乐节奏时值是附点四分音符1500 ticks。量化算法需要容忍一定的时间误差通常 ±5-10 ticks将不精确的 MIDI 时间映射到标准节奏时值。调号推断Key DetectionKrumhansl-Schmuckler 算法是业界标准。其核心原理计算每个音符出现的累计时长得到一个 12 维的音高分布向量。将这个向量与 24 个调性模板12 个大调 12 个小调做相关性计算相关性最高的就是推断的调性。选择好的调号能减少临时升降记号的数量——这是谱面可读性的关键。声部分离Voice Separation钢琴谱面的最大挑战。没有统一标准——基于音高阈值高于某个音高 右手低于 左手是最简单的方案但遇到交叉弹奏就失败。更复杂的算法考虑音高连续性和节奏独立性。符杠分组Beaming根据节拍对八分音符和更短音符分组。4/4 拍下通常每拍一组如果在 4/4 中有连续的八分音符通常按一拍一组来用符杠连接。休止符填充Rest Filling五线谱每个声部在每一拍上都必须有东西——要么是音符要么是休止符。如果某个声部在某一拍上没有任何音符需要插入对应时值的休止符。三、生产级代码实现 MIDI → 五线谱 (MusicXML) 自动转换引擎 核心算法 1. 节奏量化 (Quantization) 2. Krumhansl-Schmuckler 调号推断 3. 声部分离 import math import logging from typing import List, Dict, Tuple, Optional from dataclasses import dataclass from enum import Enum from collections import Counter import mido # MIDI 解析库 from music21 import * # 谱面生成库 logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) # --------------------------------------------------------------------------- # 数据模型 # --------------------------------------------------------------------------- dataclass class QuantizedNote: 量化后的音符 pitch: int # MIDI 音高 0-127 start_beat: float # 开始节拍以拍为单位 duration_beats: float # 持续节拍 velocity: int # 力度 0-127 def __repr__(self): return fNote(pitch{self.pitch}, start{self.start_beat:.2f}b, dur{self.duration_beats:.2f}b) # Krumhansl-Schmuckler 调性模板 # 24 个调性12 大调 12 小调的音符分布权重 # 值越大表示该音符在该调性中越重要 KEY_PROFILES { # 大调模板C Major 的标准化分布其他大调通过半音偏移得到 major: [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], # 小调模板 minor: [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], } # 12 个半音的名称从 C 开始 PITCH_NAMES [C, C#, D, D#, E, F, F#, G, G#, A, A#, B] # 标准节奏时值以四分音符 1.0 为单位 STANDARD_DURATIONS [ 0.25, # 十六分音符 0.375, # 附点十六分 0.5, # 八分音符 0.75, # 附点八分 1.0, # 四分音符 1.5, # 附点四分 2.0, # 二分音符 3.0, # 附点二分 4.0, # 全音符 ] class MidiToScoreConverter: MIDI → MusicXML 乐谱转换器 使用 music21 作为谱面生成引擎 def __init__(self): self.quantization_grid 0.125 # 默认量化网格为三十二分音符精度 def convert_file(self, midi_path: str, output_path: str) - bool: 转换 MIDI 文件为 MusicXML 乐谱 步骤 1. 解析 MIDI → 提取音符 2. 调号推断 3. 节奏量化 4. 声部分离 5. 生成 MusicXML try: # 1. 解析 midi_file mido.MidiFile(midi_path) ticks_per_beat midi_file.ticks_per_beat tempo self._extract_tempo(midi_file) # 2. 提取原始音符 raw_notes self._extract_notes(midi_file, ticks_per_beat) if not raw_notes: logger.error(No notes found in MIDI file) return False logger.info(Extracted %d notes from MIDI, len(raw_notes)) # 3. 节奏量化 quantized self._quantize_notes(raw_notes) # 4. 调号推断 key_signature self._infer_key(quantized) logger.info(Inferred key: %s, key_signature) # 5. 声部分离 voices self._separate_voices(quantized) logger.info(Separated %d voices, len(voices)) # 6. 生成 MusicXML使用 music21 库 score self._build_score(voices, key_signature, tempo) score.write(musicxml, output_path) logger.info(Score saved to %s, output_path) return True except Exception as e: logger.error(Conversion failed: %s, e) import traceback traceback.print_exc() return False def _extract_tempo(self, midi_file: mido.MidiFile) - int: 提取 MIDI 文件中的 tempo默认 120 BPM for track in midi_file.tracks: for msg in track: if msg.type set_tempo: return int(mido.tempo2bpm(msg.tempo)) return 120 # 默认 def _extract_notes(self, midi_file: mido.MidiFile, ticks_per_beat: int) - List[QuantizedNote]: 提取 MIDI 文件中的所有音符并转换为节拍坐标 转换关系ticks → beats beat tick / ticks_per_beat notes [] # 按音轨和通道追踪活跃的 note_on 事件 # key: (channel, pitch) → start_tick active_notes: Dict[Tuple[int, int], int] {} current_tick 0 for track in midi_file.tracks: for msg in track: current_tick msg.time if msg.type note_on and msg.velocity 0: # 记录 note_on key (msg.channel, msg.note) active_notes[key] current_tick elif msg.type note_off or (msg.type note_on and msg.velocity 0): # note_off或 velocity0 的 note_on 表示 note off key (msg.channel, msg.note) if key in active_notes: start_tick active_notes.pop(key) start_beat start_tick / ticks_per_beat duration_ticks current_tick - start_tick duration_beats duration_ticks / ticks_per_beat notes.append(QuantizedNote( pitchmsg.note, start_beatstart_beat, duration_beatsduration_beats, velocitymsg.velocity if msg.type note_on else 64, )) # 按开始时间排序 notes.sort(keylambda n: n.start_beat) return notes def _quantize_notes(self, notes: List[QuantizedNote]) - List[QuantizedNote]: 节奏量化将不精确的 MIDI 时值映射到最接近的标准节奏值 为什么需要量化 AI 生成的 MIDI 音符时长受浮点精度影响可能不是精确的 1.0 拍。 比如 0.998 拍应该量化为 1.0四分音符0.752 拍量化为 0.75附点八分。 quantized [] for note in notes: # 量化起始时间 quant_start self._quantize_time(note.start_beat) # 量化持续时长 # 注意duration 量化后结束时间可能改变 # 但这种误差通常在 ±5% 以内不影响谱面可读性 quant_dur self._quantize_duration(note.duration_beats) # 防止量化为 0极短音符 quant_dur max(quant_dur, self.quantization_grid) quantized.append(QuantizedNote( pitchnote.pitch, start_beatquant_start, duration_beatsquant_dur, velocitynote.velocity, )) return quantized def _quantize_time(self, time_beats: float) - float: 量化时间点到最近的网格线 return round(time_beats / self.quantization_grid) * self.quantization_grid def _quantize_duration(self, duration: float) - float: 量化时值到最接近的标准值时 if duration 0: return self.quantization_grid # 找到最近的标准时值 closest min(STANDARD_DURATIONS, keylambda d: abs(d - duration)) # 如果差距 20%可能是复杂的时值如三连音保持原始值 if abs(closest - duration) / duration 0.20: # 保持原始但舍入到网格 return self._quantize_time(duration) return closest def _infer_key(self, notes: List[QuantizedNote]) - str: Krumhansl-Schmuckler 调号推断算法 步骤 1. 统计每个音高的累计时长 2. 得到 12 维的音高分布 3. 与 24 个调性模板做 Pearson 相关系数 4. 相关系数最高的就是推断调性 if not notes: return C major # 1. 统计音高分布按累计时长加权 pitch_duration [0.0] * 12 for note in notes: pitch_class note.pitch % 12 pitch_duration[pitch_class] note.duration_beats # 归一化 total sum(pitch_duration) if total 0: pitch_duration [d / total for d in pitch_duration] # 2. 与 24 个调性模板比较 best_corr -1.0 best_key C major for mode, profile in KEY_PROFILES.items(): for tonic_idx in range(12): # 将模板旋转到当前主音 rotated profile[tonic_idx:] profile[:tonic_idx] # Pearson 相关系数 corr self._pearson_corr(pitch_duration, rotated) if corr best_corr: best_corr corr best_key f{PITCH_NAMES[tonic_idx]} {mode} return best_key staticmethod def _pearson_corr(x: List[float], y: List[float]) - float: 计算 Pearson 相关系数 n len(x) mean_x sum(x) / n mean_y sum(y) / n cov sum((x[i] - mean_x) * (y[i] - mean_y) for i in range(n)) std_x math.sqrt(sum((v - mean_x) ** 2 for v in x)) std_y math.sqrt(sum((v - mean_y) ** 2 for v in y)) if std_x 0 or std_y 0: return 0.0 return cov / (std_x * std_y) def _separate_voices(self, notes: List[QuantizedNote]) - List[List[QuantizedNote]]: 声部分离 简单策略基于音高阈值分离 - 低于 MIDI 60中央 C→ 低声部左手 - 高于 MIDI 60 → 高声部右手 注意这只是基础算法。复杂的钢琴谱需要更多规则。 low_threshold 60 # MIDI 中央 C high_voice [n for n in notes if n.pitch low_threshold] low_voice [n for n in notes if n.pitch low_threshold] # 过滤空声部 voices [] if high_voice: voices.append(high_voice) if low_voice: voices.append(low_voice) # 如果只有一个声部返回原样单声部音乐 if len(voices) 0: return [notes] return voices def _build_score(self, voices: List[List[QuantizedNote]], key_signature: str, tempo: int) - music21.stream.Score: 使用 music21 构建乐谱 music21 内部处理了五线谱的渲染音符位置、符杠、调号等 这里负责将量化的音符数据转换为 music21 的对象模型 from music21 import stream, meter, key, tempo as m21tempo, note, clef score stream.Score() part stream.Part() # 拍号默认 4/4——实际应通过 MIDI 的 time_signature 事件获取 part.append(meter.TimeSignature(4/4)) # 调号 ks key.Key(key_signature) part.append(ks) # 速度 part.append(m21tempo.MetronomeMark(numbertempo)) # 为每个声部创建一个 Stream for voice_idx, voice_notes in enumerate(voices): voice_stream stream.Voice() for qn in voice_notes: # 创建 music21 Note 对象 n note.Note() n.pitch.midi qn.pitch n.duration.quarterLength qn.duration_beats n.volume.velocity qn.velocity # 设置音符在谱面上的位置 voice_stream.insert(qn.start_beat, n) part.append(voice_stream) score.append(part) return score # --------------------------------------------------------------------------- # 使用示例 # --------------------------------------------------------------------------- if __name__ __main__: converter MidiToScoreConverter() success converter.convert_file(ai_generated.mid, output.musicxml) if success: print(转换成功: output.musicxml) print(可以用 MuseScore / Verovio 打开查看五线谱) else: print(转换失败请检查 MIDI 文件)四、边界分析与架构权衡节奏量化的过度拟合量化参数太严格只接受标准时值会导致三连音、swing、自由节奏被强制对齐到错误的时值如果 AI 生成模型本身就能输出节奏正确的 MIDI如 MusicGen 的 melody 模式跳过量化步骤直接映射保留原始 MIDI → music21的未量化路径让用户选择是否需要量化调号推断的边界情况无调性音乐现代/实验音乐或频繁转调的音乐K-S 算法表现差——推断出一个不存在的调号比不推断更糟糕解决方案如果相关性 0.5不推断调号使用 C Major 作为默认表示无调号声部分离的真实困难音高阈值法对左右手交叉无能为力——右手弹低音区、左手弹高音区会全部反多声部混叠在交响乐中更复杂——40 个乐器的 MIDI 混在同一个音轨里目前的实用解决方案要求 AI 生成模型输出分音轨 MIDIType 1 MIDI每个乐器/声部走独立的 track五、总结MIDI 到五线谱的转换核心不是映射音高 → 位置是理解节奏量化、调号推断、声部分离。K-S 算法做调号推断以减少临时升降号节奏量化将浮点时间映射到标准时值声部分离处理多声部音乐的表示。目前music21库已经做了 80% 的工作——你需要做的是上游的 MIDI 分析和参数决策把理解后的数据交给 music21 去渲染成标准的 MusicXML。渲染出的 MusicXML 可以用 Verovio浏览器端或 MuseScore桌面端做最终的可视化输出。