Guide

Guide Audio Beat detection

Beat detection

Drive visuals from beat and frequency information.

Intermediate ~4 min read

What you'll learn

  • read beat and frequency information
  • drive timing from audio analysis

Before you start

Beat detection

The BeatDetector analyses an audio signal in real time using a self-contained AudioWorkletProcessor. It detects tempo, beat positions, and bar/downbeat structure, then reports results back to the main thread as both discrete events (onBeat, onDownbeat, onBarStart, onTempoChange) and per-frame polling getters. You use the events for one-shot triggers and the getters for continuous animation — both are covered in detail in Audio-reactive visualization.

This chapter covers what the detector does and the events it fires. The next chapter covers what you do with that information.

Connecting a source

The detector taps any audio source — Sound, Music, AudioBus, AudioNode, or a MediaStream — as a parallel branch that does not affect the source’s main signal chain:

import { BeatDetector, Music } from '@codexo/exojs';

const music = loader.get(Music, 'track').setLoop(true).play();
const detector = new BeatDetector();
detector.source = music;

You can also pass the source as a constructor option:

const detector = new BeatDetector({ source: music });

The detector runs its own FFT inside the worklet processor. No AudioAnalyser is required — BeatDetector handles frequency analysis internally. The fftSize option (default 2048) and hopSize (default 512) control the worklet’s internal analysis window.

Tempo and confidence

Two read-only getters give you the detector’s current tempo estimate:

update(delta) {
    const bpm = this.detector.tempo;           // 0 before lock-in
    const confidence = this.detector.confidence; // 0..1
}

tempo returns 0 until the detector has enough data to lock onto a stable beat. confidence rises as the beat grid stabilises. The onTempoChange signal fires when the detector settles on a new tempo:

detector.onTempoChange.add((newBpm, oldBpm) => {
    console.log(`Tempo changed from ${oldBpm} to ${newBpm} BPM`);
});

The settlingMs option (default 1500) gates beat-phase output during initial warm-up so beat timing only starts after enough analysis history is available.

Event signals

Four signals fire at discrete moments in the music:

SignalFires whenPayload
onBeatA beat occursBeatInfo — audio time, tempo, confidence, energy, isDownbeat, beatInBar
onDownbeatThe first beat of a barSame BeatInfo
onBarStartA new bar beginsBarInfo — audio time, tempo, confidence, barNumber
onTempoChangeTempo estimate changes(newBpm, oldBpm)

Use onBeat for things that should happen on every beat — a sprite pulses, a particle burst fires, a UI element flashes:

detector.onBeat.add(info => {
    this.pulse = 0.3;
    this.particleBurst.reset();
});

Use onDownbeat for events that should only fire on the “one” — a brighter flash, a bar-level visual change, a camera shake:

detector.onDownbeat.add(info => {
    this.barFlash = 0.5;
});

BeatInfo carries isDownbeat and beatInBar so you can use the general onBeat signal and filter inside the callback:

detector.onBeat.add(info => {
    if (info.isDownbeat) {
        // big effect on beat 1
    } else if (info.beatInBar === 3) {
        // secondary emphasis on beat 3
    }
});

State getters

The detector exposes read-only state you can poll every frame:

detector.tempo              // BPM, or 0 before lock
detector.confidence         // 0..1 locking quality
detector.beatPhase          // 0..1 phase within current beat
detector.barPosition        // 1..N beat within current bar
detector.barLength          // beats per bar (default 4)
detector.timeSignature      // { numerator, denominator }
detector.rms                // 0..1 overall RMS energy
detector.onsetStrength      // 0..1 onset novelty at current frame
detector.bandEnergy         // { low, mid, high } band energies
detector.tempoCandidates    // readonly array of { bpm, score } candidates
detector.lookahead          // readonly array of upcoming beats

These are pure derivations from the detector’s most recent worklet state update — no allocation, no event dispatch.

Bar and time-signature tracking

The detector attempts 4/4 vs. 3/4 time-signature detection when enableTimeSignatureDetection is true (the default). Set it false in the constructor to lock to 4/4:

const detector = new BeatDetector({
    enableTimeSignatureDetection: false,
});

barPosition (1-based) tells you which beat you’re on within the current bar. barLength reports the detected bar length. Together they let you build sequencers, rhythm-game logic, and procedural music controllers that follow the music’s structure.

Worklet processor

BeatDetector uses an AudioWorkletProcessor that runs entirely in the Web Audio rendering thread. It applies a Hann window, runs a radix-2 FFT, computes mel-band energies, detects onsets via spectral flux, builds a tempogram with autocorrelation, and tracks beat phase with a dynamic-programming grid. All of this is internal — the public API is the events and getters listed above.

