Repository Wiki
bytedance/piano_transcription

钢琴活动检测与音频处理工具

本主题覆盖 piano_transcription 仓库中推理与后处理链路的两大支撑性工具集:utils/piano_vad.py 提供的钢琴音符/踏板活动检测(VAD, Value Activity Detection)算法,以及围绕 librosa 与 PianoTranscription 类构建的音频加载、重采样、分段与打包工具。

Purpose and Scope

本页面完整讲解以下内容:

  • utils/piano_vad.py 中的三个核心后处理函数:note_detection_with_onset_offset_regress(音符事件检测)、pedal_detection_with_onset_offset_regress(踏板事件检测)、onsets_frames_note_detection(Google Onsets-and-Frames 基线对比实现)。
  • 音频加载与预处理:librosa.core.load 的单声道重采样用法、float32_to_int16 的波形压缩存储、utils/features.py 中 MAESTRO / MAPS 数据集打包到 HDF5 的流程。
  • 推理侧音频处理:pytorch/inference.py 中 PianoTranscription 类的音频补齐(padding)、分帧、去帧逻辑与后处理阈值配置。

以下内容由兄弟页面承载,本页不做展开:

  • 神经网络结构与前向计算 —— 参见模型相关页面(pytorch/models.py 的 Note_pedal)。
  • 训练流程与损失函数 —— 参见训练与损失页面。
  • 评测与指标计算 —— 参见评测页面(pytorch/evaluate.py)。
  • MIDI 事件写出与 note/pedal 模型合并 —— 参见 MIDI 写出相关页面。

Overview

钢琴转录系统在神经网络前向之后得到的并不是 MIDI 音符,而是按帧、按音高排列的预测矩阵(frame_output、onset_output、onset_shift_output、offset_output、offset_shift_output、velocity_output)。将这些连续概率矩阵转换为离散的 (onset, offset, note, velocity) 音符事件,正是 piano_vad.py 的职责。

与传统语音 VAD(Voice Activity Detection)不同,这里的 "VAD" 是**逐音高(per-pitch)**的活动检测:调用方对 88 个钢琴音符中的每一个分别调用一次检测算法,把该音高的帧序列转换为若干 [bgn, fin, onset_shift, offset_shift, velocity] 元组。

为什么需要独立的后处理模块而不是直接在模型中输出事件?核心原因是帧率与时间精度解耦:模型以 frames_per_second 输出粗粒度预测,而 onset_shift / offset_shift 回归头在帧内部做亚帧(sub-frame)偏移修正,二者的组合逻辑放在纯 NumPy 循环里实现,便于独立调试、阈值调优和与 Google 基线公平对比。

音频处理侧则覆盖三条链路:

  1. 训练数据准备:utils/features.py 用 librosa.core.load(audio_path, sr=sample_rate, mono=True) 将各采样率的原始音频统一重采样到 config.sample_rate,再经 float32_to_int16 压缩为 int16 存入 HDF5;
  2. 推理输入准备:PianoTranscription.transcribe 对任意长度音频补齐到 segment_samples(默认 10 秒)的整数倍,分帧送入模型,再把分段输出拼接回原始长度;
  3. 后处理调度:RegressionPostProcessor(在 utils/utilities.py 中)持有阈值参数,逐音高调用 VAD 算法并把帧索引换算成秒。

Architecture

Loading diagram...

图中三层职责清晰分离:音频处理层只关心统一采样率与存储格式;推理层只关心任意长度音频的分段前向;后处理层只关心帧级概率到事件元组的转换与秒级换算。OnsetsFramesPostProcessor 与 RegressionPostProcessor 并列,说明仓库刻意把"提出的高分辨率回归后处理"与"Google 基线"做成可切换的两条路径(由 post_processor_type 控制),从而保证论文对比的公平性。

数据流中的张量形状

阶段形状说明
输入音频(audio_samples,)采样率 config.sample_rate 的单声道波形
transcribe 内部(1, audio_samples + pad_len)补齐后送入 enframe
enframe 输出(N, segment_samples)N 段,每段默认 16000×10 采样点
forward 输出(N, segment_frames, classes_num)逐段逐帧 88 维预测
deframe 之后(segment_frames, classes_num)拼接并裁剪 [0 : audio_len]
VAD 函数输入(frames_num,)单个音高的 1 维序列
VAD 函数输出list of [bgn, fin, onset_shift, offset_shift, velocity]帧索引 + 归一化偏移
最终事件(notes, 4)onset/offset 秒、MIDI 音符号、力度

