Skip to content

Realtime BPM Detection in the Browser ​

Most BPM-detection tutorials on the web were written in 2014. They use ScriptProcessorNode, which has been deprecated since 2019, and they assume you only ever want to analyse a file you already have on disk. Real applications — DJ tools, music games, fitness trackers, visualizers — need to detect tempo as audio is playing, from a microphone, an <audio> element, or a live HTTP stream, with no glitches and on a separate audio thread.

This guide is the modern version. It uses the AudioWorklet API, which runs your analysis off the main thread on a real-time audio rendering thread. It works the same way across files, microphone input, and streaming sources, because it operates on AudioNode graphs — the universal abstraction the Web Audio API gives you.

The shape of the problem ​

Detecting BPM in the browser is three concerns in a trench coat:

  1. Get audio samples into a place where you can read them. The Web Audio API does this with AudioContext and a graph of AudioNodes.
  2. Find the beats in those samples. This is signal processing — filtering, peak detection, interval analysis.
  3. Stay out of the way of the UI thread. Audio analysis on the main thread will glitch playback the moment React re-renders or the GC pauses.

realtime-bpm-analyzer handles (2) and (3) for you. You provide (1) — an AudioNode connected to a source. The rest is library territory.

Why AudioWorklet matters ​

The Web Audio API used to expose ScriptProcessorNode, which let you read samples from the audio graph in JavaScript. But it ran on the main thread, in fixed-size chunks, with no real-time guarantees. As soon as your page did anything else — render a frame, run a layout, garbage collect — playback would stutter and BPM detection would miss beats.

AudioWorklet, shipped in Chrome 66 (2018) and Safari 14.1 (2021), fixes this by running your processor on the audio rendering thread. Samples arrive in 128-frame quanta with hard real-time deadlines. Nothing the main thread does can stall it.

realtime-bpm-analyzer is AudioWorklet-native. The processor is shipped inline as part of the bundle and registered for you when you call createRealtimeBpmAnalyzer(audioContext) — no separate hosted file to configure, no audioWorklet.addModule plumbing in your application code.

Installation ​

bash
npm install realtime-bpm-analyzer

Browser requirements: any browser with AudioWorklet support — Chrome 66+, Firefox 76+, Safari 14.1+, Edge 79+. No Internet Explorer, no legacy fallback. See Browser Compatibility for details.

The pattern, in one screen ​

The shape of every realtime BPM integration is identical. Three lines do the work:

typescript
import { createRealtimeBpmAnalyzer, getBiquadFilter } from 'realtime-bpm-analyzer';

const audioContext = new AudioContext();
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext);
const lowpass = getBiquadFilter(audioContext);

// Build the source — file, mic, or stream. See sections below.
const source = /* an AudioNode you create */;

// Wire the analysis chain. Filter into analyzer; everything is parallel.
source.connect(lowpass).connect(bpmAnalyzer.node);

// Listen.
bpmAnalyzer.on('bpm', ({ bpm }) => console.log('candidate:', bpm[0]?.tempo));
bpmAnalyzer.on('bpmStable', ({ bpm }) => console.log('stable:', bpm[0]?.tempo));

The only thing that changes between use cases is how you build the source node. Three patterns follow.

Pattern A: an <audio> or <video> element ​

The most common case — you have a media element on the page and want to read its tempo while it plays.

typescript
import { createRealtimeBpmAnalyzer, getBiquadFilter } from 'realtime-bpm-analyzer';

const audioContext = new AudioContext();
const audioElement = document.querySelector('audio')!;

// Resume the context on user gesture. Required by autoplay policies.
await audioContext.resume();

const source = audioContext.createMediaElementSource(audioElement);
const lowpass = getBiquadFilter(audioContext);
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext);

// Analysis chain — into the analyzer.
source.connect(lowpass).connect(bpmAnalyzer.node);

// Playback chain — straight to the speakers, in parallel.
source.connect(audioContext.destination);

bpmAnalyzer.on('bpmStable', ({ bpm }) => {
  if (bpm.length > 0) {
    console.log(`Stable tempo: ${bpm[0].tempo} BPM`);
  }
});