Beat/state messages are posted from the worklet and handled asynchronously on the main thread. They are not locked to your scene’s update timing, and worklet timing can be finer than requestAnimationFrame. For frame-stable visual animation, prefer polling getters in update (see Audio-reactive visualization).

Configuring the detector

Construction options with their defaults:

const detector = new BeatDetector({
    minBpm: 50,                            // minimum detectable tempo
    maxBpm: 250,                           // maximum detectable tempo
    fftSize: 2048,                         // internal FFT window
    hopSize: 512,                          // samples between successive FFTs
    tempoWindowSec: 6,                     // sliding window for tempogram
    settlingMs: 1500,                      // initial suppression period
    melBands: 24,                          // mel filterbank bands
    enableTimeSignatureDetection: true,    // auto-detect 3/4 vs 4/4
    source: music,                         // initial source (optional)
});

minBpm and maxBpm constrain the tempo search. tempoWindowSec controls how much history the autocorrelation uses — larger values give more stable tempo estimates on steady music but slower reaction to tempo changes. melBands controls the granularity of the internal onset detection; the default of 24 is sufficient for most music.

Lifecycle

Create one detector per scene (or share across scenes if the same source persists). Destroy it when you’re done:

destroy() {
    this.detector.destroy();
}

destroy() disconnects the worklet node, clears the signal subscribers, and releases references. The worklet processor itself stays registered for the duration of the page — that’s a browser-level concern, not a leak.

Examples

Beat Sync Pulse Audio Open in Playground View source
import { Application, BeatDetector, Color, Music, Scene, Sprite, Text, Texture, Vector } from '@codexo/exojs';
import {
    AlphaFadeOverLifetime,
    BurstSpawn,
    ConeDirection,
    Constant,
    particlesExtension,
    ParticleSystem,
} from '@codexo/exojs-particles';
import { mountControlPanel, mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
    extensions: [particlesExtension],
});

class BeatSyncPulseScene extends Scene {
    private music!: Music;
    private detector!: BeatDetector;
    private sprite!: Sprite;
    private pulse = 0;
    private intensity = 0.28;
    private beats = 0;
    private particles!: ParticleSystem;
    private burst!: BurstSpawn;
    private hud!: ReturnType<typeof mountControls>;
    private tapPrompt!: Text;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png', particle: 'image/particle-light.png' });
        await loader.load(Music, { track: 'audio/demo-loop-main.ogg' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.music = loader.get(Music, 'track');
        this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(width / 2, height / 2);

        this.hud = mountControls({
            title: 'Beat Sync Pulse',
            hint: 'The ring and particle burst fire on each detected beat.',
        });

        this.particles = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 3500 });
        this.particles.setPosition(width / 2, height / 2);
        this.burst = new BurstSpawn({
            schedule: [{ time: 0, count: 90 }],
            lifetime: new Constant(0.6),
            position: new Constant(new Vector(0, 0)),
            velocity: ConeDirection.omni(80, 240),
            scale: new Constant(new Vector(0.2, 0.2)),
        });
        this.particles.addSpawnModule(this.burst);
        this.particles.addUpdateModule(new AlphaFadeOverLifetime());

        // Shown while the browser still blocks audio (`app.audio.locked`); the
        // first click or keypress unlocks it and the queued music starts.
        this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' })
            .setAnchor(0.5, 0.5)
            .setPosition(width / 2, height - 64);

        mountControlPanel({ title: 'Tuning' }).addSlider({
            label: 'Pulse intensity',
            min: 0.1,
            max: 0.5,
            step: 0.01,
            value: this.intensity,
            onChange: value => {
                this.intensity = value;
            },
        });

        this.detector = new BeatDetector();
        this.detector.source = this.music;
        this.detector.onBeat.add(() => {
            this.pulse = this.intensity;
            this.burst.reset();
            this.beats += 1;
            this.hud.setStatus(`Beats detected: ${this.beats}`);
        });

        // Core defers playback until the AudioContext unlocks on the first
        // gesture, then starts automatically — just call play().
        this.music.setLoop(true).setVolume(0.8).play();
    }

    override update(delta): void {
        this.pulse = Math.max(0, this.pulse - delta.seconds * 1.2);
        this.sprite.setScale(1 + this.pulse);
        this.particles.update(delta);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.particles);
        context.render(this.sprite);

        if (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new BeatSyncPulseScene());

A sprite pulses on every beat and particles burst on the onBeat signal — the canonical beat-reactive pattern.