核心实现:音符活动检测算法

note_detection_with_onset_offset_regress 是整个后处理链路中最关键的函数。它对单个音高的六条预测序列做一次线性扫描,用三个状态变量 bgn(当前音符起点)、frame_disappear(帧概率首次跌破阈值的位置)、offset_occur(偏移预测首次置 1 的位置)协同工作:

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 """Process prediction matrices to note events information. 5 First, detect onsets with onset outputs. Then, detect offsets 6 with frame and offset outputs. 7 """ 8 output_tuples = [] 9 bgn = None 10 frame_disappear = None 11 offset_occur = None 12 13 for i in range(onset_output.shape[0]): 14 if onset_output[i] == 1: 15 """Onset detected""" 16 if bgn: 17 """Consecutive onsets. E.g., pedal is not released, but two 18 consecutive notes being played.""" 19 fin = max(i - 1, 0) 20 output_tuples.append([bgn, fin, onset_shift_output[bgn], 21 0, velocity_output[bgn]]) 22 frame_disappear, offset_occur = None, None 23 bgn = i 24 25 if bgn and i > bgn: 26 """If onset found, then search offset""" 27 if frame_output[i] <= frame_threshold and not frame_disappear: 28 """Frame disappear detected""" 29 frame_disappear = i 30 31 if offset_output[i] == 1 and not offset_occur: 32 """Offset detected""" 33 offset_occur = i 34 35 if frame_disappear: 36 if offset_occur and offset_occur - bgn > frame_disappear - offset_occur: 37 """bgn --------- offset_occur --- frame_disappear""" 38 fin = offset_occur 39 else: 40 """bgn --- offset_occur --------- frame_disappear""" 41 fin = frame_disappear 42 output_tuples.append([bgn, fin, onset_shift_output[bgn], 43 offset_shift_output[fin], velocity_output[bgn]]) 44 bgn, frame_disappear, offset_occur = None, None, None 45 46 if bgn and (i - bgn >= 600 or i == onset_output.shape[0] - 1): 47 """Offset not detected""" 48 fin = i 49 output_tuples.append([bgn, fin, onset_shift_output[bgn], 50 offset_shift_output[fin], velocity_output[bgn]]) 51 bgn, frame_disappear, offset_occur = None, None, None 52 53 # Sort pairs by onsets 54 output_tuples.sort(key=lambda pair: pair[0]) 55 return output_tuples

Source: piano_vad.py

逐行设计意图解析:

  1. 起音优先策略:循环以 onset_output 为主导,onset_output[i] == 1(已被上游二值化)即认定新音符开始。这一决策来自钢琴发声物理特性——起音瞬间能量陡增,是检测中最可靠的信号。
  2. 连续起音处理:若上一个音符尚未结束就出现新起音(踏板未释放时连续击键的典型场景),用 fin = max(i - 1, 0) 强制截断上一音符,offset_shift 记为 0(因为该截断是策略性的,不是真实释放),随后重置 frame_disappear 和 offset_occur,让新音符从头累积状态。max(..., 0) 保证起始帧处的越界安全。
  3. 双通道结束判定:结束位置同时接受两个证据——frame_output 跌破 frame_threshold(帧概率消失)或 offset_output 置 1(偏移头命中)。二者中谁先出现且距起音更近,就作为 fin。offset_occur - bgn > frame_disappear - offset_occur 的比较意味着:当 offset 命中点处于起音与帧消失点之间偏后段时选 offset,否则选帧消失点。这是"两证据取置信度更高者"的软融合,而非硬性二选一。
  4. 600 帧兜底:i - bgn >= 600 对应 10 秒(frames_per_second=100 时)仍未检测到结束的极端长音,强制以当前帧收尾,防止音符事件无限延伸泄漏到下一个片段。同理 i == onset_output.shape[0] - 1 处理音频末尾未闭合的音符。
  5. 按起音排序:sort(key=lambda pair: pair[0]) 确保输出事件按时间有序,供下游 MIDI 写出与评测对齐。

状态机视图

Loading diagram...

踏板活动检测算法

pedal_detection_with_onset_offset_regress 与音符版共享相同的"起音后搜索结束"骨架,但起音判定完全不同——踏板没有独立的 onset 头,而是从帧概率的上升沿推断:

