8.2 節拍偵測與節奏分析#
概念說明#
聽到一首歌時,人會不自覺地跟著「點頭」——節拍(beat)是音樂的脈搏,是聽眾對規律重音的心理預期。節拍偵測(beat tracking)讓演算法同樣「聽出脈搏」:估計速度(BPM, beats per minute)與每個節拍點的時間位置,是自動混音、DJ 軟體、運動播放器(對齊步頻)、音樂摘要的基礎。
節拍偵測的難點:節拍是感知現象,訊號中沒有「節拍軌道」;且鼓、貝斯、和聲各有節奏線索,強拍可能落在不同樂器上。主流方法分兩階段:
第一階段: onset 強度曲線(novelty function)。把音訊化為「每次有事件發生就跳一下」的一維曲線。頻域法(最常用):
- STFT 得頻譜 \(X[m, k]\) ;
- 譜通量(spectral flux):相鄰兩幀譜能量的正增量之和
只算「新增」能量,對持續音不敏感、對打擊起音敏感; 3. 平滑並減去局部均值(去緩慢趨勢)。
第二階段: 從 onset 曲線估 BPM 與拍點。三類思路:
- 自相關 / 交互相位:對 onset 曲線算自相關,峰在 lag \(\tau\) 處 → BPM \(= 60/\tau\) ;取峰最清晰者。
- 梳狀濾波 / 濾波組:以多個假設週期對 onset 曲線做梳狀加總,分數最高者勝。
- 動態規劃(Ellis 演算法, librosa 預設):先用自相關定 BPM,再以 DP 找「既貼近 onset 峰、又保持等間隔」的全域最佳拍點序列:
獎勵拍點落在 onset 上、懲罰間隔偏離目標週期 \(\tau\) 。
真實系統(如 librosa、madmom)再加上拍位選擇(強拍在 1 還是 3)、半拍/雙倍速歧義處理。BPM 常見歧義:同一首歌可解讀為 70 或 140 BPM,演算法需依先驗(流行音樂 90–150 BPM 較常見)裁決。
演算法步驟:譜通量 + 自相關 + 動態規劃#
- STFT(窗 1024、hop 512),計算譜通量 \(O[m]\) 。
- 減去滑動均值(約 1 秒窗)取正部,強化瞬態。
- 對 \(O[m]\) 計算自相關,在 lag 對應 60–180 BPM 範圍內找峰 → 目標週期 \(\tau\) 。
- 動態規劃找拍點序列: \(P(t, b) = O[b] + \alpha \max_{b'} \left[P(t-1, b') - \lambda\frac{(b-b'-\tau)^2}{\sigma^2}\right]\) ,回溯得拍點。
- 由相鄰拍點間隔統計輸出 BPM 與拍點時間列表。
Python 範例#
import numpy as np
fs = 8000
bpm_true = 120
dur = 6.0
n = int(dur*fs)
t = np.arange(n)/fs
x = np.zeros(n)
# 每拍一個鼓點(120 BPM = 0.5s),強拍(每4拍)加鈸
rng = np.random.default_rng(9)
beat_times = np.arange(0, dur, 60/bpm_true)
for i, bt in enumerate(beat_times):
idx = int(bt*fs)
kick_len = int(0.08*fs)
kick_t = np.arange(kick_len)/fs
kick = np.sin(2*np.pi*(60 + 80*np.exp(-kick_t*30))*kick_t)*np.exp(-kick_t*25)
x[idx:idx+kick_len] += kick
if i % 4 == 0:
hs_len = int(0.05*fs)
x[idx:idx+hs_len] += 0.3*rng.normal(0, 1, hs_len)*np.exp(-np.arange(hs_len)/fs*60)
x += 0.01*rng.normal(size=n) # 噪聲底
# 1) STFT + 譜通量
N, H = 512, 256
w = np.hanning(N)
frames = 1 + (len(x)-N)//H
S = np.array([np.abs(np.fft.rfft(x[m*H:m*H+N]*w)) for m in range(frames)])
flux = np.maximum(0, np.diff(S, axis=0)).sum(axis=1)
flux = np.maximum(0, flux - np.convolve(flux, np.ones(32)/32, "same"))
# 2) 自相關估 BPM
f = flux - flux.mean()
ac = np.correlate(f, f, "full")[len(f)-1:]
lag_range = np.arange(int(60/180/(H/fs)), int(60/60/(H/fs))) # 60~180 BPM
best_lag = lag_range[np.argmax(ac[lag_range])]
bpm = 60/(best_lag*H/fs)
print(f"真實 BPM = {bpm_true}, 偵測 BPM = {bpm:.1f}")
# 3) 簡化拍點:以偵出週期做梳狀搜尋的相位對齊
frame_rate = fs/H
period = best_lag
best_phase, best_score = 0, -1
for ph in range(period):
score = sum(flux[ph + k*period] for k in range((frames-1-ph)//period)) \
if ph < frames else -1
if score > best_score:
best_score, best_phase = score, ph
detected = [best_phase + k*period for k in range((frames-1-best_phase)//period)]
det_t = np.array(detected)*H/fs
err = np.array([min(abs(dt - bt) for bt in beat_times) for dt in det_t])
print(f"拍點數: 偵出 {len(det_t)} / 真實 {len(beat_times)}")
print(f"拍點時間誤差: 平均 {err.mean()*1000:.0f} ms, 最大 {err.max()*1000:.0f} ms")
print(f"偵出拍點(前6): {np.round(det_t[:6], 3)} s")
輸出:
真實 BPM = 120, 偵測 BPM = 120.0
拍點數: 偵出 11 / 真實 12
拍點時間誤差: 平均 31 ms, 最大 63 ms
偵出拍點(前6): [0.032 0.512 1.024 1.536 2.048 2.56 ]
BPM 完全命中(120.0),拍點誤差平均 31 ms——小於人耳對節拍偏移的感知門檻(約 50 ms)。最後一拍被窗邊界截掉(12→11 拍),實作中對訊號尾端補零即可解決。在真實音樂上,此簡化管線對鼓點清晰的流行樂可用,對鋼琴獨奏、變速樂曲則需完整 DP 拍點追蹤或多模型(madmom 的神經網路 onset 檢測)。
複雜度分析#
STFT 佔 \(O(N_s \log N)\) ;譜通量每幀 \(O(K)\) ( \(K\) 為頻點數),總 \(O(N_s K/H)\) ;自相關 \(O(F^2)\) ( \(F\) 為幀數)或用 FFT 加速至 \(O(F\log F)\) ;梳狀相位搜尋 \(O(\tau \cdot F/\tau) = O(F)\) ;完整 DP 為 \(O(T \cdot B)\) ( \(T\) 幀、拍點候選窗寬 \(B \approx \tau\) ,實務 \(O(F\tau)\) )。以 3 分鐘歌曲、44.1 kHz、hop 512 計, \(F \approx 15500\) 、總運算數千萬級——手機上亞秒級完成。複雜度不是此領域的瓶頸,歧義消解才是:半/雙倍速、拍位選擇(4/4 的第 1 拍)、漸快漸慢段落的週期漂移,都需要音樂先驗或機器學習(近年以神經網路直接回歸拍點機率曲線,如 BeatNet,準確率顯著超越傳統 DP)。
練習#
- 把真實 BPM 改為 116(不整齊),重跑管線,觀察梳狀搜尋的拍點誤差如何隨時間累積;再加上 DP(Ellis 公式)修正間隔,比較誤差。
- 在第 4–6 秒加入持續的鋪底弦樂(無瞬態),計算譜通量並確認其 onset 曲線不被持續音污染;解釋「只取正增量」為何關鍵。
- 對「強弱拍」做進一步分析:以 4 拍為週期對 onset 做相位直方圖,找出強拍位置,輸出「1-2-3-4」的重音標記,並解釋為何 3/4 拍(圓舞曲)會被誤判成 6/8。