Two things to notice:

  1. The source is connected twice — once to the analyzer (through the filter), once to destination so the user actually hears the audio. These are independent paths in the audio graph; signal flows down both.
  2. createMediaElementSource takes ownership of the element's audio. If you do not connect the source to destination, you will hear nothing. This is a common first-encounter trap.

Pattern B: microphone (MediaStream) ​

You want to detect BPM from whatever the user's microphone hears — a song playing in the room, a live performer, a metronome.

typescript
import { createRealtimeBpmAnalyzer, getBiquadFilter } from 'realtime-bpm-analyzer';

const audioContext = new AudioContext();

// HTTPS is required. getUserMedia only works on secure origins (or localhost).
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: false,
    noiseSuppression: false,
    autoGainControl: false,
  },
});

const source = audioContext.createMediaStreamSource(stream);
const lowpass = getBiquadFilter(audioContext);
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext);

source.connect(lowpass).connect(bpmAnalyzer.node);

// Do NOT connect to destination — feedback loop.

bpmAnalyzer.on('bpmStable', ({ bpm }) => {
  if (bpm.length > 0) {
    console.log(`Mic tempo: ${bpm[0].tempo} BPM`);
  }
});

Critically: do not connect the microphone source to audioContext.destination. The mic captures audio from the speakers, which then plays through the speakers, which the mic captures again. Howls, runaway gain, unhappy users. Keep the mic source plugged into the analyzer only.

For a deeper look at mic-specific concerns — permissions, mobile, ambient detection — see the dedicated guide: How to Detect BPM from a Microphone in JavaScript.

Pattern C: HTTP audio stream (radio, internet streams) ​

You have a URL — say, an internet radio stream or a long-running shoutcast URL — and want to monitor its tempo over time.

typescript
import { createRealtimeBpmAnalyzer, getBiquadFilter } from 'realtime-bpm-analyzer';

const audioContext = new AudioContext();
await audioContext.resume();

// Build an audio element pointed at the stream. The browser handles HTTP,
// the codec, and the buffering for you.
const audio = new Audio('https://example.com/stream.mp3');
audio.crossOrigin = 'anonymous'; // required if the stream sets CORS headers
audio.play();

const source = audioContext.createMediaElementSource(audio);
const lowpass = getBiquadFilter(audioContext);
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext);

source.connect(lowpass).connect(bpmAnalyzer.node);
source.connect(audioContext.destination);

bpmAnalyzer.on('bpmStable', ({ bpm }) => {
  console.log(`Stream tempo: ${bpm[0]?.tempo} BPM`);
});

crossOrigin = 'anonymous' is the default-correct choice for any stream served from a different origin. Without it, browsers will refuse to expose the audio data to the Web Audio API and the analyzer will see silence.

For HLS or DASH streams, you do not integrate the player at the network layer — let hls.js or your video framework do its job, and feed the resulting <audio> or <video> element into the same createMediaElementSource pattern as above. The library doesn't care where the bytes came from; it only sees the decoded samples.

What the events mean ​

realtime-bpm-analyzer emits two BPM-related events. They serve different purposes and you usually want both.

typescript
bpmAnalyzer.on('bpm', ({ bpm }) => {
  // Fires roughly every chunk (~once per second).
  // Always available — even early, before detection has settled.
  // Use for a live-updating UI display.
});

bpmAnalyzer.on('bpmStable', ({ bpm }) => {
  // Fires when the algorithm has high confidence in a value.
  // Slower to fire — usually 5–15 seconds in.
  // Use for "the answer" — pinning the BPM into application state.
});

The bpm array is sorted by count, the number of peak intervals that voted for each tempo. bpm[0] is the top candidate; bpm[1] and beyond are alternatives. Each candidate also carries a confidence: the share of all votes within ±1 BPM of it, from 0 to 1. Compare it between candidates or over time; no absolute cut-off has been calibrated on real music yet.

The library folds every tempo into the 90–180 BPM range by halving or doubling it. This is the convention for dance and pop music, but it reads an 85 BPM hip-hop track as 170. A tempo and its double usually fold onto the same value, so you cannot pick the octave from the candidates. Pick it with the tempoRange option instead:

