Music

Metronome

Precise click track for practice. Set BPM, time signature, tap tempo. Space = start/stop.

120

BPM

STOPPED
Quick presets
Tempo Reference
BPMTermFeel
20–40LarghissimoVery very slow
40–60LarghettoBroadly slow
60–66LargoSlow & broad
66–76AdagioSlow
76–108AndanteWalking pace
108–120ModeratoModerate
120–156AllegroFast
156–176VivaceVery fast
176–200PrestoVery, very fast
200–300PrestissimoAs fast as possible
Practice Tips
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();