Repository Wiki
bytedance/piano_transcription

回归后处理与 MIDI 音符重建

本页介绍 piano_transcription 系统中的"高分辨率回归后处理"(high-resolution regression post processing)子系统:如何将 CRNN 模型输出的逐帧、逐音高(88 键)的回归概率张量转换为带子帧级时间精度的音符/踏板事件,并最终重建为 MIDI 文件。

Purpose and Scope

本页覆盖以下内容(端到端的后处理链路):

  • PianoTranscription.transcribe 中后处理器的选择与装配(regression vs onsets_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_pedal CRNN)与训练损失 → 参见模型与训练相关页面
  • 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

Loading diagram...

图中虚线关系由 utils/utilities.py 顶部从 piano_vad 导入这四个检测函数这一事实佐证——RegressionPostProcessor / OnsetsFramesPostProcessor 是把这些一维序列检测函数装配到 (frames, 88) 矩阵与事件字典上的封装层。实线部分(transcribe 内的选择与调用顺序)逐行取自 pytorch/inference.py。

层级职责:

  1. 推理层(PianoTranscription.transcribe):负责分段、前向、拼回全长,并按 post_processor_type 实例化后处理器。
  2. 后处理器层(utilities.py):持有阈值与 frames_per_second、classes_num,把 output_dict 转成 est_note_events / est_pedal_events 事件列表。
  3. 检测算法层(piano_vad.py):纯 NumPy 的单音高序列状态机,输出 [bgn, fin, onset_shift, offset_shift, normalized_velocity] 元组。
  4. 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 头首次激活的帧)。

Loading diagram...

关键实现(含设计意图注释):

python
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 = i

Source: piano_vad.py

为什么连续 onset 需要特殊处理:踏板踩住或连奏时,frame_output 不会跌破阈值、offset_output 也可能不激活,导致上一个音符找不到自然终点。此时用新 onset 的前一帧强制闭合旧音符,offset_shift 记为 0(因为在 onset 处没有可靠的 offset 证据),然后立即开新音符。这保证了在连奏下不会把两个音符粘连成一个长音。

offset 搜索与仲裁逻辑("frame 证据"与"offset 证据"取更近者):

python
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_tuples

Source: 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

推理侧:后处理器的选择与调用

python
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):

python
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 后处理

python
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_tuples

Source: 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"状态机骨架,但有三处关键差异,全部源于踏板的物理特性:

python
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_tuples

Source: piano_vad.py

  1. 踏板 onset 不使用 onset 头,而是"上升沿检测":frame_output[i] >= frame_threshold and frame_output[i] > frame_output[i - 1]。踏板是单通道((frames, 1))连续压/放动作,帧概率的上升沿比独立 onset 头更稳。
  2. onset 不打断进行中的事件(if bgn: pass):踏板不能像音符那样"同键连击",上升沿检测中若已有 bgn 则忽略,避免抖动重复开段。
  3. 关闭条件不同:offset_occur 一旦出现立即闭合(不需 frame_disappear 佐证);而仅 frame_disappear 时要等 10 帧(0.1 s)确认才闭合——踏板可能被短暂半抬再压回去,这 10 帧是抗抖动的确认窗口。onset_shift 恒为 0.,因为上升沿本身就在 onset 帧上。

端到端时序:从音频到 MIDI

Loading diagram...

序列中的补零、分帧、deframe 细节取自 pytorch/inference.py(transcribe 主体)与 pytorch/inference.py(enframe/deframe 实现)。deframe 之所以丢掉每段最后一帧(center=True 的频谱额外帧)再取每段中间 50%,是为了消除分段边界处帧对齐误差,避免后处理在拼接缝上产生伪 onset/offset——这是分段推理与逐帧后处理耦合时必须处理的工程细节。

Configuration Options

后处理行为完全由推理入口的四个阈值控制(定义于 PianoTranscription.__init__):

选项类型默认值作用
post_processor_typestr'regression'选择后处理器:'regression'(本文的高分辨率算法)或 'onsets_frames'(Google 基线,仅对比用)
onset_thresholdfloat0.3onset 头二值化阈值,决定何时开音符段
offset_threshodfloat0.3offset 头二值化阈值(注意属性名拼写,见上文提醒)
frame_thresholdfloat0.1frame_output 低于该值视为"音符不再持续"(frame_disappear)
pedal_offset_thresholdfloat0.2踏板 offset 头阈值(仅在踏板通道生效)
frames_per_secondintconfig.frames_per_second帧率,用于帧索引↔秒换算(隐含 100)
classes_numintconfig.classes_num音高类别数 = 88(隐含)
segment_samplesint16000*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 佐证)双证据仲裁,降低单一头噪声影响
极长音符无 offseti - 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 反归一化)未在本文档中逐行验证。

Sources

(3 files)