python
1 for i in range(1, frame_output.shape[0]): 2 if frame_output[i] >= frame_threshold and frame_output[i] > frame_output[i - 1]: 3 """Pedal onset detected""" 4 if bgn: 5 pass 6 else: 7 bgn = i 8 9 if bgn and i > bgn: 10 """If onset found, then search offset""" 11 if frame_output[i] <= frame_threshold and not frame_disappear: 12 """Frame disappear detected""" 13 frame_disappear = i 14 15 if offset_output[i] == 1 and not offset_occur: 16 """Offset detected""" 17 offset_occur = i 18 19 if offset_occur: 20 fin = offset_occur 21 output_tuples.append([bgn, fin, 0., offset_shift_output[fin]]) 22 bgn, frame_disappear, offset_occur = None, None, None 23 24 if frame_disappear and i - frame_disappear >= 10: 25 """offset not detected but frame disappear""" 26 fin = frame_disappear 27 output_tuples.append([bgn, fin, 0., offset_shift_output[fin]]) 28 bgn, frame_disappear, offset_occur = None, None, None

Source: piano_vad.py

与音符算法的三点差异:

维度音符检测踏板检测
起音来源专用 onset 头(二值)帧概率上升沿 frame_output[i] > frame_output[i-1] 且过阈值
循环起点range(0, n)range(1, n)(上升沿需要前一帧比较)
结束优先级帧消失与 offset 二者择近offset 优先:只要 offset_occur 就立即收尾,帧消失需额外满足 i - frame_disappear >= 10(约 0.1 秒持续低于阈值才算真释放,抗抖动)
onset_shift真实回归值固定 0.(无回归头)

踏板版使用"持续 10 帧低于阈值"的防抖设计,是因为踏板帧概率在物理踩放边界附近比音符概率更易出现高频抖动,简单的一次跌破会造成大量碎片事件。

音频加载与数据打包

utils/features.py 是训练数据的入库脚本。两个打包函数(MAESTRO、MAPS)都遵循"重采样 → int16 压缩 → HDF5"三步:

python
1def pack_maestro_dataset_to_hdf5(args): 2 """Load & resample MAESTRO audio files, then write to hdf5 files.""" 3 # Arguments & parameters 4 dataset_dir = args.dataset_dir 5 workspace = args.workspace 6 sample_rate = config.sample_rate 7 ... 8 # Load & resample each audio file to a hdf5 file 9 for n in range(audios_num): 10 logging.info('{} {}'.format(n, meta_dict['midi_filename'][n])) 11 # Read midi 12 midi_path = os.path.join(dataset_dir, meta_dict['midi_filename'][n]) 13 midi_dict = read_midi(midi_path) 14 # Load audio 15 audio_path = os.path.join(dataset_dir, meta_dict['audio_filename'][n]) 16 (audio, _) = librosa.core.load(audio_path, sr=sample_rate, mono=True) 17 ... 18 with h5py.File(packed_hdf5_path, 'w') as hf: 19 hf.attrs.create('canonical_composer', data=meta_dict['canonical_composer'][n].encode(), dtype='S100') 20 ... 21 hf.create_dataset(name='midi_event', data=[e.encode() for e in midi_dict['midi_event']], dtype='S100') 22 hf.create_dataset(name='midi_event_time', data=midi_dict['midi_event_time'], dtype=np.float32) 23 hf.create_dataset(name='waveform', data=float32_to_int16(audio), dtype=np.int16)

Source: features.py

设计意图说明:

  • sr=sample_rate, mono=True:MAESTRO 源音频采样率不一(44.1k/48k),librosa 在加载时一次完成重采样与混单声道,避免训练时每个样本重复做重计算。
  • float32_to_int16 + np.int16:float32 波形直接存盘体积翻倍,int16 量化已满足钢琴转录精度需求,同时 HDF5 数据集按样本懒加载,显著降低磁盘占用。
  • 元数据走 attrs、对齐数据走 dataset:midi_event(事件字符串)与 midi_event_time(秒级时间戳)与波形同文件存放,训练时一次读取即可获得音频-标注对齐,无需跨文件 JOIN。
  • MAPS 版本(pack_maps_dataset_to_hdf5)逻辑相同,仅遍历 ['ENSTDkCl', 'ENSTDkAm'] 两台钢琴的 MUS 子目录并固定 split='test',体现 MAPS 仅用于评测的定位。

