The demo below simulates the beat-detection pipeline on a synthetic drum loop. The top row shows the energy envelope — how loud each frame is. Spikes mark onsets. The bottom row shows the autocorrelation of those spikes: a peak at lag τ means "spikes repeat every τ frames." The tallest autocorrelation peak gives the estimated tempo.
<div class="hint-box">A synthetic drum loop plays at the chosen BPM. The <b>energy envelope</b> shows how loud each frame is — spikes are beats. The <b>autocorrelation</b> reveals the repeating period: the tallest bar gives the estimated tempo. The metronome flashes at that rate.</div>
<div class="controls">
<label>BPM: <span id="bpm-val">120</span> BPM
<input type="range" id="bpm-slider" min="60" max="180" value="120" step="1">
</label>
<label>Noise level:
<input type="range" id="noise-slider" min="0" max="40" value="10" step="1">
</label>
<button id="btn-restart" type="button">Restart</button>
</div>
<div class="panel">
<div class="panel-title">Energy envelope (onset spikes)</div>
<canvas id="cv-energy" width="560" height="100"></canvas>
</div>
<div class="panel">
<div class="panel-title">Autocorrelation (periodicity)</div>
<canvas id="cv-acf" width="560" height="100"></canvas>
</div>
<div class="metronome-row">
<div id="metro-light" class="metro-light"></div>
<span id="metro-label">Estimated tempo: —</span>
</div>
<div id="status" class="status"></div>
/* base layout and typography styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint-box { font-size: .85rem; color: #444; margin-bottom: .6rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: .5rem .8rem; align-items: center; margin-bottom: .5rem; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .35rem; }
input[type=range] { width: 90px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
/* chart panel and canvas styles */
.panel { margin-bottom: .4rem; }
.panel-title { font-size: .78rem; font-weight: 600; color: #1d3557; margin-bottom: 2px; }
canvas { width: 100%; max-width: 560px; height: 100px; display: block;
border: 1px solid #cdd9e3; border-radius: 6px; background: #f7fafc; }
/* metronome light and label styles */
.metronome-row { display: flex; align-items: center; gap: .5rem; margin: .4rem 0; }
.metro-light { width: 22px; height: 22px; border-radius: 50%;
background: #cdd9e3; border: 2px solid #adb1b8; transition: background .06s; }
.metro-light.on { background: #e63946; border-color: #c92f3c; }
#metro-label { font-size: .88rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Beat detection: estimate tempo from periodic energy spikes in the audio signal.
const FRAMES = 140; // total frames to simulate
const SR = 100; // simulated sample rate (frames per second)
// Generate a synthetic energy envelope with kick + snare beats at the given BPM.
function generateEnergy(bpm, noiseLvl) {
// frames between beats
const beatInterval = Math.round(SR * 60 / bpm);
const energy = new Float32Array(FRAMES);
for (let i = 0; i < FRAMES; i++) {
// base noise floor
energy[i] = Math.random() * noiseLvl;
}
// place kick drum peaks
for (let b = 0; b * beatInterval < FRAMES; b++) {
const pos = Math.round(b * beatInterval);
// Gaussian kick shape centered at beat position
for (let d = -2; d <= 4; d++) {
const idx = pos + d;
if (idx >= 0 && idx < FRAMES) {
energy[idx] += 100 * Math.exp(-0.5 * d * d);
}
}
// snare on the off-beat (every half-beat)
const snarePos = Math.round((b + 0.5) * beatInterval);
for (let d = -1; d <= 3; d++) {
const idx = snarePos + d;
if (idx >= 0 && idx < FRAMES) {
energy[idx] += 55 * Math.exp(-0.6 * d * d);
}
}
}
return energy;
}
// Compute autocorrelation of the energy signal to find periodicity.
function autocorrelation(energy) {
const maxLag = Math.floor(FRAMES / 2);
const acf = new Float32Array(maxLag);
// sum of products for each lag
for (let lag = 1; lag < maxLag; lag++) {
let sum = 0;
for (let i = 0; i < FRAMES - lag; i++) {
sum += energy[i] * energy[i + lag];
}
acf[lag] = sum / (FRAMES - lag);
}
return acf;
}
// Draw a bar chart on the canvas; highlight the peak bar.
function drawBar(canvas, data, peakIdx, color, peakColor) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
let maxV = 0;
for (let i = 0; i < data.length; i++) if (data[i] > maxV) maxV = data[i];
if (maxV === 0) maxV = 1;
const bw = W / data.length;
for (let i = 0; i < data.length; i++) {
const h = (data[i] / maxV) * (H - 4);
ctx.fillStyle = (peakIdx !== null && i === peakIdx) ? peakColor : color;
ctx.fillRect(i * bw, H - h - 2, Math.max(bw - 1, 1), h);
}
}
// Find the lag with highest autocorrelation = estimated beat period.
function detectTempo(acf) {
// skip very short lags (too fast to be a musical beat)
let bestLag = 1, bestVal = -1;
const minLag = 20; // skip lags below 20 frames (~300 BPM) to avoid sub-beat peaks
for (let lag = minLag; lag < acf.length; lag++) {
if (acf[lag] > bestVal) { bestVal = acf[lag]; bestLag = lag; }
}
return bestLag;
}
// Flash the metronome light at the detected BPM.
let metroTimer = null;
let lightTimer = null;
function startMetronome(bpmEstimate) {
clearInterval(metroTimer);
clearTimeout(lightTimer);
const light = document.getElementById('metro-light');
const intervalMs = (60 / bpmEstimate) * 1000;
function flash() {
light.classList.add('on');
lightTimer = setTimeout(() => light.classList.remove('on'), 80);
}
flash();
metroTimer = setInterval(flash, intervalMs);
}
// Main function: generate signal, compute ACF, detect tempo, draw, run metronome.
function run() {
const bpm = parseInt(document.getElementById('bpm-slider').value, 10);
document.getElementById('bpm-val').textContent = bpm;
const noiseLvl = parseInt(document.getElementById('noise-slider').value, 10);
const energy = generateEnergy(bpm, noiseLvl);
const acf = autocorrelation(energy);
const bestLag = detectTempo(acf);
const estBpm = Math.round(SR * 60 / bestLag);
drawBar(document.getElementById('cv-energy'), energy, null, '#6aafe6', '#e63946');
drawBar(document.getElementById('cv-acf'), acf, bestLag, '#7bcda0', '#e63946');
const st = document.getElementById('status');
if (Math.abs(estBpm - bpm) <= 5 || Math.abs(estBpm * 2 - bpm) <= 5 || Math.abs(estBpm - bpm * 2) <= 5) {
st.className = 'status ok';
st.textContent = i18nFmt('Detected %bpm% BPM — locked onto the beat!', { bpm: estBpm });
} else {
st.className = 'status bad';
st.textContent = i18nFmt('Detected %est% BPM (actual %actual% BPM) — harmonic alias.', { est: estBpm, actual: bpm });
}
document.getElementById('metro-label').textContent = i18nFmt('Estimated tempo: %bpm% BPM', { bpm: estBpm });
startMetronome(estBpm);
}
document.getElementById('btn-restart').addEventListener('click', run);
document.getElementById('bpm-slider').addEventListener('input', () => {
document.getElementById('bpm-val').textContent = document.getElementById('bpm-slider').value;
});
run();
<div class="hint-box">A synthetic drum loop plays at the chosen BPM. The <b>energy envelope</b> shows how loud each frame is — spikes are beats. The <b>autocorrelation</b> reveals the repeating period: the tallest bar gives the estimated tempo. The metronome flashes at that rate.</div>
<div class="controls">
<label>BPM: <span id="bpm-val">120</span> BPM
<input type="range" id="bpm-slider" min="60" max="180" value="120" step="1">
</label>
<label>Noise level:
<input type="range" id="noise-slider" min="0" max="40" value="10" step="1">
</label>
<button id="btn-restart" type="button">Restart</button>
</div>
<div class="panel">
<div class="panel-title">Energy envelope (onset spikes)</div>
<canvas id="cv-energy" width="560" height="100"></canvas>
</div>
<div class="panel">
<div class="panel-title">Autocorrelation (periodicity)</div>
<canvas id="cv-acf" width="560" height="100"></canvas>
</div>
<div class="metronome-row">
<div id="metro-light" class="metro-light"></div>
<span id="metro-label">Estimated tempo: —</span>
</div>
<div id="status" class="status"></div>
/* base layout and typography styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint-box { font-size: .85rem; color: #444; margin-bottom: .6rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: .5rem .8rem; align-items: center; margin-bottom: .5rem; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .35rem; }
input[type=range] { width: 90px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
/* chart panel and canvas styles */
.panel { margin-bottom: .4rem; }
.panel-title { font-size: .78rem; font-weight: 600; color: #1d3557; margin-bottom: 2px; }
canvas { width: 100%; max-width: 560px; height: 100px; display: block;
border: 1px solid #cdd9e3; border-radius: 6px; background: #f7fafc; }
/* metronome light and label styles */
.metronome-row { display: flex; align-items: center; gap: .5rem; margin: .4rem 0; }
.metro-light { width: 22px; height: 22px; border-radius: 50%;
background: #cdd9e3; border: 2px solid #adb1b8; transition: background .06s; }
.metro-light.on { background: #e63946; border-color: #c92f3c; }
#metro-label { font-size: .88rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Beat detection: estimate tempo from periodic energy spikes in the audio signal.
const FRAMES = 140; // total frames to simulate
const SR = 100; // simulated sample rate (frames per second)
// Generate a synthetic energy envelope with kick + snare beats at the given BPM.
function generateEnergy(bpm, noiseLvl) {
// frames between beats
const beatInterval = Math.round(SR * 60 / bpm);
const energy = new Float32Array(FRAMES);
for (let i = 0; i < FRAMES; i++) {
// base noise floor
energy[i] = Math.random() * noiseLvl;
}
// place kick drum peaks
for (let b = 0; b * beatInterval < FRAMES; b++) {
const pos = Math.round(b * beatInterval);
// Gaussian kick shape centered at beat position
for (let d = -2; d <= 4; d++) {
const idx = pos + d;
if (idx >= 0 && idx < FRAMES) {
energy[idx] += 100 * Math.exp(-0.5 * d * d);
}
}
// snare on the off-beat (every half-beat)
const snarePos = Math.round((b + 0.5) * beatInterval);
for (let d = -1; d <= 3; d++) {
const idx = snarePos + d;
if (idx >= 0 && idx < FRAMES) {
energy[idx] += 55 * Math.exp(-0.6 * d * d);
}
}
}
return energy;
}
// Compute autocorrelation of the energy signal to find periodicity.
function autocorrelation(energy) {
const maxLag = Math.floor(FRAMES / 2);
const acf = new Float32Array(maxLag);
// sum of products for each lag
for (let lag = 1; lag < maxLag; lag++) {
let sum = 0;
for (let i = 0; i < FRAMES - lag; i++) {
sum += energy[i] * energy[i + lag];
}
acf[lag] = sum / (FRAMES - lag);
}
return acf;
}
// Draw a bar chart on the canvas; highlight the peak bar.
function drawBar(canvas, data, peakIdx, color, peakColor) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
let maxV = 0;
for (let i = 0; i < data.length; i++) if (data[i] > maxV) maxV = data[i];
if (maxV === 0) maxV = 1;
const bw = W / data.length;
for (let i = 0; i < data.length; i++) {
const h = (data[i] / maxV) * (H - 4);
ctx.fillStyle = (peakIdx !== null && i === peakIdx) ? peakColor : color;
ctx.fillRect(i * bw, H - h - 2, Math.max(bw - 1, 1), h);
}
}
// Find the lag with highest autocorrelation = estimated beat period.
function detectTempo(acf) {
// skip very short lags (too fast to be a musical beat)
let bestLag = 1, bestVal = -1;
const minLag = 20; // skip lags below 20 frames (~300 BPM) to avoid sub-beat peaks
for (let lag = minLag; lag < acf.length; lag++) {
if (acf[lag] > bestVal) { bestVal = acf[lag]; bestLag = lag; }
}
return bestLag;
}
// Flash the metronome light at the detected BPM.
let metroTimer = null;
let lightTimer = null;
function startMetronome(bpmEstimate) {
clearInterval(metroTimer);
clearTimeout(lightTimer);
const light = document.getElementById('metro-light');
const intervalMs = (60 / bpmEstimate) * 1000;
function flash() {
light.classList.add('on');
lightTimer = setTimeout(() => light.classList.remove('on'), 80);
}
flash();
metroTimer = setInterval(flash, intervalMs);
}
// Main function: generate signal, compute ACF, detect tempo, draw, run metronome.
function run() {
const bpm = parseInt(document.getElementById('bpm-slider').value, 10);
document.getElementById('bpm-val').textContent = bpm;
const noiseLvl = parseInt(document.getElementById('noise-slider').value, 10);
const energy = generateEnergy(bpm, noiseLvl);
const acf = autocorrelation(energy);
const bestLag = detectTempo(acf);
const estBpm = Math.round(SR * 60 / bestLag);
drawBar(document.getElementById('cv-energy'), energy, null, '#6aafe6', '#e63946');
drawBar(document.getElementById('cv-acf'), acf, bestLag, '#7bcda0', '#e63946');
const st = document.getElementById('status');
if (Math.abs(estBpm - bpm) <= 5 || Math.abs(estBpm * 2 - bpm) <= 5 || Math.abs(estBpm - bpm * 2) <= 5) {
st.className = 'status ok';
st.textContent = i18nFmt('Detected %bpm% BPM — locked onto the beat!', { bpm: estBpm });
} else {
st.className = 'status bad';
st.textContent = i18nFmt('Detected %est% BPM (actual %actual% BPM) — harmonic alias.', { est: estBpm, actual: bpm });
}
document.getElementById('metro-label').textContent = i18nFmt('Estimated tempo: %bpm% BPM', { bpm: estBpm });
startMetronome(estBpm);
}
document.getElementById('btn-restart').addEventListener('click', run);
document.getElementById('bpm-slider').addEventListener('input', () => {
document.getElementById('bpm-val').textContent = document.getElementById('bpm-slider').value;
});
run();
Try changing the BPM slider and pressing Restart. Watch how the autocorrelation peak shifts as tempo changes. At very low or very high BPM the detector may lock onto a harmonic (half or double time) — a known failure mode of simple energy-based systems.
Comments
Loading comments...