
Sampling · 10 November 2026 · 6 min
Sample drifting off the grid? Find its real BPM, decimals included
A sample that starts on the grid and slowly slides off it almost always has a tempo with a decimal in it, and your session is set to the nearest whole number. In our test a loop at 93.6 BPM in a 94 BPM session was 43.6 ms late after four bars and 174.6 ms late after sixteen, more than a 16th note. Trim the loop to whole bars and work out the tempo from its length: that gave back 93.600 exactly.
You chop a loop, drop it in, and the first bar sounds perfect. By the second time around the kick is flamming against your drums. By the hook it’s plainly behind. You nudge it, it’s fine for a bit, then it drifts again.
That isn’t a timing problem in the sample. Records were played by people, or programmed at whatever tempo the producer liked, and plenty of them land between whole numbers. Your session can’t be 93.6 if you typed 94.
How fast a rounded tempo drifts
The error is tiny per beat and it never resets, so it adds up across the song. We wrote bpm_check.py to show how much. It works out where a 93.6 BPM loop’s beats land against a grid set to 94, 93 and 93.5, then builds a four-bar synthetic drum loop at 93.6 and tries three ways to find its tempo: the loop’s length, an onset detector, and tap tempo.
#!/usr/bin/env python3
"""A sample that drifts off the grid: how far a rounded BPM drifts, and three ways to find the real tempo.
The loop is synthetic: a 4-bar drum pattern at 93.6 BPM, 44.1 kHz. Needs numpy."""
import numpy as np
SR, TRUE_BPM, BARS = 44100, 93.6, 4
def bpm_from_length(n_samples, sr, bars, beats_per_bar=4):
"""Exact tempo of a loop you trimmed to whole bars."""
return bars * beats_per_bar * 60 * sr / n_samples
# --- 1. Drift: put the loop in a session set to a rounded tempo
print(f"Loop really at {TRUE_BPM} BPM. Where its beats land vs a grid at the rounded tempo:")
for grid in (94, 93, 93.5):
row = []
for bars in (1, 4, 16, 32):
beats = bars * 4
drift = beats * 60 / TRUE_BPM - beats * 60 / grid # seconds; + = loop late
row.append(f"{bars:2d} bars {drift * 1000:+6.1f} ms")
print(f" grid {grid:>5} BPM: " + " | ".join(row))
print(f" (a 16th note at {TRUE_BPM} BPM is {60 / TRUE_BPM / 4 * 1000:.0f} ms)")
# --- 2. Build the loop: kick on 1 and 3, snare on 2 and 4, closed hat on 8ths
beat = 60 / TRUE_BPM
n = int(round(BARS * 4 * beat * SR))
x = np.zeros(n); rng = np.random.default_rng(7)
def hit(at, kind):
i = int(round(at * SR)); L = min(int(0.25 * SR), n - i); tt = np.arange(L) / SR
if kind == "kick": s = np.sin(2 * np.pi * 55 * tt) * np.exp(-tt * 12)
elif kind == "snare": s = rng.normal(0, 0.5, L) * np.exp(-tt * 25)
else: s = rng.normal(0, 0.15, L) * np.exp(-tt * 80)
x[i:i + L] += s
for b in range(BARS * 4):
hit(b * beat, "kick" if b % 2 == 0 else "snare")
hit(b * beat, "hat"); hit((b + 0.5) * beat, "hat")
print(f"\nSynthetic loop: {BARS} bars, {n} samples = {n / SR:.4f} s")
# Method A: length of the trimmed loop
print(f" A) from the length ({BARS} bars): {bpm_from_length(n, SR, BARS):.3f} BPM")
# Same loop trimmed 10 ms long at the end (a sloppy edit)
print(f" same loop trimmed 10 ms long: {bpm_from_length(n + int(0.010 * SR), SR, BARS):.3f} BPM")
# Method B: onset autocorrelation (what a tempo detector roughly does), 10 ms frames
hop = int(0.010 * SR)
env = np.array([np.sum(x[i:i + hop] ** 2) for i in range(0, n - hop, hop)])
flux = np.maximum(np.diff(env), 0)
ac = np.correlate(flux, flux, "full")[len(flux) - 1:]
lags = np.arange(len(ac)); bpms = 60 / (lags[1:] * hop / SR)
ok = (bpms >= 60) & (bpms <= 200)
cand = sorted(zip(ac[1:][ok], bpms[ok]), reverse=True)[:3]
print(" B) onset autocorrelation, 10 ms frames, top picks 60-200 BPM: " + ", ".join(f"{b:.1f}" for _, b in cand))
# Method C: tap tempo with human timing error (15 ms standard deviation), tempo = slope of a line through the taps
for k in (8, 32, 64):
errs = []
for trial in range(200):
taps = np.arange(k) * beat + rng.normal(0, 0.015, k)
errs.append(abs(60 / np.polyfit(np.arange(k), taps, 1)[0] - TRUE_BPM))
print(f" C) {k:2d} taps, 15 ms timing error: typical miss {np.median(errs):.2f} BPM, worst of 200 tries {max(errs):.2f} BPM")
$ python3 bpm_check.py
Loop really at 93.6 BPM. Where its beats land vs a grid at the rounded tempo:
grid 94 BPM: 1 bars +10.9 ms | 4 bars +43.6 ms | 16 bars +174.6 ms | 32 bars +349.2 ms
grid 93 BPM: 1 bars -16.5 ms | 4 bars -66.2 ms | 16 bars -264.7 ms | 32 bars -529.4 ms
grid 93.5 BPM: 1 bars -2.7 ms | 4 bars -11.0 ms | 16 bars -43.9 ms | 32 bars -87.8 ms
(a 16th note at 93.6 BPM is 160 ms)
Synthetic loop: 4 bars, 452308 samples = 10.2564 s
A) from the length (4 bars): 93.600 BPM
same loop trimmed 10 ms long: 93.509 BPM
B) onset autocorrelation, 10 ms frames, top picks 60-200 BPM: 93.8, 92.3, 187.5
C) 8 taps, 15 ms timing error: typical miss 0.25 BPM, worst of 200 tries 0.98 BPM
C) 32 taps, 15 ms timing error: typical miss 0.03 BPM, worst of 200 tries 0.11 BPM
C) 64 taps, 15 ms timing error: typical miss 0.01 BPM, worst of 200 tries 0.03 BPM
Reading the numbers
Rounding up to 94 costs about 11 ms a bar. 10.9 ms after one bar, 43.6 after four, 174.6 after sixteen. A 16th note at this tempo is 160 ms, so by bar sixteen the sample is more than a whole 16th behind. Rounding down to 93 is worse, 16.5 ms a bar the other way. Even 93.5 is 43.9 ms off after sixteen bars, which you can hear as a flam on the kick.
The loop’s length is the most exact clock you have. Our four bars came out at 452,308 samples, and bars × 4 × 60 × sample rate ÷ samples gives 93.600 BPM, spot on. The catch is the trim. Leave the end 10 ms long and the same math says 93.509. Cut at the start of the downbeat and the start of the next downbeat after it, on whole bars, and zoom in to do it.
Tempo detectors get you close, not exact. The onset detector’s top pick was 93.8, limited by its 10 ms frames, with 92.3 next and 187.5, double time, third. Every detector has some version of this: good for a ballpark, often unsure between half and double time. Use it to know you’re near 93.6, then confirm with the length.
Tap tempo works if you tap for a while. With 15 ms of human slop per tap, eight taps typically missed by 0.25 BPM and once by 0.98. Thirty-two taps typically missed by 0.03, and 64 by 0.01. Tap a couple of bars and you’re guessing. Tap through a whole section and you’re close.
How to lock a sample in your session
- Trim the loop to whole bars. Start on a downbeat, end right before the next downbeat after one, two or four bars. Snap off, zoom in, and cut on the hit, not on the waveform before it.
- Do the math. Tempo = bars × 4 × 60 × sample rate ÷ length in samples, for 4/4. Your DAW shows the length in samples, or the script’s
bpm_from_lengthwill do it. - Set the session to that tempo, decimals included. Most DAWs take decimals in the tempo field. Then your drums, delays and sidechain follow the sample instead of fighting it.
- Or stretch the sample to your tempo on purpose. If the song has to be 94, warp the sample to 94 and check it didn’t change pitch. If it did, that’s a resample, not a stretch.
- If it still drifts, the sample itself moves. Live records speed up and slow down. Then you’re warping to the beats, or chopping it into pieces that each start on the grid, not hunting for one number.
When the drums are buried
Finding the downbeat is the hard part when the drums sit under strings and vocals. Pull the drums out with StemSplit first, trim on the kick in the drum stem, and use that length for the math. Then go back to the full sample with the tempo in hand.
- Why does my sample drift off beat?
- Because its real tempo has a decimal and your session is rounded. A 93.6 BPM loop in a 94 BPM session was 43.6 ms late after four bars and 174.6 ms after sixteen in our test.
- How do I find the exact BPM of a sample?
- Trim it to whole bars and divide: bars × 4 × 60 × sample rate ÷ length in samples. Our four-bar test loop came out at exactly 93.600 BPM.
- Is a BPM detector accurate enough?
- For a ballpark. Our onset detector picked 93.8 for a 93.6 loop and also offered 187.5, double time. Confirm the number with the loop length.
- How many taps do I need for tap tempo?
- More than you think. With 15 ms of timing slop, 8 taps typically missed by 0.25 BPM and 32 taps by 0.03.