命令行接口

features.py 通过 argparse 子命令暴露两种打包模式:

python
1 parser = argparse.ArgumentParser(description='') 2 subparsers = parser.add_subparsers(dest='mode') 3 4 parser_pack_maestro = subparsers.add_parser('pack_maestro_dataset_to_hdf5') 5 parser_pack_maestro.add_argument('--dataset_dir', type=str, required=True, help='Directory of dataset.') 6 parser_pack_maestro.add_argument('--workspace', type=str, required=True, help='Directory of your workspace.')

Source: features.py

推理侧音频分段处理

PianoTranscription 类(pytorch/inference.py)承担把任意长度音频送入固定输入尺寸模型的职责。transcribe 方法是完整入口:

python
1 def transcribe(self, audio, midi_path): 2 """Transcribe an audio recording. 3 4 Args: 5 audio: (audio_samples,) 6 midi_path: str, path to write out the transcribed MIDI. 7 """ 8 audio = audio[None, :] # (1, audio_samples) 9 10 # Pad audio to be evenly divided by segment_samples 11 audio_len = audio.shape[1] 12 pad_len = int(np.ceil(audio_len / self.segment_samples)) \ 13 * self.segment_samples - audio_len 14 15 audio = np.concatenate((audio, np.zeros((1, pad_len))), axis=1) 16 17 # Enframe to segments 18 segments = self.enframe(audio, self.segment_samples) 19 """(N, segment_samples)""" 20 21 # Forward 22 output_dict = forward(self.model, segments, batch_size=1) 23 """{'reg_onset_output': (N, segment_frames, classes_num), ...}""" 24 25 # Deframe to original length 26 for key in output_dict.keys(): 27 output_dict[key] = self.deframe(output_dict[key])[0 : audio_len]

Source: inference.py

关键细节逐项解释:

  1. audio[None, :]:从 (audio_samples,) 扩为 (1, audio_samples),为后续 np.concatenate 与批处理维度对齐做准备。
  2. 尾部零填充公式:ceil(audio_len / segment_samples) * segment_samples - audio_len 保证填充后能被段长整除——enframe 内部有 assert x.shape[1] % segment_samples == 0,缺填充会直接断言失败。
  3. deframe 后裁剪 [0 : audio_len]:由于预测是逐帧的,audio_len 在此处实际按"帧数"语义裁剪,把填充引入的多余帧去掉,避免幻觉事件出现在填充区域。
  4. forward(..., batch_size=1):以单样本批处理逐段前向,控制显存峰值;多 GPU 时借助 torch.nn.DataParallel。

阈值与后处理器配置

python
1 self.segment_samples = segment_samples 2 self.post_processor_type = post_processor_type 3 self.frames_per_second = config.frames_per_second 4 self.classes_num = config.classes_num 5 self.onset_threshold = 0.3 6 self.offset_threshod = 0.3 7 self.frame_threshold = 0.1 8 self.pedal_offset_threshold = 0.2

Source: inference.py

选项类型默认值说明
segment_samplesint16000×10推理分段长度(采样点),构造参数可覆盖
frames_per_secondintconfig.frames_per_second帧率,决定帧↔秒换算
classes_numintconfig.classes_num音高类别数(88)
onset_thresholdfloat0.3起音二值化阈值
offset_threshodfloat0.3偏移二值化阈值(注意源码中拼写为 threshod)
frame_thresholdfloat0.1帧概率阈值,用于 VAD 结束判定
pedal_offset_thresholdfloat0.2踏板偏移阈值
post_processor_typestr'regression''regression'(本文算法)或 'onsets_frames'(Google 基线)

后处理器调度与逐音高循环

RegressionPostProcessor(位于 utils/utilities.py)是 VAD 算法的编排者。output_dict_to_detected_notes 对 88 个音高逐一调用检测函数,再统一换算为秒:

