回归后处理与 MIDI 音符重建
本页介绍 piano_transcription 系统中的"高分辨率回归后处理"(high-resolution regression post processing)子系统:如何将 CRNN 模型输出的逐帧、逐音高(88 键)的回归概率张量转换为带子帧级时间精度的音符/踏板事件,并最终重建为 MIDI 文件。
Purpose and Scope
本页覆盖以下内容(端到端的后处理链路):
PianoTranscription.transcribe中后处理器的选择与装配(regressionvsonsets_frames)- 核心算法文件 utils/piano_vad.py:
note_detection_with_onset_offset_regress、pedal_detection_with_onset_offset_regress,以及用于对比的 Google onsets-and-frames 后处理onsets_frames_note_detection/onsets_frames_pedal_detection - utils/utilities.py 中的
RegressionPostProcessor/OnsetsFramesPostProcessor/write_events_to_midi的接口契约(由调用方 pytorch/inference.py 与 utils/plot_for_paper.py 的使用方式佐证) - 阈值配置、边界情况(连续 onset、offset 缺失、超长音符等)与性能特征
有意留给兄弟页面的内容:
- 模型结构(
Note_pedalCRNN)与训练损失 → 参见模型与训练相关页面 enframe/deframe分段推理细节 → 参见推理流水线页面(本页仅在流程图中带过)- 训练侧目标构造(
TargetProcessor)→ 参见数据与目标处理页面
Overview
系统的输出头都是逐帧、逐 88 音高的张量(踏板头为 (frames, 1)),包含:
| 输出头 | 形状 | 语义 |
|---|---|---|
reg_onset_output | (frames_num, 88) | onset 回归激活值(后续与阈值比较二值化) |
reg_offset_output | (frames_num, 88) | offset 回归激活值 |
frame_output | (frames_num, 88) | 帧级"音符持续"概率 |
velocity_output | (frames_num, 88) | 归一化力度(0~1,见下文 normalized_velocity) |
reg_pedal_onset_output / reg_pedal_offset_output | (frames_num, 1) | 踏板 onset/offset 回归激活值 |
pedal_frame_output | (frames_num, 1) | 踏板帧级概率 |
形状证据见 pytorch/inference.py 中的注释。
核心设计意图(WHY):帧级预测的时间分辨率受限于 frames_per_second。若只取帧索引作为 onset/offset 时刻,会有 ±0.5 帧的量化误差。因此模型额外预测每个帧上的回归偏移量(shift),后处理把 帧索引 + shift 组合起来,把时间精度提升到子帧级别;同时用 frame_output 作为"音符还在响"的证据、用 offset_output 作为"音符被释放"的证据,两者仲裁出更可靠的 offset。这就是论文中"high-resolution"的来源,也是 post_processor_type='regression'(提出的方法)与 'onsets_frames'(Google 基线,仅用于对比)的本质差异(见 pytorch/inference.py 的注释)。
Architecture
图中虚线关系由 utils/utilities.py 顶部从 piano_vad 导入这四个检测函数这一事实佐证——RegressionPostProcessor / OnsetsFramesPostProcessor 是把这些一维序列检测函数装配到 (frames, 88) 矩阵与事件字典上的封装层。实线部分(transcribe 内的选择与调用顺序)逐行取自 pytorch/inference.py。
层级职责:
- 推理层(
PianoTranscription.transcribe):负责分段、前向、拼回全长,并按post_processor_type实例化后处理器。 - 后处理器层(
utilities.py):持有阈值与frames_per_second、classes_num,把output_dict转成est_note_events/est_pedal_events事件列表。 - 检测算法层(
piano_vad.py):纯 NumPy 的单音高序列状态机,输出[bgn, fin, onset_shift, offset_shift, normalized_velocity]元组。 - MIDI 写出层(
write_events_to_midi):把事件列表落盘为 MIDI(接口见 pytorch/inference.py:start_time, note_events, pedal_events, midi_path)。
Core Flow:音符事件检测状态机
note_detection_with_onset_offset_regress 是整个子系统的算法核心。它对单个音高的 (frames_num,) 一维序列做单遍扫描状态机,维护三个状态:bgn(当前音符起始帧)、frame_disappear(帧概率首次跌破阈值的帧)、offset_occur(offset 头首次激活的帧)。
关键实现(含设计意图注释):
1def note_detection_with_onset_offset_regress(frame_output, onset_output,
2 onset_shift_output, offset_output, offset_shift_output, velocity_output,
3 frame_threshold):
4 output_tuples = []
5 bgn = None
6 frame_disappear = None
7 offset_occur = None
8
9 for i in range(onset_output.shape[0]):
10 if onset_output[i] == 1:
11 """Onset detected"""
12 if bgn:
13 """Consecutive onsets. E.g., pedal is not released, but two
14 consecutive notes being played."""
15 fin = max(i - 1, 0)
16 output_tuples.append([bgn, fin, onset_shift_output[bgn],
17 0, velocity_output[bgn]])
18 frame_disappear, offset_occur = None, None
19 bgn = iSource: piano_vad.py
为什么连续 onset 需要特殊处理:踏板踩住或连奏时,frame_output 不会跌破阈值、offset_output 也可能不激活,导致上一个音符找不到自然终点。此时用新 onset 的前一帧强制闭合旧音符,offset_shift 记为 0(因为在 onset 处没有可靠的 offset 证据),然后立即开新音符。这保证了在连奏下不会把两个音符粘连成一个长音。
offset 搜索与仲裁逻辑("frame 证据"与"offset 证据"取更近者):
1 if bgn and i > bgn:
2 """If onset found, then search offset"""
3 if frame_output[i] <= frame_threshold and not frame_disappear:
4 """Frame disappear detected"""
5 frame_disappear = i
6
7 if offset_output[i] == 1 and not offset_occur:
8 """Offset detected"""
9 offset_occur = i
10
11 if frame_disappear:
12 if offset_occur and offset_occur - bgn > frame_disappear - offset_occur:
13 """bgn --------- offset_occur --- frame_disappear"""
14 fin = offset_occur
15 else:
16 """bgn --- offset_occur --------- frame_disappear"""
17 fin = frame_disappear
18 output_tuples.append([bgn, fin, onset_shift_output[bgn],
19 offset_shift_output[fin], velocity_output[bgn]])
20 bgn, frame_disappear, offset_occur = None, None, None
21
22 if bgn and (i - bgn >= 600 or i == onset_output.shape[0] - 1):
23 """Offset not detected"""
24 fin = i
25 output_tuples.append([bgn, fin, onset_shift_output[bgn],
26 offset_shift_output[fin], velocity_output[bgn]])
27 bgn, frame_disappear, offset_occur = None, None, None
28
29 # Sort pairs by onsets
30 output_tuples.sort(key=lambda pair: pair[0])
31
32 return output_tuplesSource: piano_vad.py
仲裁规则逐条解读:
frame_disappear一旦出现就锁定(not frame_disappear守卫),避免后续噪声帧反复改写候选位置——这体现了"首次跌破"语义而非"任意跌破"。- 比较
offset_occur - bgn > frame_disappear - offset_occur(都相对bgn/中点归一)即"offset 激活更靠近frame_disappear时选offset_occur",否则选frame_disappear。选更晚且被两个证据支持的位置,可减少"帧概率提前衰减导致音符被截短"的偏差。 600帧兜底分支:极长音符(约 600 / 100 = 6 秒,frames_per_second=100)若始终未检出 offset,则在当前帧截断,防止一个 onset 永久吞掉整段后续音频;扫描最后一帧同样强制闭合,防止末尾悬空音符丢失。- 输出元组最后按
pair[0](onset 帧)排序,保证后续 MIDI 写出时 note-on 事件时间单调。
Usage Examples
推理侧:后处理器的选择与调用
1 # Post processor
2 if self.post_processor_type == 'regression':
3 """Proposed high-resolution regression post processing algorithm."""
4 post_processor = RegressionPostProcessor(self.frames_per_second,
5 classes_num=self.classes_num, onset_threshold=self.onset_threshold,
6 offset_threshold=self.offset_threshod,
7 frame_threshold=self.frame_threshold,
8 pedal_offset_threshold=self.pedal_offset_threshold)
9
10 elif self.post_processor_type == 'onsets_frames':
11 """Google's onsets and frames post processing algorithm. Only used
12 for comparison."""
13 post_processor = OnsetsFramesPostProcessor(self.frames_per_second,
14 self.classes_num)
15
16 # Post process output_dict to MIDI events
17 (est_note_events, est_pedal_events) = \
18 post_processor.output_dict_to_midi_events(output_dict)
19
20 # Write MIDI events to file
21 if midi_path:
22 write_events_to_midi(start_time=0, note_events=est_note_events,
23 pedal_events=est_pedal_events, midi_path=midi_path)
24 print('Write out to {}'.format(midi_path))Source: inference.py
注意 offset_threshold=self.offset_threshod——self.offset_threshod 是 inference.py 中实际的属性名(拼写少了一个 l,与 self.onset_threshold 不同),扩展/修改代码时需保持一致,否则会触发 AttributeError。
调用方式二:MIDI 事件的结构约定
从 transcribe 返回值与调用方代码可确认事件字典的键(onset_time、offset_time、midi_note,踏板事件为 onset_time/offset_time):
ref_on_off_pairs = np.array([[event['onset_time'], event['offset_time']] for event in note_events])
ref_midi_notes = np.array([event['midi_note'] for event in note_events])Source: plot_for_paper.py
即:RegressionPostProcessor.output_dict_to_midi_events 返回的 est_note_events 是形如 {'onset_time': float, 'offset_time': float, 'midi_note': int, (velocity)} 的字典列表;est_pedal_events 为 {'onset_time', 'offset_time'} 列表,最终由 write_events_to_midi 转成 MIDI note-on/note-off 与延音踏板(CC64)控制事件。output_dict_to_midi_events(output_dict) -> (note_events, pedal_events) 的签名由上面的调用点直接佐证。
对比基线:Google onsets-and-frames 后处理
1def onsets_frames_note_detection(frame_output, onset_output, offset_output,
2 velocity_output, threshold):
3 """Process pedal prediction matrices to note events information. onset_ouput
4 is used to detect the presence of notes. frame_output is used to detect the
5 offset of notes."""
6 output_tuples = []
7
8 loct = None
9 for i in range(onset_output.shape[0]):
10 # Use onset_output is used to detect the presence of notes
11 if onset_output[i] > threshold:
12 if loct:
13 output_tuples.append([loct, i, velocity_output[loct]])
14 loct = i
15 if loct and i > loct:
16 # Use frame_output is used to detect the offset of notes
17 if frame_output[i] <= threshold:
18 output_tuples.append([loct, i, velocity_output[loct]])
19 loct = None
20
21 output_tuples.sort(key=lambda pair: pair[0])
22
23 return output_tuplesSource: piano_vad.py
对比可见回归版多了三个输入(onset_shift_output、offset_output、offset_shift_output),且输出元组携带两个 shift 值;基线版完全依赖 onset 激活切分、用帧概率低于阈值终止,没有任何子帧修正——这正是两套后处理在指标上(如 reg_onset_mae / reg_offset_mae,见 plot_statistics.py)拉开差距的原因。
Core Flow:踏板事件检测状态机
pedal_detection_with_onset_offset_regress 与音符版共用同一套"bgn / frame_disappear / offset_occur"状态机骨架,但有三处关键差异,全部源于踏板的物理特性:
1def pedal_detection_with_onset_offset_regress(frame_output, offset_output,
2 offset_shift_output, frame_threshold):
3 output_tuples = []
4 bgn = None
5 frame_disappear = None
6 offset_occur = None
7
8 for i in range(1, frame_output.shape[0]):
9 if frame_output[i] >= frame_threshold and frame_output[i] > frame_output[i - 1]:
10 """Pedal onset detected"""
11 if bgn:
12 pass
13 else:
14 bgn = i
15
16 if bgn and i > bgn:
17 """If onset found, then search offset"""
18 if frame_output[i] <= frame_threshold and not frame_disappear:
19 """Frame disappear detected"""
20 frame_disappear = i
21
22 if offset_output[i] == 1 and not offset_occur:
23 """Offset detected"""
24 offset_occur = i
25
26 if offset_occur:
27 fin = offset_occur
28 output_tuples.append([bgn, fin, 0., offset_shift_output[fin]])
29 bgn, frame_disappear, offset_occur = None, None, None
30
31 if frame_disappear and i - frame_disappear >= 10:
32 """offset not detected but frame disappear"""
33 fin = frame_disappear
34 output_tuples.append([bgn, fin, 0., offset_shift_output[fin]])
35 bgn, frame_disappear, offset_occur = None, None, None
36
37 # Sort pairs by onsets
38 output_tuples.sort(key=lambda pair: pair[0])
39
40 return output_tuplesSource: piano_vad.py
- 踏板 onset 不使用 onset 头,而是"上升沿检测":
frame_output[i] >= frame_threshold and frame_output[i] > frame_output[i - 1]。踏板是单通道((frames, 1))连续压/放动作,帧概率的上升沿比独立 onset 头更稳。 - onset 不打断进行中的事件(
if bgn: pass):踏板不能像音符那样"同键连击",上升沿检测中若已有bgn则忽略,避免抖动重复开段。 - 关闭条件不同:
offset_occur一旦出现立即闭合(不需frame_disappear佐证);而仅frame_disappear时要等10帧(0.1 s)确认才闭合——踏板可能被短暂半抬再压回去,这 10 帧是抗抖动的确认窗口。onset_shift恒为0.,因为上升沿本身就在 onset 帧上。
端到端时序:从音频到 MIDI
序列中的补零、分帧、deframe 细节取自 pytorch/inference.py(transcribe 主体)与 pytorch/inference.py(enframe/deframe 实现)。deframe 之所以丢掉每段最后一帧(center=True 的频谱额外帧)再取每段中间 50%,是为了消除分段边界处帧对齐误差,避免后处理在拼接缝上产生伪 onset/offset——这是分段推理与逐帧后处理耦合时必须处理的工程细节。
Configuration Options
后处理行为完全由推理入口的四个阈值控制(定义于 PianoTranscription.__init__):
| 选项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
post_processor_type | str | 'regression' | 选择后处理器:'regression'(本文的高分辨率算法)或 'onsets_frames'(Google 基线,仅对比用) |
onset_threshold | float | 0.3 | onset 头二值化阈值,决定何时开音符段 |
offset_threshod | float | 0.3 | offset 头二值化阈值(注意属性名拼写,见上文提醒) |
frame_threshold | float | 0.1 | frame_output 低于该值视为"音符不再持续"(frame_disappear) |
pedal_offset_threshold | float | 0.2 | 踏板 offset 头阈值(仅在踏板通道生效) |
frames_per_second | int | config.frames_per_second | 帧率,用于帧索引↔秒换算(隐含 100) |
classes_num | int | config.classes_num | 音高类别数 = 88(隐含) |
segment_samples | int | 16000*10 | 分段推理的段长(10 s),影响 enframe/deframe 而非后处理本身 |
Source: inference.py
调参直觉:onset_threshold 偏低→召回高但易把噪声当音符;frame_threshold 偏高→frame_disappear 更早出现,音符更容易被截短;offset_threshod/pedal_offset_threshold 偏低→offset 频繁激活,长音易被切碎。
API Reference
note_detection_with_onset_offset_regress(frame_output, onset_output, onset_shift_output, offset_output, offset_shift_output, velocity_output, frame_threshold)
单音高一维序列 → 音符事件元组列表。
Parameters:
frame_output(ndarray, shape(frames_num,)):帧级持续概率onset_output(ndarray, shape(frames_num,)):onset 二值激活(调用前应已阈值化,循环中直接判== 1)onset_shift_output(ndarray, shape(frames_num,)):onset 子帧回归偏移offset_output(ndarray, shape(frames_num,)):offset 二值激活offset_shift_output(ndarray, shape(frames_num,)):offset 子帧回归偏移velocity_output(ndarray, shape(frames_num,)):归一化力度frame_threshold(float):frame_disappear判定阈值
Returns: list of [bgn, fin, onset_shift, offset_shift, normalized_velocity],按 bgn 升序,例如 [[1821, 1909, 0.47498, 0.3048533, 0.72119445], ...]。
Source: piano_vad.py
pedal_detection_with_onset_offset_regress(frame_output, offset_output, offset_shift_output, frame_threshold)
Parameters: 同上去掉 onset_output/onset_shift_output/velocity_output;frame_output、offset_output 为踏板单通道序列。
Returns: list of [bgn, fin, onset_shift=0., offset_shift]。
Source: piano_vad.py
RegressionPostProcessor(frames_per_second, classes_num, onset_threshold, offset_threshold, frame_threshold, pedal_offset_threshold).output_dict_to_midi_events(output_dict)
把 7 个输出头的字典转换成事件列表。签名与调用点见 pytorch/inference.py(关键字参数名以调用点为准:frames_per_second, classes_num, onset_threshold, offset_threshold, frame_threshold, pedal_offset_threshold)。
Returns: (note_events, pedal_events),note_events 元素含 onset_time / offset_time / midi_note(可能含 velocity),pedal_events 元素含 onset_time / offset_time(见上文"事件结构约定")。
write_events_to_midi(start_time, note_events, pedal_events, midi_path)
事件列表 → MIDI 文件。调用点见 pytorch/inference.py。
onsets_frames_note_detection(frame_output, onset_output, offset_output, velocity_output, threshold) / onsets_frames_pedal_detection(frame_output, offset_output, frame_threshold)
Google 基线对比实现,返回 [loct, i, velocity](音符)与 [bgn, fin](踏板)元组。Source: piano_vad.py。
Failure Modes, Edge Cases & Concurrency
边界与失败模式已在源码中显式处理,汇总如下:
| 场景 | 源码行为 | 设计意图 |
|---|---|---|
| 连续 onset(踏板未放/连奏) | 用 fin = max(i-1, 0) 立即闭合旧音符,offset_shift 记 0 | 防止音符粘连;max(i-1,0) 防 bgn=0 时 fin=-1 负索引 |
| offset 头与帧证据冲突 | 取相对中点更近的候选(offset_occur 需同时被 frame_disappear 佐证) | 双证据仲裁,降低单一头噪声影响 |
| 极长音符无 offset | i - bgn >= 600 帧后强制闭合 | 约 6 s 兜底,防一个 onset 吞掉整段音频 |
| 序列结尾音符未闭合 | i == onset_output.shape[0] - 1 强制闭合 | 防末尾音符丢失 |
| 踏板上升沿抖动 / 半抬 | 仅 frame_disappear 时需再等 10 帧(0.1 s)确认 | 抗抖动确认窗口 |
踏板已有 bgn 时再次上升 | if bgn: pass 忽略 | 踏板不会"连击",忽略重复开段 |
onset_output/offset_output 是概率而非二值 | 循环直接判 == 1 | 调用方(后处理器层)需先做阈值化;否则永远不触发 |
| 多段拼接缝 | deframe 丢末帧 + 取 25%~75% 中段 | 消除 center=True 频谱导致的帧错位伪事件 |
并发方面:检测函数是纯 NumPy 单线程、无共享状态(所有状态都是局部变量),因此天然线程安全、可对 88 个音高并行切片处理;piano_vad.py 全文件只 import numpy as np,无任何 I/O 或全局状态,这也使其易于在评估(calculate_score_for_paper.py)与可视化(plot_for_paper.py)中复用。
Professional Notes
性能:note_detection_with_onset_offset_regress 是 O(frames) 单遍扫描、常数额外内存。以 frames_per_second=100、5 分钟音频计,每个音高 3 万帧 × 88 音高 ≈ 264 万次纯 Python 循环——这是后处理的主要耗时点,远高于 NumPy 向量化的帧概率计算;工程上若要优化,可将状态机向量化或用 Numba/Cython,但需保持"首次跌破锁定 + 600 帧兜底 + 末帧闭合"的语义完全一致。
评估联动:训练/评估曲线里的 reg_onset_mae、reg_offset_mae、velocity_mae、reg_pedal_onset_mae、reg_pedal_offset_mae 等指标(见 plot_statistics.py)正是对本文档的后处理输出(子帧级 onset/offset 与力度)的度量;onsets_frames 系列为对比基线指标。
Extension Points:
- 新增后处理策略:仿照
inference.py中post_processor_type的 if/elif 分支增加新类型,并实现output_dict_to_midi_events(output_dict) -> (note_events, pedal_events)这一鸭子类型接口即可接入。 - 阈值自适应:四个阈值目前是
PianoTranscription.__init__的实例属性,可按乐曲/音频级别统计(如 onset 激活分布)自适应调整,而不必改动piano_vad.py状态机。 - 复用检测算法:
piano_vad.py中四个函数均不依赖模型或张量形状,只要提供一维(frames,)序列即可复用(plot_for_paper.py即以这种方式复用后处理器做论文绘图)。
已知源码细节:RegressionPostProcessor / OnsetsFramesPostProcessor / write_events_to_midi 的完整实现体位于 utils/utilities.py(该文件超过 1300 行),本次源码浏览受预算限制仅核对了其导入关系与全部调用点契约;上述 API 签名与返回结构均由调用方源码佐证,类内部实现细节(如逐音高切片的具体写法、velocity 反归一化)未在本文档中逐行验证。
Related Links
- pytorch/inference.py —
PianoTranscription推理入口、阈值与后处理器装配 - utils/piano_vad.py — 回归后处理核心状态机算法
- utils/utilities.py —
RegressionPostProcessor/OnsetsFramesPostProcessor/write_events_to_midi所在文件 - predict.py — 命令行预测入口(调用
PianoTranscription) - utils/plot_for_paper.py — 后处理器与
TargetProcessor的复用示例(事件字典结构佐证) - utils/plot_statistics.py — 与后处理输出对应的评估指标类型