Home / Blog / BPM from an MP3 without decoding

Engineering

How We Read a Beat's BPM From an MP3 Without Decoding It

By the Rhymeable Team · September 28, 2026 · 10 min read · Читать на русском

Rhymeable lays a grid of rhyme words over a beat, in time with it, so it has to know the beat's tempo. For the 102 beats we ship, we do. For beats people import themselves, the phone has to work it out. This is how we ended up reading the tempo from a field inside the MP3 that was never meant to measure loudness - and what the numbers look like, including the ones that don't flatter us.

The constraint

The app is React Native on Expo, and we did not want to own a native audio decoder just to find one number. Metadata covers some files: a name like Type Beat 140 BPM is close to certain, and many exports carry an ID3 TBPM tag. But a bare number in a name is a guess - 90s Vibe is not 90 BPM - and plenty of files have neither. So there had to be a way to get the tempo from the audio itself.

First attempt: listen to it play

The obvious route was to play the file and sample it through the platform's audio tap - on Android, the system Visualizer. It worked, in the sense that it returned numbers. What it actually delivered:

Measured on our 102 beats, it landed on the right tempo 45% of the time - and that figure assumes the permission was granted and the frames were not silent, which in the field they often weren't.

The real problem was the sample rate

It is tempting to blame the tempo maths. It wasn't the maths. The textbook estimator, autocorrelation, compares the onset curve with a shifted copy of itself - and it can only shift by whole samples. At 76 Hz, neighbouring shifts around 120 BPM are about 3 BPM apart. At 10 Hz they are tens of BPM apart, and the right answer usually falls between two of them.

Here is plain autocorrelation on the same 102 beats, with the same loudness curve thinned out to lower and lower rates:

Envelope rateExact tempoWithin an octave
76.6 Hz69.6%75.5%
38.3 Hz42.2%62.7%
19.1 Hz37.3%41.2%
9.6 Hz8.8%12.7%

At the rate the Visualizer gives you, the classic method is close to useless. So the fix had to start with a denser curve, not a cleverer estimator.

The loudness curve that was already in the file

MP3 (MPEG-1 Layer III) stores audio in frames. Each frame starts with a 4-byte header, then an optional CRC, then the side info: a small block that tells the decoder how to unpack the compressed audio that follows. The side info has one entry per granule - a slice of 576 samples - per channel, and every entry contains an 8-bit field called global_gain.

Header32 bit CRCoptional Side info17 or 32 bytes (MPEG-1) Main dataHuffman-coded audio main_data_beginprivate, scfsi part2_3_length12 bit big_values9 bit global_gain8 bit scalefac, tables, ...rest of the entry One entry per granule per channel. MPEG-1 has two granules of 576 samples in each frame.
Figure 1. Where global_gain sits. Reading it needs the header, the side info offsets and nothing else.

global_gain is the quantiser step the encoder chose for that granule, on a logarithmic scale. To stay inside its bitrate, the encoder quantises loud passages more coarsely, so the value rises and falls with the energy it has to encode. It is not a loudness meter - it is a coding decision, and the bit reservoir, the psychoacoustic model and the encoder settings all push it around. But note attacks are large, sudden jumps, and those come through.

The useful part is the rate. At 44.1 kHz a granule lasts 576 samples, so the curve has 76.6 values a second - more than seven times what the Visualizer delivered - and it comes from walking the file, with no decoding, no playback and no permission. We read the first 45 seconds (at most 3 MB) and average the channels:

// Bits before the per-granule entries, and the size of one entry.
// MPEG-1: main_data_begin(9) + private(5 mono | 3 stereo) + scfsi(4 per ch).
// MPEG-2: main_data_begin(8) + private(1 mono | 2 stereo), no scfsi.
const base = isV1 ? 9 + (channels === 1 ? 5 : 3) + 4 * channels
                  : 8 + (channels === 1 ? 1 : 2);
const entry = isV1 ? 59 : 63;
const GAIN_OFFSET = 21; // after part2_3_length(12) and big_values(9)

for (let g = 0; g < nGranules; g++) {
  let sum = 0;
  for (let c = 0; c < channels; c++) {
    const bitPos = sideStart * 8 + base + (g * channels + c) * entry + GAIN_OFFSET;
    sum += readBits(buf, bitPos, 8);
  }
  env.push(sum / channels);
}

Stage 1: a harmonic tempogram

From the curve we take the onset flux: only the positive changes, because attacks are rises. We thin it to about 38 Hz, which makes the rest six times cheaper and, measured, costs nothing in accuracy.

Then, instead of shifting the curve by whole samples, we ask directly how much energy the flux has at each candidate tempo: a single-frequency DFT for every candidate from 70 to 180 BPM in half-BPM steps. The frequency can be any real number, so there is no grid of lags for the answer to fall between.