python
1 def output_dict_to_detected_notes(self, output_dict): 2 """Postprocess output_dict to piano notes. 3 4 Returns: 5 est_on_off_note_vels: (notes, 4), the four columns are onsets, offsets, 6 MIDI notes and velocities. E.g., 7 [[39.7375, 39.7500, 27., 0.6638], 8 [11.9824, 12.5000, 33., 0.6892], 9 ...] 10 """ 11 est_tuples = [] 12 est_midi_notes = [] 13 classes_num = output_dict['frame_output'].shape[-1] 14 15 for piano_note in range(classes_num): 16 """Detect piano notes""" 17 est_tuples_per_note = note_detection_with_onset_offset_regress( 18 frame_output=output_dict['frame_output'][:, piano_note], 19 onset_output=output_dict['onset_output'][:, piano_note], 20 onset_shift_output=output_dict['onset_shift_output'][:, piano_note], 21 offset_output=output_dict['offset_output'][:, piano_note], 22 offset_shift_output=output_dict['offset_shift_output'][:, piano_note], 23 velocity_output=output_dict['velocity_output'][:, piano_note], 24 frame_threshold=self.frame_threshold) 25 26 est_tuples += est_tuples_per_note 27 est_midi_notes += [piano_note + self.begin_note] * len(est_tuples_per_note) 28 29 est_tuples = np.array(est_tuples) # (notes, 5) 30 31 onset_times = (est_tuples[:, 0] + est_tuples[:, 2]) / self.frames_per_second 32 offset_times = (est_tuples[:, 1] + est_tuples[:, 3]) / self.frames_per_second 33 velocities = est_tuples[:, 4] 34 35 est_on_off_note_vels = np.stack((onset_times, offset_times, 36 est_midi_notes, velocities), axis=-1) 37 return est_on_off_note_vels.astype(np.float32)

Source: utilities.py

亚帧精度换算的核心是第 937-938 行:

python
onset_times = (est_tuples[:, 0] + est_tuples[:, 2]) / self.frames_per_second offset_times = (est_tuples[:, 1] + est_tuples[:, 3]) / self.frames_per_second

即 帧索引 + 亚帧回归偏移 再除以帧率,把 100 fps 的粗粒度帧网格细化为连续时间值。piano_note + self.begin_note 把 0-based 类别索引映射为真实 MIDI 音符号(88 键起于 A0=21)。这也是本算法名称中 "regress" 的含义——不靠提高模型输出帧率,而靠回归头 + 偏移叠加实现高时间分辨率。

Core Flow:从音频到 MIDI 的端到端时序

Loading diagram...

整个流程中,音频处理(补齐/分帧/去帧)与活动检测(逐音高扫描)是两个独立的纯函数式阶段,唯一的耦合点是 output_dict 的形状契约:(segment_frames, classes_num)。这意味着只要形状对齐,模型可以替换、VAD 阈值可以独立调优,互不干扰——这正是本仓库模块划分的工程价值所在。

API Reference

note_detection_with_onset_offset_regress(...)

完整签名见 piano_vad.py

python
def note_detection_with_onset_offset_regress(frame_output, onset_output, onset_shift_output, offset_output, offset_shift_output, velocity_output, frame_threshold):

参数:

参数类型说明
frame_outputndarray (frames_num,)单音高的帧存在概率
onset_outputndarray (frames_num,)已二值化的起音预测(1 表示起音帧)
onset_shift_outputndarray (frames_num,)起音亚帧偏移回归值
offset_outputndarray (frames_num,)已二值化的偏移预测
offset_shift_outputndarray (frames_num,)偏移亚帧偏移回归值
velocity_outputndarray (frames_num,)归一化力度回归值
frame_thresholdfloat帧概率消失判定阈值(推理时为 0.1)

返回值: list of [bgn, fin, onset_shift, offset_shift, normalized_velocity],按起音帧升序排列。

边界行为: 连续起音时上一音符被强制截断且 offset_shift 记为 0;音符持续 600 帧未闭合时强制收尾;音频末帧强制闭合未结束音符。

pedal_detection_with_onset_offset_regress(...)

完整签名见 piano_vad.py

python
def pedal_detection_with_onset_offset_regress(frame_output, offset_output, offset_shift_output, frame_threshold):

参数: frame_output / offset_output / offset_shift_output 为单通道 (frames_num,) 序列,frame_threshold 为帧阈值。

返回值: list of [bgn, fin, onset_shift, offset_shift],其中 onset_shift 恒为 0.。

边界行为: 帧概率持续 10 帧低于阈值才确认释放(防抖);offset 命中即优先收尾。

onsets_frames_note_detection(...)

见 piano_vad.py

Google Onsets-and-Frames 风格的对比基线:仅用 onset_output > threshold 判定音符存在,frame_output 判定结束,不含亚帧回归偏移。仅在论文对比实验中通过 OnsetsFramesPostProcessor 使用,产线推理不启用。

