Metronome
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A metronome whose clicks are booked ahead of time on the sound card's own clock, so they land where they are supposed to even while the browser is busy repainting. A timer wakes up every 25 ms and schedules every click falling in the next 120 ms; the timer is allowed to be late, the clicks are not. To make the difference visible, a plain setInterval runs alongside doing nothing but counting, and its error is sampled at each tick — watch the millisecond count walk away from zero while the booked clicks stay exactly where they were put.
The arm is already swinging when the page opens — silent, because browsers only allow sound after a click. Press Start, then tap Tap tempo four times to lift the tempo out of your head, switch to 7/8 to hear the accents land on 1, 3 and 5, and turn on Triplets to subdivide the beat. The sliding weight rides up and down the scale as the tempo changes, exactly like the wind-up kind.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Metronome — sloprun.dev</title>
<link rel="icon" href="data:,">
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local values the shared tokens don't carry */
:root {
--well: #F1F0EC;
--shadow: 0 1px 2px rgba(26,29,33,.05);
--shadow-lift: 0 8px 22px rgba(26,29,33,.08);
--glow: rgba(192,86,0,.28);
--btn-ink: #FFFFFF;
--rod: #3A3F47;
--case: #EAE8E1;
--slot: #FCFBF9;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--well: #16181D;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--shadow-lift: 0 10px 26px rgba(0,0,0,.5);
--glow: rgba(255,180,84,.3);
--btn-ink: #14161A;
--rod: #C6C3BC;
--case: #242932;
--slot: #12151A;
}
}
:root[data-theme="dark"] {
--well: #16181D;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--shadow-lift: 0 10px 26px rgba(0,0,0,.5);
--glow: rgba(255,180,84,.3);
--btn-ink: #14161A;
--rod: #C6C3BC;
--case: #242932;
--slot: #12151A;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
.wrap {
max-width: 940px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 14px;
}
@media (max-width: 460px) { .wrap { padding: 16px 10px 20px; gap: 12px; } }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede .m { font-family: var(--font-mono); font-size: 13px; color: var(--ink); }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
}
/* ── main split ───────────────────────────────────────────────────────────── */
.main { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 0; }
@media (max-width: 700px) { .main { grid-template-columns: 1fr; } }
.stage {
padding: 16px 14px 18px; display: flex; flex-direction: column; align-items: center;
border-right: 1px solid var(--line); background:
radial-gradient(120% 60% at 50% 0%, var(--accent-soft) 0%, transparent 62%);
border-radius: var(--radius) 0 0 var(--radius);
}
@media (max-width: 700px) {
.stage { border-right: 0; border-bottom: 1px solid var(--line); border-radius: var(--radius) var(--radius) 0 0; }
}
.bigtempo { text-align: center; line-height: 1; }
.bpmline { font-family: var(--font-mono); font-weight: 600; letter-spacing: -.03em; display: flex; align-items: baseline; justify-content: center; gap: 6px; }
#bpmBig { font-size: clamp(40px, 11vw, 54px); font-variant-numeric: tabular-nums; }
.bpmline .unit { font-size: 12px; letter-spacing: .12em; color: var(--muted); }
.term { margin-top: 7px; font-size: 13px; color: var(--muted); font-style: italic; }
.metro { width: 100%; max-width: 252px; height: auto; display: block; margin: 4px 0 2px; }
.case { fill: var(--case); stroke: var(--line); stroke-width: 1.6; stroke-linejoin: round; }
.window { fill: var(--slot); stroke: var(--line); stroke-width: 1.2; }
.base { fill: var(--case); stroke: var(--line); stroke-width: 1.6; stroke-linejoin: round; }
.rod { fill: var(--rod); }
.bob { fill: var(--accent); stroke: rgba(0,0,0,.22); stroke-width: 1; }
.bobline { stroke: rgba(0,0,0,.25); stroke-width: 1; }
.pivot { fill: var(--surface); stroke: var(--rod); stroke-width: 2; }
.tick { stroke: var(--ink); opacity: .3; stroke-width: 1.5; stroke-linecap: round; }
.tick.maj { opacity: .55; }
.tlabel { fill: var(--ink); opacity: .6; font-family: var(--font-mono); font-size: 8px; letter-spacing: -.04em; }
.tip { fill: var(--accent); }
.gstop { stop-color: var(--accent); }
/* beat dots */
.dots { display: flex; gap: 8px; align-items: center; justify-content: center; min-height: 22px; margin-top: 8px; flex-wrap: wrap; }
.dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--line); border: 1px solid transparent;
transition: background .12s linear, box-shadow .12s linear, border-color .12s linear;
}
.dot.acc { width: 14px; height: 14px; border-color: var(--line); background: transparent; }
.dot.mid { width: 12px; height: 12px; }
.dot.on { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px var(--glow); }
@media (prefers-reduced-motion: reduce) {
.dot, .btn, .chip, .step { transition: none; }
.btn:active, .step:active { transform: none; }
}
.stagenote {
margin: 12px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted);
text-align: center; max-width: 30ch;
}
/* ── control panel ────────────────────────────────────────────────────────── */
.panel { padding: 16px 16px 18px; display: flex; flex-direction: column; gap: 15px; min-width: 0; justify-content: space-between; }
.transport { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.btn {
font: inherit; font-size: 13.5px; font-weight: 550;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 9px 14px; cursor: pointer; box-shadow: var(--shadow);
display: inline-flex; align-items: center; gap: 8px; line-height: 1;
transition: transform .08s ease, background .15s ease, border-color .15s ease;
}
.btn:hover { border-color: var(--muted); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--btn-ink); min-width: 116px; justify-content: center; }
.btn.primary:hover { filter: brightness(1.06); }
.gl { font-size: 11px; line-height: 1; }
.status { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .09em; color: var(--muted); }
.led { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); opacity: .5; }
.status.preview .led { background: var(--accent); opacity: 1; }
.status.preview { color: var(--accent); }
.status.run .led { background: var(--run); opacity: 1; box-shadow: 0 0 0 3px var(--run-soft); }
.status.run { color: var(--run); }
.readout {
margin-left: auto; display: inline-flex; align-items: baseline; gap: 6px;
font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .08em;
}
.readout b { font-size: 13.5px; color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 0; }
.ctl { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.cl {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .11em;
color: var(--muted); display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
}
.cv { color: var(--ink); font-size: 12px; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.sliderrow { display: flex; align-items: center; gap: 8px; }
.step {
font: inherit; font-family: var(--font-mono); font-size: 15px; line-height: 1;
width: 32px; height: 32px; flex: 0 0 auto; border-radius: 8px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
cursor: pointer; box-shadow: var(--shadow);
}
.step:hover { border-color: var(--muted); }
.step:active { transform: translateY(1px); }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; min-width: 0;
background: transparent; height: 26px; margin: 0; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 6px; border-radius: 999px; background: var(--well); border: 1px solid var(--line); }
input[type=range]::-moz-range-track { height: 6px; border-radius: 999px; background: var(--well); border: 1px solid var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 18px; height: 18px; margin-top: -7px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 1px 3px rgba(0,0,0,.25);
}
input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.25);
}
.taprow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.btn.tap { position: relative; overflow: hidden; min-width: 118px; justify-content: center; }
.btn.tap.hit { background: var(--accent-soft); border-color: var(--accent); }
.tapout { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); letter-spacing: .02em; }
.tapout b { color: var(--ink); font-weight: 600; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font: inherit; font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em;
padding: 7px 11px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--muted);
transition: background .14s ease, color .14s ease, border-color .14s ease;
}
.chip:hover { border-color: var(--muted); color: var(--ink); }
.chip[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
.chip .sub { font-size: 10px; opacity: .75; margin-left: 5px; letter-spacing: .04em; }
.cnote { margin: -1px 0 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .01em; }
/* ── under the hood ───────────────────────────────────────────────────────── */
.hood { padding: 14px 16px 16px; }
.hood h2 { margin: 0 0 3px; font-size: 14px; letter-spacing: -.01em; }
.hood .sub { margin: 0 0 12px; font-size: 13px; color: var(--muted); line-height: 1.5; max-width: 74ch; }
.rows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
@media (max-width: 560px) { .rows { grid-template-columns: 1fr; } }
.hrow { background: var(--well); border-radius: 8px; padding: 9px 11px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.hk { font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; color: var(--muted); }
.hv { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.hrow.ok .hv { color: var(--run); }
.hrow.warn .hv { color: var(--accent); }
.hrow.bad .hv { color: var(--danger); }
.hint { margin: 0; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.hint b { color: var(--ink); font-weight: 600; }
kbd {
font-family: var(--font-mono); font-size: 11px; padding: 2px 6px; border-radius: 5px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink); box-shadow: var(--shadow);
}
.note {
margin: 0; font-size: 12.5px; color: var(--danger);
border: 1px solid var(--line); border-left: 3px solid var(--danger);
background: var(--surface); border-radius: 6px; padding: 8px 11px;
}
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .06em; padding-top: 2px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Metronome</h1>
<p class="lede">
A click track that keeps time on the <b>sound card's own clock</b> instead of a JavaScript timer.
The arm is already swinging — it is silent until you press <b>Start</b>, because browsers
only allow sound after a click.
</p>
</header>
<section class="card main">
<div class="stage">
<div class="bigtempo">
<div class="bpmline"><span id="bpmBig">108</span><span class="unit">BPM</span></div>
<div class="term" id="term">Moderato</div>
</div>
<svg class="metro" id="metro" viewBox="0 0 200 232" role="img" aria-labelledby="metroTitle">
<title id="metroTitle">A metronome arm swinging in time with the beat</title>
<defs>
<radialGradient id="mGlow" cx="50%" cy="50%" r="50%">
<stop class="gstop" offset="0%" stop-opacity=".5"/>
<stop class="gstop" offset="100%" stop-opacity="0"/>
</radialGradient>
</defs>
<path class="case" d="M30 214 L72 54 L128 54 L170 214 Z"/>
<rect class="window" x="71" y="64" width="57" height="140" rx="6"/>
<g id="ticks"></g>
<rect class="base" x="22" y="210" width="156" height="13" rx="5"/>
<g id="arm">
<circle id="glow" cx="100" cy="20" r="30" fill="url(#mGlow)" opacity="0"/>
<rect class="rod" x="97.8" y="18" width="4.4" height="190" rx="2.2"/>
<g id="bobG" transform="translate(0,140)">
<rect class="bob" x="89" y="-8" width="22" height="16" rx="3"/>
<line class="bobline" x1="91" y1="-3.5" x2="109" y2="-3.5"/>
</g>
<circle class="tip" id="tip" cx="100" cy="20" r="5"/>
</g>
<circle class="pivot" cx="100" cy="206" r="6.5"/>
</svg>
<div class="dots" id="dots" aria-hidden="true"></div>
<p class="stagenote" id="stageNote">Silent preview — press Start to hear it.</p>
</div>
<div class="panel">
<div class="transport">
<button class="btn primary" id="play" type="button">
<span class="gl" id="playGlyph" aria-hidden="true">▶</span><span id="playLabel">Start</span>
</button>
<span class="status preview" id="status"><span class="led" aria-hidden="true"></span><span id="statusText">PREVIEW</span></span>
<div class="readout" aria-hidden="true">
<span>BAR</span><b id="roBar">001</b><span>BEAT</span><b id="roBeat">1/4</b>
</div>
</div>
<div class="ctl">
<label class="cl" for="tempo">TEMPO<span class="cv"><span id="tempoV">108</span> BPM</span></label>
<div class="sliderrow">
<button class="step" id="slower" type="button" aria-label="Slower by one BPM">−</button>
<input type="range" id="tempo" min="40" max="240" step="1" value="108" aria-label="Tempo in beats per minute">
<button class="step" id="faster" type="button" aria-label="Faster by one BPM">+</button>
</div>
</div>
<div class="ctl">
<span class="cl">TAP IT OUT</span>
<div class="taprow">
<button class="btn tap" id="tap" type="button">Tap tempo</button>
<span class="tapout" id="tapOut">tap 4 times on the beat</span>
</div>
</div>
<div class="ctl">
<span class="cl" id="sigLabel">TIME SIGNATURE</span>
<div class="chips" id="sigChips" role="group" aria-labelledby="sigLabel"></div>
<p class="cnote" id="sigNote"></p>
</div>
<div class="ctl">
<span class="cl" id="divLabel">SUBDIVIDE THE BEAT</span>
<div class="chips" id="divChips" role="group" aria-labelledby="divLabel"></div>
<p class="cnote" id="divNote"></p>
</div>
<div class="ctl">
<label class="cl" for="vol">VOLUME<span class="cv"><span id="volV">70</span>%</span></label>
<input type="range" id="vol" min="0" max="100" step="1" value="70" aria-label="Volume">
</div>
</div>
</section>
<section class="card hood">
<h2>Under the hood</h2>
<p class="sub">
A timer that fires every 25 ms wakes up and books every click that falls inside the next
120 ms, stamped with an exact time on the audio clock. The timer is allowed to be late;
the clicks are not. A plain <span style="font-family:var(--font-mono)">setInterval</span> runs
alongside doing nothing but counting, and its error is read at each tick — that is the
number you can watch slide away from zero.
</p>
<div class="rows">
<div class="hrow"><span class="hk">SCHEDULER</span><span class="hv" id="hSched">wakes every 25 ms · books 120 ms ahead</span></div>
<div class="hrow" id="hRowJit"><span class="hk">TIMER LATENESS</span><span class="hv" id="hJit">—</span></div>
<div class="hrow" id="hRowNote"><span class="hk">CLICK PLACEMENT</span><span class="hv" id="hNote">waiting for start</span></div>
<div class="hrow" id="hRowDrift"><span class="hk">PLAIN setInterval</span><span class="hv" id="hDrift">—</span></div>
</div>
</section>
<p class="hint">
<kbd>Space</kbd> starts and stops, <kbd>T</kbd> taps the tempo, <kbd>↑</kbd><kbd>↓</kbd> nudge
it by 1 (hold <kbd>Shift</kbd> for 5). The <b>first beat of every bar</b> is the loud one; 4/4, 5/4
and 6/8 add one softer accent inside the bar, and 7/8 adds two — it counts 2+2+3.
</p>
<p class="note" id="audioNote" hidden>This browser has no Web Audio support, so the arm swings without sound.</p>
<p class="note" id="blockNote" hidden></p>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr" id="live" role="status" aria-live="polite"></div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var AC = window.AudioContext || window.webkitAudioContext;
var audioDead = !AC;
var mq = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduce = mq ? mq.matches : false;
if (mq) {
if (mq.addEventListener) mq.addEventListener("change", function (e) { reduce = e.matches; });
else if (mq.addListener) mq.addListener(function (e) { reduce = e.matches; });
}
/* ── musical config ─────────────────────────────────────────────────────── */
/* accent levels: 2 = downbeat, 1 = inner accent, 0 = plain, -1 = subdivision */
var SIGS = [
{ id: "2/4", beats: 2, pat: [2, 0] },
{ id: "3/4", beats: 3, pat: [2, 0, 0] },
{ id: "4/4", beats: 4, pat: [2, 0, 1, 0] },
{ id: "5/4", beats: 5, pat: [2, 0, 0, 1, 0] },
{ id: "6/8", beats: 6, pat: [2, 0, 0, 1, 0, 0] },
{ id: "7/8", beats: 7, pat: [2, 0, 1, 0, 1, 0, 0] }
];
var DIVS = [
{ n: 1, label: "Quarter", sub: "×1" },
{ n: 2, label: "Eighths", sub: "×2" },
{ n: 3, label: "Triplets", sub: "×3" },
{ n: 4, label: "Sixteenths", sub: "×4" }
];
var TERMS = [
[40, "Grave"], [46, "Largo"], [56, "Larghetto"], [66, "Adagio"], [76, "Andante"],
[108, "Moderato"], [120, "Allegro moderato"], [156, "Allegro"], [176, "Vivace"],
[200, "Presto"], [241, "Prestissimo"]
];
var LOOKAHEAD_MS = 25; /* how often the timer wakes up */
var AHEAD = 0.12; /* how far ahead of the audio clock we book clicks */
var AMP = 24; /* pendulum swing, degrees */
var state = { bpm: 108, sig: 2, div: 1, vol: 70, mode: "preview" };
function spb() { return 60 / state.bpm; }
function sig() { return SIGS[state.sig]; }
/* ── elements ───────────────────────────────────────────────────────────── */
var $ = function (id) { return document.getElementById(id); };
var elArm = $("arm"), elBob = $("bobG"), elTip = $("tip"), elGlow = $("glow");
var elDots = $("dots"), elBpmBig = $("bpmBig"), elTerm = $("term");
var elTempo = $("tempo"), elTempoV = $("tempoV"), elVol = $("vol"), elVolV = $("volV");
var elPlay = $("play"), elPlayLabel = $("playLabel"), elPlayGlyph = $("playGlyph");
var elStatus = $("status"), elStatusText = $("statusText");
var elBar = $("roBar"), elBeat = $("roBeat"), elStageNote = $("stageNote");
var elTap = $("tap"), elTapOut = $("tapOut"), elLive = $("live");
var elJit = $("hJit"), elNote = $("hNote"), elDrift = $("hDrift");
var rowNote = $("hRowNote"), rowDrift = $("hRowDrift"), rowJit = $("hRowJit");
/* ── audio ──────────────────────────────────────────────────────────────── */
var ctx = null, master = null, noiseBuf = null;
function volGain() { var v = state.vol / 100; return v * v * 0.9; }
function ensureAudio() {
if (ctx || audioDead) return ctx;
try { ctx = new AC(); } catch (e) { ctx = null; audioDead = true; $("audioNote").hidden = false; return null; }
master = ctx.createGain();
master.gain.value = volGain();
master.connect(ctx.destination);
var n = Math.max(1, Math.floor(ctx.sampleRate * 0.2));
noiseBuf = ctx.createBuffer(1, n, ctx.sampleRate);
var d = noiseBuf.getChannelData(0);
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return ctx;
}
/* stalled = the context reports "running" but its clock never advances
(no output device). Fall back to the wall clock so the arm keeps time. */
var stalled = false;
function audioUsable() { return !!ctx && !stalled; }
function nowSec() { return audioUsable() ? ctx.currentTime : performance.now() / 1000; }
var VOICE = {
"2": { f: 1660, g: 0.95, n: 0.32, d: 0.055 },
"1": { f: 1245, g: 0.66, n: 0.22, d: 0.050 },
"0": { f: 988, g: 0.54, n: 0.17, d: 0.046 },
"-1": { f: 1320, g: 0.17, n: 0.06, d: 0.026 }
};
function click(t, lvl) {
if (!ctx) return;
var s = VOICE[String(lvl)] || VOICE["0"];
var o = ctx.createOscillator();
o.type = "sine";
o.frequency.setValueAtTime(s.f, t);
o.frequency.exponentialRampToValueAtTime(s.f * 0.7, t + s.d);
var g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(s.g, t + 0.0018);
g.gain.exponentialRampToValueAtTime(0.0001, t + s.d);
o.connect(g); g.connect(master);
o.start(t); o.stop(t + s.d + 0.02);
var src = ctx.createBufferSource();
src.buffer = noiseBuf;
var hp = ctx.createBiquadFilter();
hp.type = "highpass"; hp.frequency.value = 2400;
var ng = ctx.createGain();
ng.gain.setValueAtTime(s.n, t);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.013);
src.connect(hp); hp.connect(ng); ng.connect(master);
src.start(t); src.stop(t + 0.04);
}
/* ── the lookahead scheduler ────────────────────────────────────────────── */
var timer = null, nextTick = 0, tickInBeat = 0, sBeatInBar = 0, sBar = 1, sTotal = 0;
var vq = [], placed = 0;
var wakePrev = 0, jitPeak = 0, jitSum = 0, jitN = 0, jitAt = 0, dropped = 0;
var shTimer = null, shTicks = 0, shStart = 0, shPeriod = 0, shDrift = 0;
function scheduler() {
if (!ctx) return;
var t = performance.now();
if (wakePrev) {
var late = Math.abs(t - wakePrev - LOOKAHEAD_MS);
if (late > jitPeak) jitPeak = late;
jitSum += late; jitN++;
}
wakePrev = t;
var now = ctx.currentTime;
/* the wake-up was so late that a click would have been booked in the past —
the only case where this scheduler can actually lose one */
if (nextTick < now) { dropped++; nextTick = now + 0.01; }
/* nothing drains the visual queue while the tab is hidden, so keep it bounded */
if (vq.length > 400) vq.splice(0, vq.length - 200);
while (nextTick < now + AHEAD) {
var b = sig().beats;
var inBar = sBeatInBar % b;
var lvl = tickInBeat === 0 ? sig().pat[inBar] : -1;
click(nextTick, lvl);
placed++;
vq.push({ t: nextTick, main: tickInBeat === 0, lvl: lvl, beat: sTotal, bar: sBar, inBar: inBar });
nextTick += spb() / state.div;
tickInBeat++;
if (tickInBeat >= state.div) {
tickInBeat = 0; sTotal++; sBeatInBar = inBar + 1;
if (sBeatInBar >= b) { sBeatInBar = 0; sBar++; }
}
}
}
/* A plain setInterval running alongside, purely as a control. Its error is
sampled AT each tick — comparing a mid-beat clock reading against a whole
number of ticks would just measure where you are inside the beat. */
function startShadow() {
stopShadow();
if (!ctx) return;
shTicks = 0; shDrift = 0; shPeriod = spb(); shStart = ctx.currentTime;
shTimer = setInterval(function () {
if (!ctx) return;
shTicks++;
shDrift = ((ctx.currentTime - shStart) - shTicks * shPeriod) * 1000;
}, shPeriod * 1000);
}
function stopShadow() { if (shTimer) { clearInterval(shTimer); shTimer = null; } }
/* ── the silent preview clock (before the first click, and if audio is off) ─ */
var pNext = 0, pInBar = 0, pBar = 1, pTotal = 0;
function previewStep(now) {
if (!pNext) pNext = now + 0.05;
if (now - pNext > 0.6) pNext = now; /* tab was hidden: rejoin the beat */
var guard = 0;
while (now >= pNext && guard++ < 16) {
var b = sig().beats;
var inBar = pInBar % b;
applyBeat({ t: pNext, main: true, lvl: sig().pat[inBar], beat: pTotal, bar: pBar, inBar: inBar });
pNext += spb();
pTotal++; pInBar = inBar + 1;
if (pInBar >= b) { pInBar = 0; pBar++; }
}
}
/* ── visual state ───────────────────────────────────────────────────────── */
var clock = { t: 0, beat: 0, lvl: 0 };
var subFlash = 0, activeDot = -1, shownBar = -1, shownBeat = -1;
var raf = null, stopAt = 0, lastAngle = 0;
function applyBeat(e) {
clock.t = e.t; clock.beat = e.beat; clock.lvl = e.lvl;
if (activeDot !== e.inBar) {
var kids = elDots.children;
if (activeDot >= 0 && kids[activeDot]) kids[activeDot].classList.remove("on");
if (kids[e.inBar]) kids[e.inBar].classList.add("on");
activeDot = e.inBar;
}
if (shownBar !== e.bar) { shownBar = e.bar; elBar.textContent = pad3(e.bar); }
if (shownBeat !== e.inBar) { shownBeat = e.inBar; elBeat.textContent = (e.inBar + 1) + "/" + sig().beats; }
}
function drain(now) {
var main = null;
while (vq.length && vq[0].t <= now + 0.004) {
var e = vq.shift();
if (e.main) main = e; else subFlash = 1;
}
if (main) applyBeat(main);
}
function pad3(n) { return n < 10 ? "00" + n : n < 100 ? "0" + n : String(n); }
function frame() {
raf = requestAnimationFrame(frame);
var now = nowSec();
if (state.mode === "preview" || (state.mode === "running" && !audioUsable())) previewStep(now);
else if (state.mode === "running") drain(now);
var damp = 1;
if (state.mode === "stopped") {
damp = Math.exp(-(now - stopAt) * 2.4);
if (damp < 0.012) damp = 0;
}
var phase = clock.beat + (now - clock.t) / spb();
var ang = reduce ? 0 : AMP * Math.cos(Math.PI * phase) * damp;
lastAngle = ang;
elArm.setAttribute("transform", "rotate(" + ang.toFixed(2) + " 100 206)");
var since = now - clock.t;
var pulse = state.mode === "stopped" ? 0 : Math.exp(-Math.max(0, since) * 8);
var strength = clock.lvl === 2 ? 1 : clock.lvl === 1 ? 0.75 : 0.55;
var p = pulse * strength;
if (subFlash > 0) { p = Math.max(p, subFlash * 0.3); subFlash -= 0.14; if (subFlash < 0) subFlash = 0; }
/* the arm is already frozen under reduced motion; keep the glow as a soft
marker rather than a full-strength flash up to 4 times a second */
if (reduce) p = state.mode === "stopped" ? 0 : Math.min(p, 0.3) + 0.12;
elGlow.setAttribute("opacity", (p * 0.95).toFixed(3));
elTip.setAttribute("r", (reduce ? 5 : 5 + p * 4.5).toFixed(2));
if (now - hoodAt > 0.22) { hoodAt = now; updateHood(now); }
if (state.mode === "stopped" && damp === 0) {
elArm.setAttribute("transform", "rotate(0 100 206)");
elGlow.setAttribute("opacity", "0");
elTip.setAttribute("r", "5");
cancelAnimationFrame(raf); raf = null;
}
}
function ensureLoop() { if (!raf) raf = requestAnimationFrame(frame); }
/* ── the instrument panel readouts ──────────────────────────────────────── */
var hoodAt = 0;
function updateHood(now) {
if (state.mode !== "running" || !audioUsable()) return;
if (jitN > 4) {
elJit.textContent = "avg " + (jitSum / jitN).toFixed(1) + " ms · peak " + jitPeak.toFixed(1) + " ms late";
rowJit.className = "hrow warn";
}
if (now - jitAt > 4) { jitAt = now; jitSum = 0; jitN = 0; jitPeak = 0; }
elNote.textContent = "sample-exact · " + placed + " placed, " + dropped + " dropped";
rowNote.className = "hrow " + (dropped ? "warn" : "ok");
if (shTicks > 3) {
var ms = Math.abs(shDrift), tail = " after " + shTicks + " ticks";
elDrift.textContent = ms < 0.5 ? "level with the audio clock" + tail
: (shDrift > 0 ? "+" : "−") + ms.toFixed(0) + " ms " +
(shDrift > 0 ? "behind" : "ahead") + tail;
rowDrift.className = "hrow " + (ms > 90 ? "bad" : ms < 0.5 ? "ok" : "warn");
} else {
elDrift.textContent = "counting…";
rowDrift.className = "hrow";
}
}
function resetHood() {
elJit.textContent = "—";
rowJit.className = "hrow";
elNote.textContent = "waiting for start";
rowNote.className = "hrow";
elDrift.textContent = "—";
rowDrift.className = "hrow";
}
/* ── transport ──────────────────────────────────────────────────────────── */
function setStatus(kind, text) {
elStatus.className = "status" + (kind ? " " + kind : "");
elStatusText.textContent = text;
}
var blockCheck = null;
/* Hand the beat to the wall clock and say why the room went quiet. */
function fallBackToSilent(msg, note) {
clearTimeout(blockCheck);
if (timer) { clearInterval(timer); timer = null; }
stopShadow();
vq.length = 0;
pNext = 0; pInBar = 0; pBar = 1; pTotal = 0;
clock.t = performance.now() / 1000; clock.beat = 0; clock.lvl = sig().pat[0];
resetHood();
elNote.textContent = "no audio clock — nothing to place";
$("blockNote").textContent = note;
$("blockNote").hidden = false;
elStageNote.textContent = msg;
setStatus("preview", "SILENT");
ensureLoop();
}
function audioBlocked() {
if (state.mode !== "running" || !ctx || ctx.state === "running") return;
fallBackToSilent(
"The browser is holding sound back — press Start again.",
"The browser blocked the audio clock from starting. Press Start once more — a direct click usually releases it.");
}
/* A context can report "running" while its clock stands still: no output
device. Nothing would ever play and the arm would freeze mid-swing. */
function checkClockMoving(t0, tries) {
if (state.mode !== "running" || !ctx) return;
if (ctx.state !== "running") { audioBlocked(); return; }
if (ctx.currentTime - t0 > 0.05) return;
/* a slow resume can look identical for a moment — give it one more look */
if (tries > 0) {
blockCheck = setTimeout(function () { checkClockMoving(t0, tries - 1); }, 1200);
return;
}
stalled = true;
fallBackToSilent(
"This machine has no audio clock — the arm still keeps time.",
"The audio clock never started ticking, so there is no output device to click on. The metronome keeps time silently.");
}
function start() {
$("blockNote").hidden = true;
stalled = false;
var c = ensureAudio();
if (c) {
if (c.state !== "running") {
var pr = c.resume();
if (pr && pr.then) pr.then(null, audioBlocked);
}
master.gain.cancelScheduledValues(c.currentTime);
master.gain.setValueAtTime(volGain(), c.currentTime);
clearTimeout(blockCheck);
blockCheck = setTimeout(function (t0) {
return function () { checkClockMoving(t0, 1); };
}(c.currentTime), 500);
}
state.mode = "running";
tickInBeat = 0; sBeatInBar = 0; sBar = 1; sTotal = 0;
vq.length = 0; placed = 0; dropped = 0;
wakePrev = 0; jitPeak = 0; jitSum = 0; jitN = 0;
clock.t = nowSec(); clock.beat = 0; clock.lvl = sig().pat[0];
activeDot = -1; shownBar = -1; shownBeat = -1;
if (c) {
nextTick = c.currentTime + 0.09;
jitAt = c.currentTime;
if (timer) clearInterval(timer);
timer = setInterval(scheduler, LOOKAHEAD_MS);
scheduler();
startShadow();
elStageNote.textContent = "Every click below is booked before it is heard.";
setStatus("run", "RUNNING");
} else {
pNext = nowSec(); pInBar = 0; pBar = 1; pTotal = 0;
elStageNote.textContent = "No sound available here — the arm still keeps time.";
setStatus("preview", "SILENT");
}
elPlayLabel.textContent = "Stop";
elPlayGlyph.innerHTML = "■";
elPlay.setAttribute("aria-label", "Stop the metronome");
say("Started at " + state.bpm + " BPM in " + sig().id);
ensureLoop();
}
function stop() {
state.mode = "stopped";
clearTimeout(blockCheck);
$("blockNote").hidden = true;
if (timer) { clearInterval(timer); timer = null; }
stopShadow();
/* clicks are booked up to AHEAD seconds out; duck the bus so Stop is silent now */
if (ctx && master) {
master.gain.cancelScheduledValues(ctx.currentTime);
master.gain.setTargetAtTime(0.0001, ctx.currentTime, 0.012);
}
vq.length = 0;
stopAt = nowSec();
if (activeDot >= 0 && elDots.children[activeDot]) elDots.children[activeDot].classList.remove("on");
activeDot = -1;
elPlayLabel.textContent = "Start";
elPlayGlyph.innerHTML = "▶";
elPlay.setAttribute("aria-label", "Start the metronome");
setStatus("", "STOPPED");
elStageNote.textContent = "Stopped. Press Start to pick it up again.";
resetHood();
say("Stopped");
ensureLoop();
}
function toggle() { if (state.mode === "running") stop(); else start(); }
/* ── tempo ──────────────────────────────────────────────────────────────── */
var Y_TOP = 78, Y_BOT = 190, L_LO = Math.log(40), L_HI = Math.log(240);
function bpmY(b) { return Y_TOP + (Math.log(b) - L_LO) / (L_HI - L_LO) * (Y_BOT - Y_TOP); }
function termFor(b) {
for (var i = 0; i < TERMS.length; i++) if (b < TERMS[i][0]) return i === 0 ? TERMS[0][1] : TERMS[i - 1][1];
return TERMS[TERMS.length - 1][1];
}
var sayTimer = null;
function say(msg) {
clearTimeout(sayTimer);
elLive.textContent = "";
sayTimer = setTimeout(function () { elLive.textContent = msg; }, 110);
}
var tempoSay = null;
function setBpm(v, src) {
v = Math.round(v);
if (v < 40) v = 40; if (v > 240) v = 240;
if (v === state.bpm && src !== "init") return;
state.bpm = v;
elBpmBig.textContent = v;
elTempoV.textContent = v;
elTerm.textContent = termFor(v);
if (elTempo.value !== String(v)) elTempo.value = String(v);
elBob.setAttribute("transform", "translate(0," + bpmY(v).toFixed(1) + ")");
captions();
if (state.mode === "running" && audioUsable()) startShadow();
if (src && src !== "init" && src !== "slider") {
clearTimeout(tempoSay);
tempoSay = setTimeout(function () { say(v + " BPM, " + termFor(v)); }, 260);
}
}
/* ── tap tempo ──────────────────────────────────────────────────────────── */
var taps = [], tapReset = null;
function doTap() {
var t = performance.now();
if (taps.length && t - taps[taps.length - 1] > 2500) taps = [];
taps.push(t);
if (taps.length > 7) taps.shift();
elTap.classList.add("hit");
setTimeout(function () { elTap.classList.remove("hit"); }, 110);
if (taps.length < 2) {
elTapOut.innerHTML = "<b>1</b> tap · keep going";
} else {
var gaps = [], i;
for (i = 1; i < taps.length; i++) gaps.push(taps[i] - taps[i - 1]);
var sum = 0, lo = Infinity, hi = 0;
for (i = 0; i < gaps.length; i++) { sum += gaps[i]; if (gaps[i] < lo) lo = gaps[i]; if (gaps[i] > hi) hi = gaps[i]; }
var avg = sum / gaps.length;
var bpm = 60000 / avg;
if (bpm >= 30 && bpm <= 400) {
setBpm(bpm, "tap");
elTapOut.innerHTML = "<b>" + taps.length + "</b> taps · <b>" + Math.round(bpm) +
"</b> BPM · spread " + Math.round(hi - lo) + " ms";
} else {
taps = [t];
elTapOut.innerHTML = "too far apart · start again";
}
}
clearTimeout(tapReset);
tapReset = setTimeout(function () {
taps = [];
elTapOut.textContent = "tap 4 times on the beat";
}, 3000);
}
/* ── build the pickers, dots and scale ──────────────────────────────────── */
function buildDots() {
var b = sig().beats, pat = sig().pat, html = "";
for (var i = 0; i < b; i++) {
var cls = "dot" + (pat[i] === 2 ? " acc" : pat[i] === 1 ? " mid" : "");
html += '<span class="' + cls + '"></span>';
}
elDots.innerHTML = html;
activeDot = -1;
elBeat.textContent = "1/" + b;
shownBeat = -1;
}
function captions() {
var sg = sig(), acc = [];
for (var i = 0; i < sg.beats; i++) if (sg.pat[i] > 0) acc.push(i + 1);
var list = acc.length === 1 ? String(acc[0]) :
acc.slice(0, -1).join(", ") + " & " + acc[acc.length - 1];
var unit = sg.id.charAt(sg.id.length - 1) === "8" ? "an eighth note" : "a quarter note";
$("sigNote").textContent = sg.beats + " to the bar · accent" + (acc.length > 1 ? "s" : "") +
" on " + list + " · one click is " + unit;
$("divNote").textContent = state.div === 1
? "one click per beat · " + state.bpm + " a minute"
: state.div + " clicks per beat · " + (state.bpm * state.div) + " a minute";
}
function buildChips() {
var s = "", i;
for (i = 0; i < SIGS.length; i++) {
s += '<button class="chip" type="button" data-sig="' + i + '" aria-pressed="' +
(i === state.sig) + '">' + SIGS[i].id + '</button>';
}
$("sigChips").innerHTML = s;
s = "";
for (i = 0; i < DIVS.length; i++) {
s += '<button class="chip" type="button" data-div="' + i + '" aria-pressed="' +
(DIVS[i].n === state.div) + '">' + DIVS[i].label +
'<span class="sub">' + DIVS[i].sub + '</span></button>';
}
$("divChips").innerHTML = s;
}
function buildScale() {
var marks = [40, 46, 52, 60, 69, 80, 92, 104, 120, 138, 160, 184, 208, 240];
var majors = { 40: 1, 60: 1, 80: 1, 120: 1, 160: 1, 240: 1 };
var NS = "http://www.w3.org/2000/svg";
var g = $("ticks");
for (var i = 0; i < marks.length; i++) {
var y = bpmY(marks[i]), maj = majors[marks[i]];
/* ticks live to the RIGHT of the rod, numbers to the LEFT of it, so the
sliding weight (x 89–111) never sits on top of a number */
var ln = document.createElementNS(NS, "line");
ln.setAttribute("class", "tick" + (maj ? " maj" : ""));
ln.setAttribute("x1", maj ? "103" : "106"); ln.setAttribute("x2", "116");
ln.setAttribute("y1", y.toFixed(1)); ln.setAttribute("y2", y.toFixed(1));
g.appendChild(ln);
if (maj) {
var tx = document.createElementNS(NS, "text");
tx.setAttribute("class", "tlabel");
tx.setAttribute("text-anchor", "end");
tx.setAttribute("x", "88"); tx.setAttribute("y", (y + 2.6).toFixed(1));
tx.textContent = marks[i];
g.appendChild(tx);
}
}
}
/* ── wiring ─────────────────────────────────────────────────────────────── */
elPlay.addEventListener("click", toggle);
elTap.addEventListener("click", doTap);
$("slower").addEventListener("click", function () { setBpm(state.bpm - 1, "btn"); });
$("faster").addEventListener("click", function () { setBpm(state.bpm + 1, "btn"); });
elTempo.addEventListener("input", function () { setBpm(+elTempo.value, "slider"); });
elTempo.addEventListener("change", function () { say(state.bpm + " BPM, " + termFor(state.bpm)); });
elVol.addEventListener("input", function () {
state.vol = +elVol.value;
elVolV.textContent = state.vol;
if (ctx && master && state.mode !== "stopped") master.gain.setTargetAtTime(volGain(), ctx.currentTime, 0.01);
});
$("sigChips").addEventListener("click", function (ev) {
var b = ev.target.closest ? ev.target.closest("[data-sig]") : null;
if (!b) return;
var i = +b.getAttribute("data-sig");
if (i === state.sig) return;
state.sig = i;
var kids = this.children;
for (var k = 0; k < kids.length; k++) kids[k].setAttribute("aria-pressed", String(k === i));
sBeatInBar = 0; pInBar = 0;
buildDots();
captions();
say(sig().id + " time, " + sig().beats + " beats to the bar");
});
$("divChips").addEventListener("click", function (ev) {
var b = ev.target.closest ? ev.target.closest("[data-div]") : null;
if (!b) return;
var i = +b.getAttribute("data-div");
if (DIVS[i].n === state.div) return;
state.div = DIVS[i].n;
tickInBeat = 0;
var kids = this.children;
for (var k = 0; k < kids.length; k++) kids[k].setAttribute("aria-pressed", String(k === i));
captions();
say(DIVS[i].label + ", " + DIVS[i].n + " per beat");
});
document.addEventListener("keydown", function (ev) {
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
var tag = ev.target && ev.target.tagName;
var onControl = tag === "BUTTON" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
var k = ev.key;
if (ev.code === "Space" || k === " " || k === "Spacebar") {
if (onControl) return; /* let the focused control handle it */
ev.preventDefault();
if (!ev.repeat) toggle();
} else if (k === "t" || k === "T") {
if (tag === "INPUT" || tag === "TEXTAREA") return;
ev.preventDefault(); doTap();
} else if (k === "ArrowUp" || k === "ArrowRight") {
if (tag === "INPUT") return;
ev.preventDefault(); setBpm(state.bpm + (ev.shiftKey ? 5 : 1), "kbd");
} else if (k === "ArrowDown" || k === "ArrowLeft") {
if (tag === "INPUT") return;
ev.preventDefault(); setBpm(state.bpm - (ev.shiftKey ? 5 : 1), "kbd");
}
});
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
if (raf) { cancelAnimationFrame(raf); raf = null; }
} else {
pNext = 0;
if (state.mode !== "stopped" || Math.exp(-(nowSec() - stopAt) * 2.4) > 0.012) ensureLoop();
}
});
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
}
});
/* ── boot ───────────────────────────────────────────────────────────────── */
if (audioDead) {
$("audioNote").hidden = false;
elStageNote.textContent = "No Web Audio here — the arm still keeps time.";
}
buildScale();
buildChips();
buildDots();
elVolV.textContent = state.vol;
setBpm(state.bpm, "init");
captions();
clock.t = nowSec(); clock.beat = 0; clock.lvl = 2;
ensureLoop();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.