const magAt = (bpm) => {
  const om = (2 * Math.PI * (bpm / 60)) / rate;
  let re = 0, im = 0;
  for (let i = 0; i < n; i++) {
    re += w[i] * Math.cos(om * i); // w: zero-mean, Hann-windowed flux
    im += w[i] * Math.sin(om * i);
  }
  return Math.sqrt(re * re + im * im);
};

A bare maximum of that curve reliably picks the wrong peak. A drum pattern is impulsive, so it has energy at every multiple of its tempo, and a harmonic can easily be the tallest single peak. So each candidate is scored on its whole series - itself plus 2×, 3× and 4×, weighted 1, 0.5, 0.33 and 0.25 - and only the true fundamental collects support from all of them. A gentle prior centred on 125 BPM breaks near-ties.

The harmonic sum is the single biggest step in the pipeline. Without it the same code gets 39.2% exact; with it, 63.7%. The approach also holds up as the curve gets coarser, where autocorrelation falls apart:

0% 25% 50% 75% 100% 69.6 63.7 76.6 Hz 42.2 63.7 38.3 Hz 37.3 61.8 19.1 Hz 8.8 55.9 9.6 Hz Plain autocorrelation Harmonic tempogram + accent test
Figure 2. Share of the 102 beats where the tempo is exactly right (within 2 BPM), by envelope rate.

Stage 2: the accent test

What is left is mostly octave error. A 90 BPM beat with hi-hats on the eighth notes produces the same onsets as a 180 BPM beat. Onset positions cannot separate the two readings, because they are the same set of positions.

What separates them is loudness. Read at 180, the pulses alternate strong and weak: half of them are the kick and snare, half are hats. Read at 90, they are even. So we find the phase that best fits the candidate's pulses, compare the average peak on even pulses with the average on odd ones, and if the weaker side is below 55% of the stronger one over at least 16 pulses, the candidate is running at double speed and gets halved. At most twice.

That lifts exact hits from 59.8% to 63.7%. By design, it never changes whether the answer is in the right octave class - that stays at 85.3%.

At first it wasn't free. We also offer alternatives next to the estimate (see below), and with the accent test in place the right tempo was one tap away 90.2% of the time instead of 93.1%. All three beats it lost had the same shape: the tempogram read 180 for a 120 BPM beat, the test halved that to 90, and 120 is 4/3 of 90 - not one of the relatives we offered. It is, however, 2/3 of the reading the test had just set aside.

So when the test halves, the alternatives now lead with the unhalved reading and its own relatives. That recovers all three without adding a chip: 63.7% exact and 93.1% one tap, the better number on both.

What plain autocorrelation does at full rate

For honesty's sake: at the full 76.6 Hz, plain autocorrelation gets 69.6% exact, which beats our 63.7%. It loses where it matters more to a rapper, though: only 75.5% of its answers are in the right octave, against our 85.3%, and an octave error is a grid running twice as fast or half as fast as the beat.

We tried the obvious hybrid - let the tempogram choose the octave, then refine the value with autocorrelation within ±3%. It came out worse than both: 60.8% exact, 81.4% within an octave. Sometimes two good ideas combine into a third, worse one.

Handing the doubt to the user

64% is not certain enough to fill the field silently. So next to the estimate the app shows up to three alternatives: usually half, double and 3/2, and after a halving the reading the accent test set aside comes first. The 3/2 pair is there because the misses that remain cluster there - a shuffle or a half-time feel makes 120 and 180 genuinely ambiguous. With those chips the right tempo is one tap away for 93.1% of the beats.

All the numbers

MethodExactWithin an octaveOne tap
Listening through the Visualizer (before)45%--
Tempogram, no harmonic sum39.2%61.8%78.4%
Harmonic tempogram, no accent test59.8%85.3%93.1%
Harmonic tempogram + accent test, before the alternatives fix63.7%85.3%90.2%
Harmonic tempogram + accent test (shipped)63.7%85.3%93.1%
Plain autocorrelation at 76.6 Hz69.6%75.5%-
Hybrid: tempogram octave, autocorrelation value60.8%81.4%-

How this was measured. The 102 beats in our catalogue, against their tagged tempo. A hit is within 2 BPM; "within an octave" also accepts half or double. Each run reads the first 45 seconds of the file. Parsing and estimating takes about 130 ms per file in Node on a laptop; we have not timed it on phones yet.

Where it falls short

Try it on your own file

The same engine runs in the browser in our free Freestyle trainerBPM finder. Nothing is uploaded; the file is read on your machine. The source is served unminified at /tools/bpm.js - a line-for-line port of what ships in the app, so if you want to read the whole thing, that is the place.

Where this runs

Rhymeable uses it to line a rhyme grid up with any beat you import, so you can see when each rhyme should land. Free on Android.

Get it on Google Play