Tempo Tracking Audio Open in Playground View source
import { Application, BeatDetector, Color, Graphics, Music, Scene, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(14, 16, 22),
    loader: {
        basePath: 'assets/',
    },
});

class TempoTrackingScene extends Scene {
    private music!: Music;
    private detector!: BeatDetector;
    private readout!: Text;
    private confidenceLabel!: Text;
    private onsetLabel!: Text;
    private bars!: Graphics;
    private onset = 0;
    // Auto-gain reference for the onset bar: spectral flux has no fixed ceiling,
    // so we normalise against the loudest recent onset and let it decay back.
    private onsetPeak = 0.001;
    private hud!: ReturnType<typeof mountControls>;
    private tapPrompt!: Text;

    override async load(loader): Promise<void> {
        await loader.load(Music, { track: 'audio/demo-loop-main.ogg' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const marginX = width * 0.08;

        this.music = loader.get(Music, 'track');

        this.detector = new BeatDetector();
        this.detector.source = this.music;

        this.readout = new Text('BPM —', { fillColor: Color.white, fontSize: 40 });
        this.readout.setPosition(marginX, height * 0.18);

        this.confidenceLabel = new Text('Confidence', { fillColor: new Color(150, 220, 175), fontSize: 20 });
        this.confidenceLabel.setPosition(marginX, height * 0.42);

        this.onsetLabel = new Text('Onset energy', { fillColor: new Color(120, 200, 255), fontSize: 20 });
        this.onsetLabel.setPosition(marginX, height * 0.62);

        this.bars = new Graphics();

        this.hud = mountControls({
            title: 'Tempo Tracking',
            status: 'Tracking tempo…',
            hint: 'Estimated BPM and confidence from BeatDetector, alongside its raw onset energy (spectral flux).',
        });

        // Shown while the browser still blocks audio (`app.audio.locked`); the
        // first click or keypress unlocks it and the queued music starts.
        this.tapPrompt = new Text('Click or press any key to start the music', { fillColor: Color.white, fontSize: 22, align: 'center' })
            .setAnchor(0.5, 0.5)
            .setPosition(width / 2, height - 48);

        // Core defers playback until the AudioContext unlocks on the first
        // gesture, then starts automatically — just call play().
        this.music.setLoop(true).setVolume(0.8).play();
    }

    override update(delta): void {
        // Raw onset strength straight from the detector (positive spectral flux).
        this.onset = this.detector.onsetStrength;

        // Follow the loudest recent onset for the auto-gain reference, then bleed
        // it off so the bar stays responsive as the material changes.
        this.onsetPeak = Math.max(this.onset, this.onsetPeak * (1 - delta.seconds * 0.4));
    }

    override draw(context): void {
        const bpm = this.detector.tempo;
        const confidence = this.detector.confidence;
        const onsetNorm = Math.min(1, this.onset / this.onsetPeak);

        this.readout.text = bpm > 0 ? `BPM ${bpm.toFixed(1)}` : 'BPM —  (listening…)';
        this.confidenceLabel.text = `Confidence  ${confidence.toFixed(2)}`;
        this.onsetLabel.text = `Onset energy  ${this.onset.toFixed(2)}`;

        const { width, height } = this.app.canvas;
        const marginX = width * 0.08;
        const meterWidth = width - marginX * 2;
        const meterHeight = 30;
        const confidenceY = height * 0.42 + 36;
        const onsetY = height * 0.62 + 36;

        context.backend.clear();

        this.bars.clear();

        // Confidence meter.
        this.bars.fillColor = new Color(40, 44, 56);
        this.bars.drawRectangle(marginX, confidenceY, meterWidth, meterHeight);
        this.bars.fillColor = new Color(120, 220, 150);
        this.bars.drawRectangle(marginX, confidenceY, meterWidth * confidence, meterHeight);

        // Onset-energy meter.
        this.bars.fillColor = new Color(40, 44, 56);
        this.bars.drawRectangle(marginX, onsetY, meterWidth, meterHeight);
        this.bars.fillColor = new Color(96, 180, 255);
        this.bars.drawRectangle(marginX, onsetY, meterWidth * onsetNorm, meterHeight);

        context.render(this.bars);
        context.render(this.readout);
        context.render(this.confidenceLabel);
        context.render(this.onsetLabel);

        if (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new TempoTrackingScene());

Live BPM display with a confidence bar, demonstrating the tempo and confidence getters.

Where to go next

The next chapter, Audio-reactive visualization, covers how to bridge the detector’s output into the render pipeline — polling getters for continuous animation, spectrum data from AudioAnalyser, spectrograms with DataTexture, and building full audio-reactive scenes.