typescript
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext, {
  tempoRange: { min: 70, max: 140 }, // hip-hop, R&B, downtempo
});

max must be at least twice min, so that every tempo has an octave inside the range; anything narrower throws a RangeError. Keep your music's tempo away from the edges: a tempo sitting right on a bound (140 in a 70–140 range) jitters across it, and its votes split between 140 and 70, which lowers its count and confidence. See How It Works for the algorithm.

The tempogram detector ​

An experimental detector reads the tempo from an onset tempogram rather than from peaks in low-passed audio. It weighs tempos with a prior instead of folding them, and it only calls a tempo stable once the tempo agreed over several seconds and clearly leads its rivals. Opt in with the detector option, and connect the source to bpmAnalyzer.input. That node holds what the detector needs in front of it, the lowpass for peaks and nothing for tempogram, so the wiring is the same for both. The tempogram needs the full band, so it must not go through getBiquadFilter.

typescript
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext, {
  detector: 'tempogram',
});

source.connect(bpmAnalyzer.input);

analyzeFullBuffer(buffer, {detector: 'tempogram'}), analyzeChannelData in Node, the CLI's --detector tempogram and the MCP server's detector argument run it on whole files. The default stays 'peaks'.

It searches 90 to 180 BPM, and a tempo outside that reads at the octave inside: over about 179 BPM reads half, so a 200 BPM track reads 100, and a 70 BPM track reads 140.

tempoRange changes what is searched, but nothing is folded into it. A tempo from about twice min up can read at half or a quarter, whatever max is, so a lower min halves more music and a higher max does not reach faster music. On a range wider than one octave, which of those tempos halve varies from one to the next. On clean click tracks:

tempoRangeReads as itselfReads at half
60 to 180116, 140120 as 60, 160 as 80
70 to 140130140 as 70
90 to 240170180 as 90

These settings were measured on real-time analysis at 90 to 180 only, and whole files run the same settings without having been scored at them. continuousAnalysis works with it too, but a short stabilizationTime resets it before it can reach stable: at 10 seconds it never does.

tempogramMode: 'extension' ​

For a browser extension or any other page listening to a tab, which wants to be sure it runs the settings that were measured. It reads exactly as detector: 'tempogram' does, and refuses what would leave those settings: a tempoRange other than 90 to 180, and continuousAnalysis. So over 180 BPM still reads half. It applies to real-time analysis only.

typescript
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext, {
  detector: 'tempogram',
  tempogramMode: 'extension',
});

source.connect(bpmAnalyzer.input);

liveInput for a MediaStream ​

A MediaStream arrives in real time whatever the audio thread does. When the thread stays behind, the browser drops part of the stream, and the analyzer reads a tempo as high as it lost: 150 BPM reads 167 at 10% lost, marked stable. Given the track, the analyzer follows the track's clock and counts what was lost as silence. That brings the number close to the tempo and no more than that: under sustained loss in Chrome, tab capture read 148 for 150, and 117 and 118 for 120.

So what it mostly guards is the word stable:

  • While about 5% or more is being lost, bpm keeps coming but bpmStable does not. The edge is soft, because the clock moves in steps: in simulation 96% render was behind for part of the time, and a lock could come late. Smaller loss is paid the same way and can lock.
  • When the loss ends, about 3 s later, the analyzer starts over and emits analyzerReset, if a lock was held back, or if it lost 0.3 s or more and had published no lock 3 s or more before the loss showed. It then locks on what it hears from there, about 11 s later with the tempogram. In simulation every lock after that was right, across loss of 0.3 s to 3 s and 100 to 174 BPM. A smaller drop doesn't start it over: those locks read within 1 BPM.
  • A loss takes up to 3 s of audio to show, so a lock published within 3 s before it shows may have heard some of it. When 0.3 s or more is lost, that lock is withdrawn: the analyzer starts over as above. A lock published earlier stands, and nothing tells the main thread it has since heard less.
  • While reports of the track's clock stop for about 3 s, as they can on a hidden page whose timers are throttled, a lock is held back too: the clock can't vouch for it, and a window with no report doesn't count as clean.
  • Under loss that never ends it never locks.

