简介:音频节奏检测(Onset Detection)代码示例压缩包,面向音频处理与音乐信息检索开发者,以可运行的Java示例演示节拍起始点的识别流程,帮助理解从预处理、能量计算、STFT特征提取到阈值设定与事件后处理的完整实现思路。资源共64个文件,以29个class编译产物、25个java源码为主,配合xml配置与2个mp3测试音频,压缩包整体约1009KB,工程结构完整,便于导入IDE后对照源码与运行结果进行学习。预览显示其包含src、resources、target及pom.xml等Maven标准目录,test.mp3可直接用于验证检测效果,适合作为入门onset检测算法的落地范本。已有1599人浏览学习,对想掌握节拍检测代码细节的开发者很有参考价值;结合源码逐行阅读,能够把理论中的能量突变、频谱特征、阈值后处理等知识点与实际实现一一对应起来,并为进一步优化检测效果打下基础。
1. 音频节奏检测是什么:从“听到鼓点”到“让程序找到鼓点”的第一次落地
音频节奏检测(Onset Detection)要做的事,说穿了就是把“人耳能听出来的音符起始时刻”变成“程序能输出的时间戳”。你放一段打击乐进去,期望拿回来的是每个鼓点落在第几秒;你放一段人声进去,期望拿回来的是每个字头的起音位置。这个能力是节拍追踪、和弦切分、音频对齐、自动标注等一系列音频分析任务的地基,也是很多做音乐信息检索(MIR)的团队第一个要跑的算法。
我最早接触它是为了做“钢琴演奏的自动翻页”,需要知道演奏者每一拍落在哪,后来发现这东西比想象中坑多:同一段音频,换一种检测器结果差一大截,调参调不好就满屏误报。这篇笔记就把我踩过的路走一遍——先讲清楚检测器的原理和选型依据,再给一份可直接运行的最小示例,然后把 frame size、hop length、backlink 这些关键参数的调节逻辑拆开讲,最后列出真实音频环境下的五个常见翻车点。适合正在做音频分析、想快速上手的人照着一个字一个字地敲。
2. 算法选型与 Onset Detection 原理:谱通量、能量突变与两个常用检测器
2.1 为什么不能靠“音量突变”做检测
最容易想到的思路是“声音变大了就是新的音符”,于是去算短时能量,超过阈值就认为是 onset。这个做法在家里录的干净鼓点上是能用的,但换到真实音乐场景几乎必翻车:一段渐强的弦乐会触发一连串假 onset,一个军鼓的滚奏会因为能量连续性被全部漏掉,更别提鼓点和贝斯同时响起时能量早已叠在一起。
真正的 onset 检测要抓的是“频谱结构”的突变,而不是“总响度”的突变。一个音符的起始会在很短时间内改变多个频带的能量分布,尤其在高频部分会突然冒出一片新的能量;相比之下,渐强只是所有频带同步缓慢上升,并不会造成短时间内的高频能量激增。这个差异是所有现代检测器的出发点。
所以业界主流做法是用频谱通量(Spectral Flux)作为核心特征:对每一帧做短时傅里叶变换,然后比较相邻两帧在同一个频点上的能量变化,只累加“变大了”的部分。为什么只累加增大的方向?因为音符结束时频谱也会变,但结束的瞬态往往不如起始那么锐利,而且我们关注的是“起音”,不是“止音”。
2.2 谱通量计算步骤与两个主流检测器
谱通量写成公式就是逐频点计算当前帧幅度谱减去上一帧幅度谱,取正数部分后求和。实际操作一般分四步:
- 分帧:把波形切成短帧,常见帧长 1024 或 2048 个采样点,帧移 512 或 1024,采样率 44100 Hz 时对应约 23ms 和 46ms。
- 加窗:每帧乘一个汉宁窗或汉明窗,避免频谱泄漏。
- 逐帧做 FFT,取幅度谱或对数幅度谱。
- 计算当前帧与上一帧的正向差分之和,得到每帧一个通量值。
有了通量曲线之后,需要从曲线里“挑出”onset 位置。这一步有两种经典思路,也是我在项目里最常用的两个检测器:
- 第一种是“局部峰值 + 动态阈值”:先对通量曲线做平滑,然后用滑窗找局部极大值,再要求峰值超过一个随背景噪声浮动的阈值。这个阈值的设定方式直接影响误报率,我会在 4.2 里专门讲。
- 第二种是“谱通量 + 检测函数后处理”:把通量值先做一阶差分,再用一个自适应阈值和“退磁区间”(dead region)来抑制连续触发。
在 Python 生态里,librosa 的librosa.onset.onset_detect是封装得最完整的,它底层用的就是谱通量加峰值挑选;另一个常见选择是 madmom,它的 onset detection 模块用的是神经网络特征,效果更好但依赖更深。
2.3 选型依据:从“精度”和“延迟”两个维度取舍
选检测器不能只看准确率。我一般按使用场景分成三类:
第一类是“离线分析”,比如给音频打标签、做数据集切分,这种场景允许把整首歌读进内存算完再出结果,优先选 madmom 的RNNDownBeatProcessor或CNNOnsetProcessor,因为模型训练过的特征对复杂混音更鲁棒;第二类是“实时演奏跟随”,音频必须边进边出,这个时候模型一帧就要几十毫秒的推理开销,往往撑不住,我会退回谱通量加简单的峰值判定;第三类是“嵌入式设备”,树莓派或手机端跑,特征提取都要精简,一般只算梅尔谱或常 Q 变换后做子带通量。
另外要提醒一个容易忽略的点:多声道音频要先合并成单声道再计算,否则左右声道相位抵消会让通量主峰消失。两个检测器可以同时跑,用“或者”合并结果,对打击乐能挽回不少漏检,但误报也会跟着涨,需要按数据情况权衡。
3. 用 Python 跑通最小示例:librosa 安装到输出时间戳
3.1 准备环境与测试音频
我先假定你用的是 Python 3.9 以上的环境。常见做法是建一个干净的虚拟环境再装 librosa,它会把 numpy、scipy、soundfile 这些依赖一起带上来。
python -m venv onset_env source onset_env/bin/activate pip install librosa soundfile matplotlib装完之后准备一段测试音频,最好先用鼓点明显的素材,比如一支架子鼓的 loop 或者节拍器录音。如果没有现成文件,可以用下面的脚本合成一个简单的脉冲序列来验证整个流程:
import numpy as np import soundfile as sf sr = 44100 t = np.arange(sr * 2) / sr signal = np.zeros_like(t) onsets = [0.5, 1.0, 1.5, 1.75] for pos in onsets: start = int(pos * sr) length = sr // 20 # 一个 50ms 的衰减正弦波模拟鼓点 envelope = np.exp(-np.linspace(0, 8, length)) signal[start:start+length] += 0.8 * envelope * np.sin(2 * np.pi * 440 * np.arange(length) / sr) sf.write("fake_drum.wav", signal, sr)逻辑说明:这脚本生成两秒音频,在四个预定的时间点叠加上一个 440 Hz 的衰减正弦波,用来模拟简易鼓点。参数说明:sr是采样率,envelope的衰减速度控制音头长度,envelope = np.exp(-np.linspace(0, 8, length))中常数 8 越大衰减越快,音头越短。
3.2 用librosa.onset.onset_detect输出 onset 时间戳
核心代码很短,但每一行都值得说清楚:
import librosa y, sr = librosa.load("fake_drum.wav", sr=44100, mono=True) onset_frames = librosa.onset.onset_detect( y=y, sr=sr, hop_length=512, delta=0.02, wait=20, backtrack=True, units='frames' ) onset_times = librosa.frames_to_time(onset_frames, sr=sr, hop_length=512) print(onset_times)逻辑说明:librosa.load读入音频并重采样到 44100 Hz,mono=True保证单声道;onset_detect内部先算谱通量,再根据delta设定的阈值挑选峰值;backtrack=True会把检测到的 onset 位置向前回退到真正的能量上升起点,而不是峰值点本身,这个对后续对齐很有用。最终frames_to_time把帧号换算成秒。
参数说明:hop_length=512决定了时间精度,512 帧在 44100 Hz 下对应约 11.6ms 一格;delta是通量阈值,只在通量值超过自身背景噪声一定倍数时才算 onset,值越大越不容易误报但容易漏掉弱音;wait=20是“退磁区间”,意思是检测到一个 onset 后至少要隔 20 帧才能再检测下一个,防止同一个鼓点被重复报告。
跑完这一段,输出应该接近 [0.5, 1.0, 1.5, 1.75] 附近,说明整个流程通了。
3.3 可视化检查:不画图不知道漏在哪
拿到时间戳还不算完,我强烈建议画两张图:一张是波形加 onset 标记,另一张是谱通量曲线加阈值线。只靠打印时间戳根本看不出漏检和误报的原因。
import matplotlib.pyplot as plt import librosa.display onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=512) times = librosa.frames_to_time(np.arange(len(onset_env)), sr=sr, hop_length=512) fig, ax = plt.subplots(nrows=2, sharex=True, figsize=(12, 6)) librosa.display.waveshow(y, sr=sr, ax=ax[0]) ax[0].vlines(onset_times, -1, 1, color='r', linestyle='--', label='detected onsets') ax[0].legend() ax[1].plot(times, onset_env, label='onset strength') ax[1].vlines(onset_times, 0, onset_env.max(), color='r', linestyle='--') ax[1].set_xlabel('Time (s)') plt.tight_layout() plt.show()逻辑说明:onset_strength返回的是每一帧的通量值序列,可以直接看作检测函数。第二张图里红线标出的位置如果明显偏离局部峰值,说明wait或delta设置得不够合理,需要回 4.2 调整。
4. 参数怎么调:帧移、阈值、backlink 与 merge 的取舍
4.1 hop_length 与 frame size:先定时间精度,再定频率分辨率
很多人一上来就调delta,其实最先应该定的是hop_length和帧长。hop_length直接决定输出时间戳的最细粒度,比如hop_length=512在 44100 Hz 下是 11.6ms 一格,hop_length=256就是 5.8ms。听起来越小越精细,但代价是通量曲线噪声变大,峰值判定会更敏感。
帧长(默认是n_fft=2048)决定频率分辨率,2048 点对应频率分辨率约 21.5 Hz。帧长越大,每个频点带宽越窄,越能分辨接近的低频成分;但时间上更模糊,瞬态会被抹平。帧长和帧移的关系是:hop_length通常取n_fft的 1/4 到 1/2,这样既有重叠又不会算得太慢。
我处理不同音频类型时的常见参数组合如下表:
| 音频类型 | n_fft | hop_length | delta | wait | 说明 |
|---|---|---|---|---|---|
| 架子鼓独奏 | 1024 | 256 | 0.02 | 15 | 瞬态极短,帧移小一些更稳 |
| 钢琴独奏 | 2048 | 512 | 0.03 | 30 | 音符持续长,wait 大了防颤音误报 |
| 人声清唱 | 2048 | 512 | 0.04 | 40 | 元音起音较缓,阈值要提高 |
| 重度混音摇滚 | 4096 | 1024 | 0.02 | 20 | 低频能量大,帧长加大保频率分辨率 |
4.2 delta 与 wait:误报和漏检的“跷跷板”
delta是通量峰值要高出背景噪声多少倍才被接受。librosa 内部会用一个小窗口统计通量中位数和标准差,delta相当于一个乘法系数。值偏小会把弱音和噪声都当成 onset,值偏大则漏掉弱起音。我自己的调试方法是:先把 delta 调到 0.0 跑一遍看通量曲线,肉眼看到底哪些峰是真正音头,再慢慢往上加,直到曲线里的“噪声毛刺”全部被过滤掉、真峰还在。
wait是检测到一个 onset 后强制冷却的帧数。这个参数专门防“同一个事件被重复报告”——对于鼓点,音头之后还有强的共振峰,通量曲线会在主峰后面紧接着一个小峰,如果wait太短就会把共振峰也当成新的 onset。取值没有标准答案,但常见做法是 15 到 40 之间。检测对象音头越短、共振越强,wait要越大。
还有一个参数容易被忽略:pre_max和post_max。它们控制局部峰值检测的比较窗口——pre_max表示峰值要和它之前多少帧内的最大值比较,post_max表示峰值要和它之后多少帧内的最大值比较。它们的效果类似于对通量曲线做了非极大值抑制。
4.3 backtrack 与 merge:是否精确到音头,是否合并相近事件
backtrack=True是个非常值钱的开关。谱通量峰值出现的时刻往往比真正音头晚几个毫秒到几十毫秒,因为瞬态能量需要积累几帧才达到峰值。backtrack 会让算法从峰值点往前搜索,找到使通量上升最快的那一帧作为最终输出,这样获得的时间戳更贴近“物理起音时刻”,对后续做节拍对齐、MIDI 标注非常重要。代价是计算量增加,但对离线分析可以忽略。
merge参数在librosa.onset.onset_detect里不是直接暴露的,但可以通过librosa.util.fix_frames或自己写合并逻辑实现。当我检测到两个 onset 时间差小于 30ms 时,会认为它们属于同一个打击事件——比如镲片的瞬态几乎跟鼓皮振动同时出现,没必要输出两个时间戳。
5. 避坑:真实音频环境下 Onset Detection 的 5 个常见翻车点
5.1 低频重击导致整体通量曲线被“带偏”
现象:低音鼓每一下都非常重,导致通量曲线低谷段也被顶得很高,中高频乐器的轻音头全部被淹没,漏检成片。
原因:谱通量计算时所有频带差分累加是等权重的,低频能量大,其变化会主导整体曲线;而真正反映音头起始的中高频反而被忽略。
解决:改用子带通量,把频率轴拆成低频段和高频段分开算,再按比例混合。常见做法是对梅尔滤波器组的输出分别计算通量,并提升高频段的权重。我通常会把谱通量分成 0-200 Hz、200 Hz-2 kHz、2 kHz-8 kHz 三个子带,低频段乘 0.5,中频段乘 1.0,高频段乘 1.5 再加权求和,效果立竿见影。
5.2 人声的连续长音被切成一堆假 onset
现象:歌手唱一个长音,中间有明显的颤音或气息波动,检测器把这个长音报告成三四个 onset。
原因:长音内部的频谱并不是恒定不变的,颤音导致的基频变化和音量波动都会产生通量正差分。
解决:把wait调大,并提高delta;如果还不行,加一个“最小时间间隔过滤”——检测出的 onset 时间戳挨个比较,间隔小于 80ms 的强制去掉后面的。这个规则对打击乐基本不影响,但能让长音检测结果干净很多。
5.3 延迟大的实时场景下,peak 检测永远晚半拍
现象:做实时交互时,onset 时间戳打印出来比实际听到的晚 50-100ms,跟不上手速。
原因:谱通量需要用当前帧和上一帧做差分,本身就有 hop_length 的延迟;再加上峰值检测必须等峰值出现之后才能确认,这就是“检测窗延迟”。
解决:离线分析无所谓,但实时场景要用流式处理,每来一帧就更新通量值并做一次局部判定。一个实用的技巧是“提前量补偿”:统计出检测延迟大约是多少毫秒,在输出时间戳时统一减去这个延迟,再用动态时间规整对齐。实测下来,hop_length=512时延迟约 20ms 左右,减掉之后演奏跟手度会明显改善。
5.4 不同响度的音源用同一套阈值,弱音全漏
现象:一首歌里主歌和副歌响度差异大,副歌的强音触发良好,主歌的弱音基本全漏。
原因:delta是相对背景噪声的倍数,响度差异会导致通量绝对值在全局范围内大幅波动,单一全局阈值对动态范围大的音频极不友好。
解决:用自适应阈值。把通量曲线按秒级窗口滑动,计算每个窗口内的平均值和标准差,把阈值定义成“局部均值 + 局部标准差乘系数”。librosa 的默认delta机制其实已经有一点局部性,但对超过 6dB 的动态范围还是不够,需要自己写滑动窗口。我一般用 1 秒窗口、0.5 秒步长来做这个局部阈值化。
5.5 多段音频拼接连续处理,边界处凭空出现假 onset
现象:把同一批音频文件拼在一起批量检测,拼接缝处往往会出现一个 onset。
原因:拼接导致的波形不连续会让这一帧出现微弱的高频能量突变,虽然听感上不明显,但算法很容易把它当成瞬态。
解决:处理前先做 5ms 的交叉淡化(crossfade),或在拼接位置记一个 mask,检测完成之后把边界前后 30ms 内的 onset 全部删掉。两种方法我都用过,交叉淡化更治本。
6. 进阶:从时间戳到节奏图谱,把耻辱的准确率找回来
时间戳有了,离“能用”还差一口气。我现在做这件事的进阶路径有三步,每一步都对应一个具体验证方法。
第一步是校准检测延迟。拿着 onset 时间戳去跟 MIDI 文件或者人工标注对齐,算出平均延迟和方差。如果方差很大,说明检测器在有些音头位置来回跳,这时候把backtrack打开能稳定很多。
第二步是统计评价。先人工标注 20 到 50 个 onset 点,写一个小脚本计算精确率、召回率和 F1 值。这个脚本不复杂,代码示例如下:
def evaluate_onsets(detected, ground_truth, tolerance=0.05): detected = sorted(detected) ground_truth = sorted(ground_truth) matched = 0 used = [False] * len(ground_truth) for d in detected: for i, g in enumerate(ground_truth): if not used[i] and abs(d - g) < tolerance: matched += 1 used[i] = True break precision = matched / len(detected) if detected else 0 recall = matched / len(ground_truth) if ground_truth else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0 return precision, recall, f1逻辑说明:tolerance是允许的时间误差,通常取 50ms;每个检测结果只匹配一个标注点,防止一个真实 onset 被反复匹配。参数说明:tolerance越小评价越严格,实际项目里 30ms 到 80ms 不等,取决于下游任务对时间精度的要求。
第三步是处理密集的快速音型。当乐器以极快的速度演奏十六分音符(间隔约 100ms)时,检测出的结果往往会有额外抖动。一个特别有效的小技巧是:先检测,然后对时间戳做“最小间隔重排”——如果两个相邻 onset 间隔小于理论最小值,就保留能量更高的那个,把另一个删掉。
这些年调 onset detection 的经历让我形成一个习惯:永远不只看最终时间戳,而是先画检测函数曲线,再决定参数怎么改。没有这张图,调参基本靠运气,我在这上面没少浪费时间。第一次跑通你的代码时,如果检测结果看着不太对,先别急着换算法——把你自己的音频画一遍通量曲线,看看峰是不是真的在那里,很多时候问题出在音频本身而不是代码。希望帮到你。
本文还有配套的精品资源,点击获取