Finding the BPM of Any Song (and the Half-Time Trap Every Detector Falls Into)
Beat tracking, beat-interval refinement and the octave error, run on 3,141 real tracks: 64.6% have a runner-up tempo at half or double the winner.

Spotify stopped serving tempo. I compute it myself.
When Spotify closed its audio features endpoint to new apps, tempo went with it. I run my own streaming platform, Billboard, and its recommendation engine needs tempo for every track, so the signal engine computes it from raw audio. This post is part 8 of 14 in my feature engineering series, and it is about the one stage every music app thinks is easy: finding the BPM.
Here is the result that made me write it. My engine's record for Another One Bites the Dust says 110.0 BPM. The same record says the strongest peak in the song's tempogram is 55.0 BPM, exactly half. Neither number is a bug.
BPM detection in 40 seconds

Step 1: a beat grid on the onset envelope
The engine analyses a 60 second excerpt from the middle of each track at 22,050 Hz. librosa computes an onset envelope, how much the spectrum jumps from one frame to the next, and its dynamic-programming beat tracker places beats on it.
The tracker's tempo is a tempogram bin, about 2% wide around 120 BPM. The beats it places are finer than that, so I refine: take the beat intervals, keep the steady ones (within 20% of the median), and convert their mean to BPM. If the refined value agrees with the bin within 8%, it wins. A trimmed mean, not a median, because beat frames are quantised to the hop (23 ms) and the intervals alternate around the true value.
Beat-interval refinement, from bpm_analysis.py in my signal engine
tempo, beats = librosa.beat.beat_track(onset_envelope=onset_env, sr=sr, hop_length=hop,
start_bpm=120.0, tightness=100, trim=False, units="frames")
bpm = float(np.atleast_1d(tempo)[0])
beat_times = librosa.frames_to_time(beats, sr=sr, hop_length=hop)
if len(beat_times) >= 8:
ivl_all = np.diff(beat_times)
med = float(np.median(ivl_all))
steady = ivl_all[np.abs(ivl_all / med - 1.0) < 0.2]
if len(steady) >= 4:
refined = 60.0 / float(steady.mean())
if abs(refined / bpm - 1.0) <= 0.08:
bpm = refinedStep 2: the half-time trap
The octave error, 120 against 60 against 240, is the mistake every tempo estimator makes. My engine keeps a runner-up: the next tempogram peak that is not within 4% of the winner. On real songs:
- Another One Bites the Dust (Queen): stored 110.0 BPM, runner-up 55.0, strongest tempogram peak 55.0
- Get Lucky: stored 116.4 BPM, runner-up 76.0, strongest tempogram peak 234.9
- Lose Yourself (Eminem): stored 171.4 BPM, runner-up 86.1, strongest tempogram peak 172.3
- We Will Rock You: stored 81.5 BPM, runner-up 161.5, strongest tempogram peak 80.7
Across the 3,134 tracks that have a runner-up tempo, 64.6% put it at half or double the winner, within 4%. The ambiguity is not an edge case. It is the normal state of a song.
Four songs, three tempo readings

Step 3: two confidences, because there are two questions
confidence is the share of local tempo estimates within 4% of the stored BPM. octave_agreement is the same share counting half and double as agreement. A consumer who only cares about "fast or slow" can read the second; a DJ tool that needs the exact grid reads the first. Median confidence on my catalogue: 0.82. The two differ by more than 0.2 on 2.7% of tracks.
beat_strength is the onset strength at the chosen beats relative to the loudest onset: a four-on-the-floor kick reads near 1, a rubato ballad reads low. Median 0.36.
Every track in my catalogue, by BPM

The honest part: agreement, not accuracy
Not one track in my catalogue came with a BPM. The engine is the first writer of that column, so its evaluation compares with the catalogue where it can (duration) and says plainly that it cannot score BPM. What it can test is a known answer: a synthetic 120 BPM click track must read 120 within 3%, with beat regularity above 0.9 and tempo class "upbeat". That check runs on every test run.
The catalogue median is 121.4 BPM, the middle half sits between 108 and 134. The next step is a small hand-labelled set to count real octave errors. Until then the octave ambiguity is stored next to the BPM, where the recommender can see it.
Part 8 of 14, next: Spotify's Danceability Score Doesn't Match What People Feel. Here's What Rhythm Really Measures
Part 8 of 14, next: Spotify's Danceability Score Doesn't Match What People Feel. Here's What Rhythm Really Measures
More: LinkedIn · Instagram. Portfolio and case studies: designsbyduhart.org.
If any of this saved you an afternoon, Buy me a coffee.