Limits, since this is only as good as what was measured:

  • MediaStreamTrack.stats is specified for getUserMedia tracks. A track from captureStream, a remote peer or a MediaStreamAudioDestinationNode may have no stats, and then, as in a browser without them, this does nothing and says nothing.
  • The specification says these frame counters lack Working Group consensus, so a browser could change them.
  • Loss was measured on Chrome tab capture on Linux, and in simulation. A microphone, Windows and a laptop on battery were not measured.
  • When the whole browser loses the CPU, the track slows with the thread and this clock sees no loss. Only the track against the system clock shows that.

The clock follows the track until the track ends or stop() is called, so call stop() before closing the context.

typescript
const [track] = stream.getAudioTracks();
const bpmAnalyzer = await createRealtimeBpmAnalyzer(audioContext, {
  detector: 'tempogram',
  liveInput: track,
});

audioContext.createMediaStreamSource(stream).connect(bpmAnalyzer.input);

A media element or a buffer has no such track: those wait for the thread and lose nothing.

The lowpass filter — why it's there ​

Every example above includes getBiquadFilter(audioContext) between the source and the analyzer. This is a 200 Hz low-pass filter with Q = 1.

The reason: most beats live in the low-frequency transients of the signal — kick drums, basslines, the thump on the floor. By stripping everything above 200 Hz before peak detection, you make the kick the dominant signal in the analyser's input. Without the filter, vocals, hi-hats, and synth leads add competing peaks and accuracy drops on genre-non-EDM material.

The filter is a BiquadFilterNode from the Web Audio API — standard, free, no overhead. You can omit it if you know your audio is already low-frequency-only (for example, a kick-only stem) or if you want to experiment with detection on full-spectrum signal.

Stopping cleanly ​

Always tear down the audio graph when you're done. Otherwise you leak audio threads, hold microphones open, and surprise the user the next time they navigate.

typescript
// Disconnect and free the analyzer.
bpmAnalyzer.disconnect();

// Close the audio context — releases the audio thread.
await audioContext.close();

// If you're using a microphone, also stop the MediaStream tracks.
stream.getTracks().forEach((track) => track.stop());

In a single-page application, run the disconnect path on route change or component unmount. Browsers will eventually reclaim everything when the page is closed, but eventually is not soon enough — Safari in particular keeps the orange microphone indicator lit until you stop the tracks.

Edge cases worth knowing ​

Silence. The analyzer will emit no bpmStable event when there is no detectable rhythm. Show a "still listening…" UI rather than waiting forever; if no stable BPM arrives in 20 seconds with audible audio, the material is probably ambient or beatless.

Very short clips. Below ~5 seconds the algorithm has too few peaks to form a stable interval distribution. For one-shot file analysis on short samples, results are best treated as approximate.

Tempo changes. The analyzer adapts but lags. A track that ramps from 120 to 140 BPM mid-song will surface both candidates in the bpm event; the bpmStable event will eventually retarget. For DJ tools that need sample-accurate tempo tracking through transitions, you may want to call bpmAnalyzer.reset() on the transition boundary.

Non-4/4 time signatures. All time-domain BPM detection algorithms struggle with irregular meters — jazz, classical, hip-hop with off-beats. This library is no exception. Confidence will be lower; alternative candidates more interesting. A bpm[0].confidence well below what your regular material scores is a cue to fall back to a "tap tempo" UI.

iOS Safari. The audio context must be created or resumed inside a user gesture handler — a click, a touch. Browsers will silently keep the context in the suspended state otherwise and you will read zero samples.

What's next ​

  • The Microphone tutorial covers permissions, mobile gotchas, and ambient detection in depth.
  • The React hook tutorial wraps everything above in a typed useBpm hook with StrictMode-safe cleanup.
  • The How It Works guide opens the algorithm — threshold scanning, peak detection, interval grouping, confidence scoring.
  • The API reference has the full type signatures.
  • The examples directory has nine runnable apps across vanilla JS, React, and Vue.

If this library helped, give it a star on GitHub — it's the single most useful thing you can do to support continued development.