Music
Metronome
Precise click track for practice. Set BPM, time signature, tap tempo. Space = start/stop.
120
BPM
STOPPED
Quick presets
Tempo Reference
| BPM | Term | Feel |
|---|---|---|
| 20–40 | Larghissimo | Very very slow |
| 40–60 | Larghetto | Broadly slow |
| 60–66 | Largo | Slow & broad |
| 66–76 | Adagio | Slow |
| 76–108 | Andante | Walking pace |
| 108–120 | Moderato | Moderate |
| 120–156 | Allegro | Fast |
| 156–176 | Vivace | Very fast |
| 176–200 | Presto | Very, very fast |
| 200–300 | Prestissimo | As fast as possible |
Practice Tips
- Start 20 BPM below your target, increase by 5 when clean
- Use 4/4 for most pop, rock, and contemporary music
- Use 3/4 for waltzes; 6/8 for swing and blues feels
- Sixteenth subdivisions help with syncopation practice
- Tap along to songs to find their tempo
- Play with a metronome every session — even for slow scales
- Practice difficult passages at 50% BPM first
- 120 BPM = 2 seconds per bar in 4/4 (useful reference)
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ── State ──────────────────────────────────────────────────────────
let bpm = 120, beatsPerBar = 4, subdiv = 1;
let isPlaying = false, currentBeat = 0, nextBeatTime = 0;
let seqTimer = null, rafId = null;
const LOOKAHEAD = 25, SCHED_AHEAD = 0.1;
let tapTimes = [];
// ── Audio ──────────────────────────────────────────────────────────
let audioCtx = null;
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function click(accent, time) {
const ctx = getCtx();
const osc = ctx.createOscillator(), gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = accent ? 1200 : 800;
gain.gain.setValueAtTime(accent ? 0.7 : 0.4, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.04);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(time); osc.stop(time + 0.04);
}
function subClick(time) {
const ctx = getCtx();
const osc = ctx.createOscillator(), gain = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = 1600;
gain.gain.setValueAtTime(0.12, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.02);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(time); osc.stop(time + 0.02);
}
// ── Scheduler ─────────────────────────────────────────────────────
function scheduler() {
const ctx = getCtx();
const beatDur = 60 / bpm;
while (nextBeatTime < ctx.currentTime + SCHED_AHEAD) {
click(currentBeat === 0, nextBeatTime);
if (subdiv > 1) {
for (let s = 1; s < subdiv; s++) subClick(nextBeatTime + (s / subdiv) * beatDur);
}
currentBeat = (currentBeat + 1) % beatsPerBar;
nextBeatTime += beatDur;
}
}
// ── Visual beat update ─────────────────────────────────────────────
let lastVisualBeat = -1;
function visualLoop() {
if (!isPlaying) return;
const ctx = getCtx();
const beatDur = 60 / bpm;
const elapsed = ctx.currentTime - (nextBeatTime - beatDur * beatsPerBar);
const vBeat = Math.floor(elapsed / beatDur) % beatsPerBar;
if (vBeat !== lastVisualBeat) {
lastVisualBeat = vBeat;
document.querySelectorAll('.beat-dot').forEach((d, i) => {
d.classList.toggle('active', i === vBeat);
});
}
rafId = requestAnimationFrame(visualLoop);
}
// ── Start / Stop ───────────────────────────────────────────────────
function start() {
if (isPlaying) return;
isPlaying = true; currentBeat = 0;
nextBeatTime = getCtx().currentTime + 0.1;
seqTimer = setInterval(scheduler, LOOKAHEAD);
rafId = requestAnimationFrame(visualLoop);
renderUI();
}
function stop() {
isPlaying = false;
clearInterval(seqTimer); seqTimer = null;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
lastVisualBeat = -1;
document.querySelectorAll('.beat-dot').forEach(d => d.classList.remove('active'));
renderUI();
}
// ── BPM helpers ────────────────────────────────────────────────────
function setBPM(v) {
bpm = Math.max(20, Math.min(300, Math.round(v)));
document.getElementById('bpmSlider').value = bpm;
document.getElementById('bpmVal').textContent = bpm;
document.getElementById('bpmDisplay').textContent = bpm;
document.documentElement.style.setProperty('--beat-duration', (60/bpm) + 's');
updatePresets();
}
function tapTempo() {
const now = Date.now();
tapTimes.push(now);
if (tapTimes.length > 4) tapTimes.shift();
if (tapTimes.length >= 2) {
let sum = 0;
for (let i = 1; i < tapTimes.length; i++) sum += tapTimes[i] - tapTimes[i-1];
setBPM(Math.round(60000 / (sum / (tapTimes.length - 1))));
}
const btn = document.getElementById('tapBtn');
btn.style.background = 'var(--accent-dim)';
setTimeout(() => btn.style.background = '', 120);
}
// ── Beat dots ──────────────────────────────────────────────────────
function buildDots() {
const c = document.getElementById('beatDots');
c.innerHTML = '';
for (let i = 0; i < beatsPerBar; i++) {
const d = document.createElement('div');
d.className = 'beat-dot' + (i === 0 ? ' accent' : '');
c.appendChild(d);
}
}
// ── Presets ────────────────────────────────────────────────────────
const PRESETS = [60, 80, 100, 120, 140, 160, 180];
function buildPresets() {
const g = document.getElementById('presetGrid');
PRESETS.forEach(p => {
const b = document.createElement('button');
b.className = 'preset-btn'; b.textContent = p;
b.addEventListener('click', () => setBPM(p));
g.appendChild(b);
});
}
function updatePresets() {
document.querySelectorAll('.preset-btn').forEach(b => {
b.classList.toggle('active', +b.textContent === bpm);
});
}
// ── Render UI state ────────────────────────────────────────────────
function renderUI() {
const disp = document.getElementById('bpmDisplay');
const status = document.getElementById('statusLine');
const playBtn = document.getElementById('playBtn');
const pend = document.getElementById('pendulum');
disp.classList.toggle('playing', isPlaying);
pend.classList.toggle('playing', isPlaying);
if (isPlaying) {
status.textContent = '▶ PLAYING'; status.classList.add('playing');
playBtn.textContent = '⏸ STOP';
} else {
status.textContent = 'STOPPED'; status.classList.remove('playing');
playBtn.textContent = '▶ START';
}
}
// ── Event wiring ───────────────────────────────────────────────────
document.getElementById('bpmSlider').addEventListener('input', e => setBPM(+e.target.value));
document.getElementById('d5').addEventListener('click', () => setBPM(bpm - 5));
document.getElementById('d1').addEventListener('click', () => setBPM(bpm - 1));
document.getElementById('i1').addEventListener('click', () => setBPM(bpm + 1));
document.getElementById('i5').addEventListener('click', () => setBPM(bpm + 5));
document.getElementById('tapBtn').addEventListener('click', tapTempo);
document.getElementById('playBtn').addEventListener('click', () => isPlaying ? stop() : start());
document.getElementById('resetBtn').addEventListener('click', stop);
document.getElementById('timeSig').addEventListener('change', e => {
beatsPerBar = +e.target.value;
buildDots();
if (isPlaying) { stop(); start(); }
});
document.getElementById('subdivSel').addEventListener('change', e => { subdiv = +e.target.value; });
document.addEventListener('keydown', e => {
if (e.target.tagName === 'SELECT') return;
if (e.code === 'Space') { e.preventDefault(); isPlaying ? stop() : start(); }
else if (e.key === 't' || e.key === 'T') { e.preventDefault(); tapTempo(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setBPM(bpm + 1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); setBPM(bpm - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); setBPM(bpm + 5); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); setBPM(bpm - 5); }
});
// ── Init ───────────────────────────────────────────────────────────
buildDots();
buildPresets();
setBPM(120);
renderUI();