Run Blinding Lights by The Weeknd through Essentia's RhythmExtractor2013. It returns 85.4 BPM. The track is famously 171 BPM — one of the most-played tracks of the last decade, peer-reviewed by literally every DJ in the world.
This isn't a bug. It's a known property of beat-tracking algorithms: they pick the wrong tactus level for tracks where the perceived beat doesn't match the dominant onset rate. Spotify's deprecated audio_features had the same problem — entire genres came back at half their actual tempo.
This article unpicks why that happens and shows the 30-line heuristic we use to detect and correct it.
What "BPM" actually means
Most people think BPM is a single number per track. It isn't. A piece of music has a metric hierarchy — nested levels of regular pulse:
- Tatum — the smallest regular subdivision. For a typical 4/4 dance track, this is the 16th note.
- Tactus — the level you tap your foot to. This is what humans usually call "the BPM."
- Measure — one bar.
- Hyper-measure — phrase-level groupings (4-bar, 8-bar, 16-bar).
For most tracks the tactus is unambiguous. Bohemian Rhapsody is 72 BPM. Levels by Avicii is 126 BPM. Easy.
But for some tracks, especially modern pop with halftime drums or trap-influenced production, the tactus level is genuinely ambiguous. Blinding Lights has a synth-arp running at 16th notes (171 BPM × 4 = 684 BPM at the tatum), kicks every quarter note (171 BPM), but a snare on beats 2 and 4 only (85 BPM perceived as the snare-driven beat).
Whether you call it 85 or 171 depends on where you'd start a metronome. Most listeners and DJs say 171. Beat trackers often say 85.
Why algorithms get it wrong
Beat trackers like Essentia RhythmExtractor2013, BeatTrackerDegara, and Librosa's beat.beat_track all work the same way at a high level:
- Compute an onset envelope — signal where each peak corresponds to a percussive event (kick, snare, transient).
- Compute the tempogram — autocorrelation of the onset envelope across a range of candidate periods.
- Pick the period with the highest score, weighted by a tempo prior (most algorithms prefer 90-180 BPM).
The failure mode for half-time tracks: the tempogram has a peak at both the perceived BPM (171) and at half (85.5). Both are mathematically valid — the snare on beats 2 and 4 forms a regular pulse at 85.5 BPM. The algorithm's tempo prior gently nudges toward higher BPMs (octave-aware priors typically peak at 120 BPM and fall off slowly), but for tracks where the kick-snare pattern strongly emphasises the half-time grid, the lower peak wins.
Why pop especially? Modern pop production deliberately crafts kicks and snares to feel "patient" against fast hi-hats. The snare landing on 2 and 4 (rather than every quarter note) creates a hypnotic 85-BPM feel even when the underlying tempo is 170. Producers do this on purpose. The algorithm just reports what it hears.
Detecting the wrong level
Once you know what's happening, detecting it is straightforward. A track that's "really" at 170 but reported as 85 has these tell-tale signatures:
- BPM in 70-95 range (the suspect zone — where half-time pop typically lands)
- High onset rate (>3 events per second — lots of percussion happening between the slow snares)
- High energy (>0.4 RMS — not a slow ballad)
- High danceability (>0.55 — not a smooth jazz track)
If all four are true, the track is almost certainly perceived at 2× the reported BPM. Conversely, a track reported at >170 BPM with low onset rate (sparse percussion) is likely double-time'd — the algorithm picked up a hi-hat pattern when the tactus is the slower kick.
The correction heuristic
Here's a faithful simplification of the heuristic we ship (the production version uses curated fast/slow genre sets). Returns a corrected BPM alternative when warranted, None otherwise. It never overwrites the raw BPM — we expose the corrected value as a sibling field bpm_alt so callers can choose.
def bpm_alt(bpm, onset_rate, energy, danceability, genre=None) -> float | None:
"""Half-time / double-time BPM correction. Conservative — false positives push
customers off-grid, so we require multiple signals to agree before suggesting.
onset_rate comes from AcousticBrainz and is null for most preview-analysed
tracks, so genre carries the decision whenever onset_rate is missing."""
if bpm is None:
return None
fast = genre in FAST_GENRES # house, techno, drum & bass, synthwave, ...
slow = genre in SLOW_GENRES # hip-hop, r&b/soul, ballad, downtempo, ...
# Half-time → suggest 2x (a track that *feels* like twice the reported tempo)
if 70.0 <= bpm <= 95.0 and not slow:
if onset_rate is not None and onset_rate > 3.0 \
and (energy or 0) > 0.4 and (danceability or 0) > 0.55:
return round(bpm * 2, 2)
# onset_rate missing → lean on a fast-genre tag, else demand strong evidence
if fast and (energy or 0) > 0.4 and (danceability or 0) > 0.55:
return round(bpm * 2, 2)
if onset_rate is None and (energy or 0) > 0.9 and (danceability or 0) > 0.65:
return round(bpm * 2, 2)
# Double-time → suggest 1/2x
if bpm >= 165.0:
if onset_rate is not None and onset_rate < 1.5:
return round(bpm / 2, 2)
if onset_rate is None and slow:
return round(bpm / 2, 2)
return None
Tested on the live catalog:
| Track | Raw BPM | bpm_alt | Real BPM |
|---|---|---|---|
| Blinding Lights / The Weeknd | 85.4 | 170.8 | 171 |
| Levitating / Dua Lipa | 102.8 | — | 103 |
| Bohemian Rhapsody / Queen | 72.0 | — | 72 |
| Don't Stop the Music / Rihanna | 123.6 | — | 124 |
The heuristic fires on Blinding Lights (correctly — it's tagged synthwave, a fast genre) and stays silent on the other three (correctly). The conservative thresholds — genre, onset rate, energy and danceability all have to agree — mean it rarely false-positives on tracks at 80-95 BPM that genuinely belong there.
Why not just multiply by 2 for everything in 70-95?
You'd break every R&B ballad, every downtempo track, and every hip-hop song that's actually at 90 BPM. Real 80-95 BPM music exists in volume. The whole point of the heuristic is to distinguish tracks that feel like 170 from tracks that are 85. The signal is the rest of the audio descriptors:
- A real 85-BPM track has lower onset rate (sparse hi-hats, less internal pulse)
- A real 85-BPM track has lower danceability (because beat strength is lower at the perceived tactus)
- A 170-perceived-as-85 track will pin all four signals high
Production deployment notes
Don't overwrite cached results
If your existing API stores BPM in a database, applying correction in-place is a breaking change — customers' existing playlists, mixes, and analyses depend on the previous value. Always expose the corrected BPM as a sibling field (bpm_alt, bpm_corrected, bpm_perceived) and let callers opt in.
Make it derivable, not stored
The correction only depends on existing fields: bpm, onset_rate, energy, danceability and genre. Compute it at response build time, not at ingest. That way you can tune the heuristic without re-analysing your catalog.
Surface confidence
If you have a beat-detection confidence score (Essentia returns one), use it as a fifth gate. A low-confidence detection is more likely to be an octave error and benefits from the alternative.
What about machine-learned alternatives?
Several recent papers train neural networks to predict the tactus level directly — treating tactus selection as a classification problem rather than a peak-picking problem. Madmom's DBNDownBeatTracker does something similar by jointly tracking the beat and the downbeat with a Bayesian network.
These work well but they're slower, larger (60+ MB models), and require more inference compute. For a high-throughput API the heuristic above gets you 90% of the gain at literally zero extra cost — it's just four float comparisons on existing fields.
Try FreqBlog — free tier, no card: https://freqblog.com/
Further reading
- Why DJ Platforms Disagree on Key 60% of the Time
- Camelot Wheel for Developers
- FreqBlog Music API documentation
Originally published at freqblog.com.
Top comments (0)