简介:面向米哈游音乐二创爱好者和音乐信息检索、生成方向研究者的精选钢琴二创数据集,数据源自《原神》《崩坏:星穹铁道》等米哈游旗下游戏的标志性旋律再创作。整理方在原始网络乐谱基础上,补充了游戏内地区名与结构信息作为关键字嵌入,并将切片后的ABC乐谱作为训练数据,便于后续开展旋律分析、风格迁移等实验。压缩包共5个文件,以jsonl、json及md文档为主:jsonl/json用于存放结构化乐谱与元数据,md提供数据说明;整体仅235KB,轻量易用。已有140人学习。研究者可直接获得按地区、结构标记的钢琴二创语料,以及清洗后的可训练数据格式,能够省去从网络收集与预处理的繁琐环节,适合用于小型音乐生成、二创风格对比等课题的快速起步与验证。
1. 拿到这个 rar 之后,真正的门槛才刚开始
把“米哈游音乐二创钢琴数据集.rar”从网盘拖下来,解压出几百个 .mid 文件,这个动作本身花不了十分钟。真正让数据能用起来的,是后半段:文件名可能是“原神-风神像-改谱.mid”或“崩坏StarRail_Piano_v3.mid”这种完全不统一的东西,里面有的 MIDI 是 480 tick 分辨率,有的是 960,有的左右手混在同一条音轨里,有的轨道名直接叫“Acoustic Grand Piano 3”。这类从二创社区里爬出来的钢琴数据集,没有经过标准化,天然带着一股“能用但不好用”的味道。
但这批数据对做音乐生成、钢琴翻弹合成、自动扒谱的人来说,价值反而比官方发布的数据集更高。因为二创钢琴曲的 MIDI 通常音符密集、左右手分工清晰、旋律线明确,而且是基于现有游戏 OST 的改编,旋律结构有据可查。用这批数据能做的事情很具体:训练一个钢琴风格迁移模型、做一个旋律/伴奏分离基线,或者干脆把 MIDI 转成钢琴卷帘图(piano roll)后丢进 diffusion 模型里做条件生成。本文按我平时打数据集的标准路径走一遍:解压、解析 MIDI 中的音符事件、清洗和标准化、构造训练样本、回听验证。全程用 Python 和几个开源库,不依赖任何收费软件。
2. 从 rar 到 88 键:MIDI 解析、轨道归一化和钢琴卷帘构建
2.1 先解压,但别用unrar -e一把梭
数据集打成 rar 而不是 zip,通常意味着文件数量大、文件名长、可能还有分包。命令行下unrar能解,但如果包里有密码或者某个分卷损坏,直接解压会中断。对数据集处理来说,第一步要拿到“哪些文件损坏、哪些成功解出”的清单。
unrar lb "米哈游音乐二创钢琴数据集.rar" > filelist.txt unrar e -y "米哈游音乐二创钢琴数据集.rar" output_dir/第一条命令lb列出包内文件清单,先干这一步能判断里面是不是混了 .pdf、.wav、.txt,以及 MIDI 文件大概的分布。第二条e是解压但忽略目录结构,如果包内是song_list/artist_name/*.mid这种两级目录,用x保留路径更合适:
unrar x -y "米哈游音乐二创钢琴数据集.rar" output_dir/加压解压时两个参数值得注意:-y表示覆盖时不弹出交互确认,批处理必须加;-p后接密码,只有在确实知道密码时才用,不要在命令行明文写密码,安全隐患太大。如果包中还出现.rar.001这类分卷文件,unrar x会从第一个分卷自动读取后续分卷,但要求所有分卷在同一目录下,文件不能改过名,否则会报Unexpected end of archive。
解完之后用 Python 做一次健康检查,比用文件管理器一个个点开靠谱得多:
find output_dir -name "*.mid" -o -name "*.midi" | wc -l2.2 用 pretty_midi 读取音符事件,并过滤非钢琴轨道
拿到 MIDI 文件之后,第一件事是把每个音轨的 note 事件拉出来看。pretty_midi 是这里最顺手的库,既能读 MIDI 文件,也能把音符事件转成数组,还能直接输出 piano roll,但它的get_piano_roll()默认把所有音轨合在一起,而且时间分辨率是固定的 fs 参数,这个后面会单独说。先看怎么把 note 事件完整取出来:
import pretty_midi from pathlib import Path pm = pretty_midi.PrettyMIDI(str(Path("output_dir/xxx.mid"))) print(f"Ticks per beat: {pm.resolution}") print(f"Instruments: {len(pm.instruments)}") # 收集每个 instrument 的 note 事件 rows = [] for idx, inst in enumerate(pm.instruments): if inst.is_drum: continue # 打击乐轨道丢到一边 for note in inst.notes: rows.append({ "track": idx, "program": inst.program, "pitch": note.pitch, # 0-127 的 MIDI 音高值, 中央C是60 "velocity": note.velocity, # 力度, 0-127 "start": note.start, # 秒 "end": note.end }) print(f"Total notes: {len(rows)}")这段代码做了三件事:打印 MIDI 文件的 tick 分辨率、统计音轨数、把每个音符展开成一条记录。inst.program表示音色编号,一般钢琴是 0(Acoustic Grand Piano),但二创 MIDI 里经常出现 program 为 4(Electric Piano)或者干脆是 80 几的合成音色,说明扒谱者拿的模板可能不是原声钢琴。对钢琴数据集来说,program 不是关键,关键是音轨里有没有鼓轨(is_drum),以及是否有完全无音符的轨道——无音符轨道在二创 MIDI 里非常多,不删掉后面会污染统计。
2.3 把 tick 分辨率统一到 480,并映射到 88 键坐标
刚才打印的pm.resolution和文件里实际的 delta-time 值不一定是同一个东西。MIDI 文件规范里division定义的是每个四分音符的 tick 数,常见值是 480、960、384。二创 MIDI 主要是从 DAW 里导出的,Logic Pro 默认 960,FL Studio 常用 96 到 960 不等,Cubase 能设置成 480。如果不做归一化,后面做滑窗切片时,同一个“一拍”在不同文件里对应的 tick 数不一样,窗口切出来的内容就不是对齐的。
一个标准的做法是先把所有 note 的 start/end 从 tick 转成秒(pretty_midi 内部已经转了),然后在自己的 pipeline 里重采样成固定的谱面单位。我自己习惯先把所有音符转成“以 480 tick / 四分音符 为基准”的整数 tick 值:
import numpy as np TARGET_DIVISION = 480 def normalize_division(pm, midi_path): """将音符时间统一到 480 tick/四分音符 的整数刻度""" original_div = pm.resolution tempo_ratio = TARGET_DIVISION / original_div notes = [] for inst in pm.instruments: if inst.is_drum: continue for n in inst.notes: # 用 tick 单位做缩放 start_tick = int(round(n.start * original_div * tempo_ratio)) end_tick = int(round(n.end * original_div * tempo_ratio)) notes.append((start_tick, end_tick, n.pitch, n.velocity, inst.program)) return notes参数说明:tempo_ratio是当前分辨率到目标分辨率的缩放系数,乘在 ticks 上相当于不改变实际秒数,只改变量化精度。n.start * original_div先把秒转换回旧 tick 值,再乘tempo_ratio得到新 tick 值。这里用round()取整,意味着本来精确到 1/960 拍的音符,被量化到 1/480 拍。对钢琴改编曲来说这个精度损失完全可以忽略,人耳听不出,但换来的是生成的字典规模和模型输入维度大幅下降。
接下来是 88 键坐标映射。钢琴 MIDI 文件的音高值通常在 21(A0)到 108(C8)之间,但二创 MIDI 里偶尔会出现低于 21 或高于 108 的音,来源是部分 DAW 导出时把打击乐或其他乐器的事件混进来了。用下面这段过滤:
# 钢琴键位: MIDI 21-108, 对应 piano_keys[0]-piano_keys[87] VALID_PITCHES = set(range(21, 109)) filtered_notes = [n for n in notes if n[2] in VALID_PITCHES] # 检查过滤比例 before, after = len(notes), len(filtered_notes) if before - after > 0: print(f"Filtered {before - after} notes outside 88-key range")一个隐藏的坑:有些二创 MIDI 把踏板事件 CC64 也写进了音轨里面,pretty_midi的notes列表不包含控制事件,但pm.control_changes里有。如果后续要拿 note 序列训练生成模型,踏板事件可以忽略;但如果要做“钢琴卷帘 + 延音踏板”的联合建模,就得把 CC64 的状态按时间展开成每个 tick 的布尔值,这个复杂度超出本文范围,先不展开。
2.4 构建 piano roll 矩阵,并处理时值重叠
音乐生成任务里最常用的输入是 piano roll,也就是把音符事件转成一个二维矩阵,行是时间步,列是 88 个琴键(或 128 个 MIDI 音高),值代表力度。构造这个矩阵的代码不复杂,但有两个细节会直接影响数据质量:帧移大小和同音叠加。
import scipy.sparse as sp FS = 50 # 50 帧/秒, 每帧 20ms MAX_TICKS = max([n[1] for n in filtered_notes]) + 1 def build_piano_roll(notes, fs=FS): """notes: [(start_tick, end_tick, pitch, velocity, program)]""" tick_per_beat = 480 sec_per_beat = 60.0 / 120.0 # 默认 120 BPM, 如有 tempo map 需要单独读 tick_per_sec = tick_per_beat / sec_per_beat max_sec = MAX_TICKS / tick_per_sec n_frames = int(max_sec * fs) + 1 roll = np.zeros((n_frames, 88), dtype=np.float32) for start_t, end_t, pitch, vel, _ in filtered_notes: # 对 pitch 21..108 映射到 0..87 key = pitch - 21 s_frame = int((start_t / tick_per_sec) * fs) e_frame = int((end_t / tick_per_sec) * fs) e_frame = max(e_frame, s_frame + 1) # 至少占一帧 roll[s_frame:e_frame, key] = vel / 127.0 return roll参数说明:FS=50是帧率,20 毫秒一帧,这是音乐生成里比较通用的折中方案——16 分音符在 120BPM 下持续 125ms,占 6 帧左右,能保留清晰的音符边界;vel / 127.0把力度归一化到 0-1 区间,方便后续丢给神经网络。代码里临时把 BPM 写死成 120,这是偷懒行为,真实的二创 MIDI 里 BPM 几乎都不是整数,而且可能在曲子中途变速,正确做法是用pretty_midi.get_tempo_changes()读出 BPM 变化点,分段映射。
时值重叠的问题在于:同一个琴键连续弹两下,本来是两个独立的音符(attack-attack),但如果两个音符的时间帧没有间隙,build_piano_roll会把它们合并成一个长音符。这不算 bug,但对生成模型来说,它会学到“同音不分开弹”的模式,将来生成的曲子会特别粘。解决方式是给叠加音符做一个最小间隔强制分离:
NOTE_MIN_FRAMES = int(0.03 * FS) # 30ms 最短间隔 # 按 start 排序, 同 pitch 且前一个 end > 当前 start 时偏移 for i in range(1, len(filtered_notes)): prev = filtered_notes[i-1] curr = filtered_notes[i] if prev[2] == curr[2] and curr[0] < prev[1] and (prev[1] - curr[0]) < NOTE_MIN_FRAMES: filtered_notes[i] = (prev[1], curr[1], curr[2], curr[3], curr[4])这段逻辑很短,但它解决的是 MIDI 数据集里极常见的“粘连音符”问题,尤其在扒谱软件和 MIDI 键盘录制混在一起的数据集里,不加这步后面统计音符数和时长分布时误差很大。
3. 清洗出一份能用于训练和评估的钢琴标准子集
3.1 不用人工听,先用元数据做粗筛
几百个 MIDI 文件不可能全部人工听一遍。粗筛第一层看元数据:调号、拍号、轨数、总时长。二创钢琴曲里的调号五花八门,但多数改编曲为了照顾普通演奏者,会偏向 C 大调、G 大调这类少升降号的调。调号信息记录在pm.key_signature_changes里,不过这个字段对 MIDI 文件来说经常是空的,尤其从扒谱软件导出的 MIDI 很少写调号。真正可靠的是从音符的 pitch class 直方图估算调性:
import collections def estimate_key(notes): """根据音符的 pitch class 频率估算主调""" pc_counter = collections.Counter() for start_t, end_t, pitch, vel, _ in notes: pc = pitch % 12 pc_counter[pc] += vel # 力度加权, 响的音占比更大 total = sum(pc_counter.values()) return {pc: count / total for pc, count in pc_counter.items()}pitch % 12得到的是音高类别(C=0, C#=1, ..., B=11)。力度加权是为了让主旋律里的音在统计上压过伴奏。实际用的时候,我会把返回的分布和五线谱常用调号对一下,比如 C 大调对应 [0, 2, 4, 5, 7, 9, 11] 这几个类的占比超过 90%,那就基本可以判定是 C 调。这一步不要求完全精确,它的目的是把明显异常的文件找出来——比如所有音符集中在极端音区或者只有 3 个音高类别,这类文件大概率是损坏的或者扒谱失败的结果。
3.2 音符级质量阈值,比什么都好使
下面这张表是我处理钢琴 MIDI 数据集时的默认阈值,每个文件都要过一遍,不满足的直接丢到rejected/目录。阈值不是凭空拍出来的,是从几十个正常 MIDI 文件的统计分布里取的边界值:
| 指标 | 阈值 | 含义 |
|---|---|---|
| 音符总数 | 200 ~ 20000 | 太少是片段或损坏,太多可能是多轨重叠没分开 |
| 总时长 | 30s ~ 10min | 短于 30 秒的基本是试弹或音效,超过 10 分钟可能是串烧或循环未裁剪 |
| 单音轨占比 | > 60% 音符在同一条轨 | 左右手没分开,后续不好处理 |
| velocity 标准差 | > 4 | 力度完全没有变化的是电子导入序列,不怎么适合建模 |
| pitch 范围 | 跨度大于 40 个半音 | 一首钢琴改编曲很少超过 3 个八度 |
| 同 tick 重合音符数 | 均值 < 6 | 重合超过 6 个音大概率是未拆分的和弦块 |
这些检查在 Python 里用前面提取的rows列表一次性算完:
def quality_check(rows, duration): notes_arr = np.array([(r["start"], r["end"], r["pitch"], r["velocity"]) for r in rows]) vels = notes_arr[:, 3] pitches = notes_arr[:, 2] checks = { "note_count": len(notes_arr), "duration": duration, "velocity_std": float(np.std(vels)), "pitch_span": int(pitches.max() - pitches.min()), "overlap_mean": 0.0 } # 粗算重合音符数: 采样 100 个时间点, 数每个点上有多少音符 sample_points = np.linspace(notes_arr[:, 0].min(), notes_arr[:, 0].max(), 100) overlaps = [] for t in sample_points: mask = (notes_arr[:, 0] <= t) & (notes_arr[:, 1] >= t) overlaps.append(mask.sum()) checks["overlap_mean"] = float(np.mean(overlaps)) return checksnp.linspace(notes_arr[:, 0].min(), notes_arr[:, 0].max(), 100)是在音符起始和结束之间的时间轴上均匀取 100 个点,然后对每个点统计活跃音符数。采样 100 个点而不是每个 tick 都算,是为了控制计算量,几十万音符的文件也不用担心性能。overlap_mean如果超过 6,说明这个 MIDI 很可能是左手部分和右手部分合并导出但没有清除原来的和弦伴奏轨,训练时会让生成模型学到一堆又密又乱的和声。
3.3 多轨合并与左右手拆分
通过质量检查之后,下一步是把多轨标准化成一条钢琴轨。二创 MIDI 的轨道组织方式大致有三种:左右手分轨(右手旋律 + 左手伴奏)、双手在一轨、多轨混合(旋律轨 + 伴奏轨 + 加花轨)。训练时统一成单轨最简单,但单轨意味着模型要自己学会区分旋律和伴奏的内部结构。另一种做法是按音高拆左右手,用 C4(MIDI 60)做分界线,60 及以上算右手,以下算左手:
def split_hands(notes, split_pitch=60): right = [] left = [] for start_t, end_t, pitch, vel, _ in notes: if pitch >= split_pitch: right.append((start_t, end_t, pitch, vel)) else: left.append((start_t, end_t, pitch, vel)) return right, leftsplit_pitch=60是一个足够鲁棒的默认值。真正的钢琴谱里左手偶尔会弹到中央 C 以上,右手伴奏会落在大字组,纯按音高切不可能百分之百准确,但对于做数据统计、预训练、以及大部分生成任务来说,这个简单拆分已经能让模型学习到“右高左低”的基本空间关系。把拆分结果写回新的 MIDI 文件,用miditoolkit比pretty_midi方便一点,因为它的MidiFile对象支持直接添加 track 并且能保留 tick 精度:
import miditoolkit def save_split_midi(right_notes, left_notes, output_path, division=480): m = miditoolkit.MidiFile(ticks_per_beat=division) for note_list, program in [(right_notes, 0), (left_notes, 0)]: track = miditoolkit.Instrument(program=program, is_drum=False) for start_t, end_t, pitch, vel in note_list: n = miditoolkit.Note( pitch=pitch, start=start_t, end=end_t, velocity=int(vel * 127), ) track.notes.append(n) m.instruments.append(track) m.dump(output_path)注意miditoolkit.Note的start和end是 tick,不是秒,这一点跟pretty_midi正好相反。velocity需要 int 且范围 0-127,前面如果已经归一化到 0-1,这里要乘回 127。整理完的 MIDI 建议单独放一个目录,文件名沿用“作曲者_曲名_调号”这种结构,比如Chen_opening_C.mid,命名习惯在数据集复用时的价值比想象中高,因为半年后再翻回来,文件名本身就带着筛选信息。
4. 把清洗后的数据变成可训练的样本:切片、增强与基线生成
4.1 滑窗切片和音高平移增强
钢琴曲一首动辄三分钟,直接整首丢进深度学习模型不现实。通用做法是切成 5 到 10 秒的片段,片段之间重叠 50%。切片时不需要做静音检测,因为钢琴改编曲通常从头到尾都有音符,静音检测反而会把弱起和休止符破坏掉。有一个参数需要注意:切片的边界要落在音符起点上,而不是落在任意帧位置,否则第一个音符会被截成不完整的残音。
def slice_piano_roll(roll, fs=50, segment_seconds=8.0, hop_seconds=4.0): seg_frames = int(segment_seconds * fs) hop_frames = int(hop_seconds * fs) if roll.shape[0] < seg_frames: # 长度不够的片段做零填充到目标长度 padded = np.zeros((seg_frames, roll.shape[1]), dtype=np.float32) padded[:roll.shape[0]] = roll return [padded] slices = [] for start in range(0, roll.shape[0] - seg_frames + 1, hop_frames): seg = roll[start:start + seg_frames] slices.append(seg) if roll.shape[0] % hop_frames != 0: slices.append(roll[-seg_frames:]) return sliceshop_seconds=4.0表示相邻两个切片起始点相隔 4 秒,切片本身 8 秒,重叠率 50%。重叠切片能让模型在训练时看到音符从中间进入的情况,增强泛化性,代价是同一个音符出现在多个训练样本里,模型可能会记样本而不是学规律。这个权衡在数据量不足一万条时是值得的,等数据规模上去再改成非重叠切片。
音高平移是钢琴数据增强最划算的一招。把一首曲子整体上下移调,生成一个新样本,对模型来说是完全不同的旋律线条,但音乐结构保持不变。实现方式就是给所有音符的 pitch 加上一个偏移量,注意不要超出 21-108 的范围:
def pitch_shift_notes(notes, shift, min_pitch=21, max_pitch=108): new_notes = [] for start_t, end_t, pitch, vel, _ in notes: new_pitch = pitch + shift if new_pitch < min_pitch or new_pitch > max_pitch: return [] # 该段转移超出有效范围, 放弃 new_notes.append((start_t, end_t, new_pitch, vel, 0)) return new_notesshift的取值范围一般取 -5 到 +5 的半音,超过 5 个半音之后生成的音乐听起来会和原曲差异过大,而且左手部分容易掉出有效钢琴音域。这个方法用在序列模型和生成模型里都成立,但用在调性分析任务上反而有害,所以增强手段要和你最终的目标任务匹配。
4.2 一个能跑通的最小生成基线:从 piano roll 到 LSTM
数据切好之后,先别急着炼丹。我会先用一个很小的 LSTM 模型验证数据流有没有问题——这个模型不是为了 SOTA,就是跑通一遍 forward/backward,确保 piano roll 的形状、取值、batch 拼接全部正确。下面是一个最小可跑的 PyTorch 定义:
import torch import torch.nn as nn class PianoLSTMBaseline(nn.Module): def __init__(self, input_size=88, hidden_size=128, num_layers=2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.fc = nn.Linear(hidden_size, 88) def forward(self, x): # x: (batch, time, 88) out, _ = self.lstm(x) logits = self.fc(out) # (batch, time, 88) return logits训练这个模型时,输入是 8 秒的 piano roll 片段(时间维度 400 帧),输出是下一个时间步的 88 个键值概率。loss 用BCEWithLogitsLoss,因为每一帧的每个键是一个二分类问题(按或不按),而不是 88 类互斥分类。这里最容易犯的错误是用CrossEntropyLoss,它假设只有一个键是正类,但钢琴曲同时按 3 到 5 个键是常态,必须按多标签处理。
训练循环的 batch 处理中,还有一个pack_padded_sequence的问题:不同 MIDI 切出来的片段长度是相同的(因为滑窗固定 400 帧),不需要打包;但如果你直接拿整首曲子做输入,曲子长度各不相同,就必须 pack。建议在基线阶段只用固定长度切片,等到调 batch 和 lr 都稳定了,再引入变长逻辑。
4.3 把概率输出还原成可演奏的 MIDI
模型输出的 400x88 矩阵是一堆概率值,要变成能播放的音乐,得做两步后处理:阈值化和音符粘连。阈值化是把概率大于某个值(比如 0.4)的帧当成激活,然后沿时间轴把连续的激活帧合并成一个音符事件:
def logits_to_notes(logits, threshold=0.4, min_duration_frames=2): """ logits: ndarray shape (n_frames, 88) 返回列表: [(start_tick, end_tick, pitch), ...] """ binary = (logits > threshold).astype(int) notes = [] for key in range(88): pitch = key + 21 frame_indices = np.where(binary[:, key] == 1)[0] if len(frame_indices) == 0: continue # 按连续性分段 split_points = np.where(np.diff(frame_indices) > 1)[0] segments = np.split(frame_indices, split_points + 1) for seg in segments: if len(seg) < min_duration_frames: continue start_frame = int(seg[0]) end_frame = int(seg[-1]) # 折算成 tick, 这里用 50fs * 480tick 转换 start_tick = int((start_frame / 50.0) * 120 * 480) end_tick = int(((end_frame + 1) / 50.0) * 120 * 480) notes.append((start_tick, end_tick, pitch)) return notesnp.diff(frame_indices) > 1是找断裂点,也就是当前帧索引和下一个帧索引相差大于 1 的地方,说明中间有空帧,一个音符结束了。min_duration_frames=2表示持续至少两帧(40ms)的音符才保留,过滤掉模型偶然冒出的一闪而过的杂音。到这一步,数据已经跑通了从 rar 到模型输出 MIDI 的完整链路。
5. 回放验证:把生成的或清洗后的 MIDI 转成音频、对齐原曲、量化误差
处理完的音乐不亲耳听一遍,永远不知道问题在哪。一个可靠的验证流程包括三段:MIDI 转音频回放、与原曲做时间对齐、统计音符级误差。
MIDI 转音频最简单的路子是 fluidsynth,加一个 SF2 音色库直接合成 WAV:
fluidsynth -a alsa -g 1.5 -F output.wav /usr/share/sounds/sf2/FluidR3_GM.sf2 input_piano.midi参数说明:-a alsa是音频驱动,在 Linux 上选 ALSA;-g 1.5是增益因子,合成音量不够时增大到 2.0 或 3.0;-F output.wav表示非交互式导出,没有它 fluidsynth 会进入实时演奏模式等待键盘输入。如果不指定-F,命令会挂在终端不结束,这是最常见的坑。
转出音频之后,如果是验证模型生成曲子的质量,需要和原曲做过采样对齐。用librosa的dtw计算两条音频的相似路径,能直观看到生成曲子和原曲在时间轴上的偏差分布:
import librosa import numpy as np def align_and_compute_f1(gen_audio, ref_audio, sr=22050): # 提取 chroma 特征,音高特征对钢琴曲鲁棒性最高 gen_chroma = librosa.feature.chroma_cqt(y=gen_audio, sr=sr) ref_chroma = librosa.feature.chroma_cqt(y=ref_audio, sr=sr) # 统一长度——先做时间归一化 target_len = min(gen_chroma.shape[1], ref_chroma.shape[1]) gen_chroma = gen_chroma[:, :target_len] ref_chroma = ref_chroma[:, :target_len] # 对每帧取最大响度的音高类别 gen_idx = gen_chroma.argmax(axis=0) ref_idx = ref_chroma.argmax(axis=0) # 计算逐帧一致率 accuracy = (gen_idx == ref_idx).mean() return accuracychroma_cqt把音频映射到 12 个音高类别上,忽略八度信息,钢琴曲的旋律和伴奏在这个特征空间里都有较强的区分度。逐帧argmax后取“最多能量的音高类别”做对比,能算出一个粗糙的准确率。这个数值不是官方评测指标,但用来在数据集内部做排序够了——把清洗后的 MIDI 按这个准确率从高到低排,前 80% 的质量基本可靠,后 20% 需要人工复查。
最后一个常用技巧是查“幽灵音”。MIDI 清洗过程中常遇到一些音符,在钢琴 roll 里画出来是一堆极短促的碎片,时长只有 1 到 2 帧,声学上表现为一串无意义的咚咚声。用一段简单逻辑批量排除:
def reject_ghost_notes(notes, min_tick_duration=20): """去掉时值过短的音符, 20 tick 在 480 division 下约等于 1/24 拍""" return [n for n in notes if n[1] - n[0] >= min_tick_duration]min_tick_duration=20对应 480 分辨率下的约 41ms(120BPM),普通钢琴演奏最短的装饰音也在 60ms 以上,低于这个值的音符几乎可以断定是数据噪音。这个清理动作做完整套流程就闭环了:解压、解析、清洗、切片、训练、回听,每一步的参数都能追溯到具体文件,下次再有人丢给你一个同类的“某某数据集.rar”,你能在两小时内跑完同样的流水线。
本文还有配套的精品资源,点击获取