librosa beat_track reports ~119.68 BPM for an exact 120 BPM click track
librosa beat_track reports 119.68 BPM for an exact 120 BPM click track. I was conforming generated music beds to an exact beat grid for motion graphics (120 BPM, so hits land on 0.5 s multiples). librosa.beat.beat_track (librosa 1.0.0) reported 119.68 and 122.28 BPM for two beds generated at 120 BPM, and 100.45 for one generated at 100 BPM. I time-stretched by target / estimate with librosa.effects.time_stretch. That added phase-vocoder smear, and re-analysis after stretching still said 119.68 or 122.28, never 120.0. As a sanity check I ran it on a synthetic click track at exactly 120 BPM (librosa.clicks(times=np.arange(0, 30, 0.5), sr=sr)): beat_track returned 119.681 at sr=48000 and 117.454 at sr=22050. The same 100 BPM clicks returned 100.446. The tempo comes back as a float with decimals, so it looks precise.
Root cause: the tempo from beat_track (and librosa.feature.tempo) is quantized to integer autocorrelation lags of the onset envelope. It is effectively bpm = 60 * sr / (hop_length * lag) for an integer lag. With the default hop_length=512:
| sr | 60*sr/512 | bins near 120 BPM | a true 120 reads as |
|---|---|---|---|
| 48000 | 5625 | 122.28 (lag 46), 119.68 (lag 47) | 119.68 |
| 22050 (librosa default) | 2583.98 | 123.05 (lag 21), 117.45 (lag 22) | 117.45 (2.1% off) |
Near 100 BPM at 48 kHz the bins are 102.27 / 100.45 / 98.68, so a true 100 reads 100.446. A "correction" computed from that number is noise of up to about ±1-2%. Over a 26 s edit, 1% is about 0.26 s of drift, which is visible when motion is cut to the beat. Re-analysis snaps back to the same bin, so you never see convergence.
Fix: take the tempo from the beat times, not from the tempo estimate. Fit a line through beat_track's beat times against beat index. The per-beat frame jitter (10.7 ms at 48 kHz/512) averages out over dozens of beats:
import numpy as np, librosa
onset = librosa.onset.onset_strength(y=mono, sr=sr)
_, beats = librosa.beat.beat_track(onset_envelope=onset, sr=sr, start_bpm=120, units="time")
period, t0 = np.polyfit(np.arange(len(beats)), beats, 1)
bpm = 60.0 / period # clicks: 120.000 and 99.999; generated music: 119.97-120.00What I then did:
- For small corrections (under ~3%), change speed by resampling (varispeed, a slight pitch shift) instead of
effects.time_stretch. It is artifact-free, and 0.3% of pitch is inaudible. - Anchor the grid at
t0, then refine its phase by sampling onset strength att0 + k*period + offsetfor offsets in ±period/2 and keeping the best. - Pick downbeats by comparing low-passed (<150 Hz) onset strength across beat index mod 4.
A smaller hop_length narrows the bins but does not remove the quantization.