PianoTranscription.__init__(model_type, checkpoint_path, segment_samples, device, post_processor_type)

见 inference.py

  • model_type (str):模型类名字符串,经 eval(model_type) 动态解析(如 'Note_pedal')。
  • checkpoint_path (str):checkpoint 路径;加载用 strict=False,允许权重部分缺失。
  • segment_samples (int, 默认 16000*10):推理分段长度。
  • device (torch.device, 默认 cuda):无可用 CUDA 时自动回退 cpu。
  • post_processor_type (str, 默认 'regression'):后处理器类型切换开关。

PianoTranscription.transcribe(audio, midi_path)

见 inference.py

参数: audio 为 (audio_samples,) 单声道波形;midi_path 为输出 MIDI 路径(传 None 可跳过写盘,仅返回结果)。

返回值: {'output_dict': ..., 'est_note_events': ..., 'est_pedal_events': ...}。

pack_maestro_dataset_to_hdf5(args) / pack_maps_dataset_to_hdf5(args)

见 features.py 与 features.py

参数(argparse): --dataset_dir(数据集目录,必填)、--workspace(工作区目录,必填)。无返回值,副作用为在 workspace/hdf5s/{maestro|maps}/ 下生成 .h5 文件。

Failure Modes、边界情况与并发

场景行为源码依据
音频长度非段长整数倍先补零再分帧,enframe 内 assert x.shape[1] % segment_samples == 0 兜底inference.py L82-88、L149
填充区产生幻觉帧deframe 后统一裁剪 [0 : audio_len]inference.py L96-97
无 CUDA 环境torch.cuda.is_available() 判断后回退 CPU,并打印 Using CPU.inference.py L35-38、L64
checkpoint 与模型不完全匹配load_state_dict(..., strict=False) 容忍键缺失inference.py L56
起音处越界fin = max(i - 1, 0) 防负索引piano_vad.py L38
超长未闭合音符i - bgn >= 600 强制收尾piano_vad.py L65
踏板概率边界抖动需 i - frame_disappear >= 10 持续低值才确认释放piano_vad.py L123
数据集目录结构不符pack_maps_dataset_to_hdf5 直接 os.listdir 遍历固定子目录,结构错误会抛异常features.py L112-116
未知子命令raise Exception('Incorrect arguments!')features.py L165-166

并发说明: piano_vad.py 中的检测函数是纯函数(只读输入数组、无共享状态),天然线程安全,可按音高并行化;但当前实现为串行 for piano_note in range(classes_num) 循环(88 次调用)。若需提升长音频后处理吞吐,按音高分片并行是明确的扩展方向。PianoTranscription 侧则通过 DataParallel 支持多 GPU 数据并行前向。

Performance 与运维要点

  • 时间复杂度:VAD 检测为单遍线性扫描,O(frames_num) 每音高,总计 O(88 × frames_num);长音频的主要耗时在模型前向而非后处理。
  • 显存控制:forward(..., batch_size=1) 逐段前向,显存峰值与音频总长解耦,只取决于 segment_samples。
  • 数据入库耗时:features.py 以 logging.info('Time: {:.3f} s') 记录打包总耗时,便于估算大规模数据集预处理成本。
  • 阈值运维:四个阈值(onset/offset/frame/pedal_offset)硬编码于 PianoTranscription.__init__,调整需改源码或继承覆写;frame_threshold=0.1 明显低于 onset/offset 的 0.3,反映帧头输出分布更平滑、需要更宽松的消失判据。

Extension Points

  1. 后处理器替换:post_processor_type 字符串开关 + RegressionPostProcessor / OnsetsFramesPostProcessor 双实现,新增算法只需实现 output_dict_to_midi_events(output_dict) 接口即可插入。
  2. 数据集扩展:features.py 的子命令模式(add_subparsers)可照搬新增 pack_xxx_dataset_to_hdf5 函数支持新数据集,只需保证 HDF5 内 waveform(int16)与 midi_event/midi_event_time 数据集契约一致。
  3. 逐音高并行化:VAD 函数无状态,可安全并行;output_dict_to_detected_notes 的 88 次循环是现成的并行切分点。
  4. 阈值外部化:将 __init__ 中的四个阈值提为构造参数即可实现免改码调参。