$sloprun.dev

Rhythm Trainer

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A metronome that grades you. The click marks the beat, dots mark where your taps belong, and every tap you land is measured against the grid and reported in milliseconds early or late — not "good job", an actual number. Bars are written onto the sound card's own clock a fraction of a second ahead and each note is stamped with the judging window it was born with, so the grid never moves under a repaint, and a note is never scored against a tempo that has already changed. The dots are also nudged by the browser's reported output latency, which means you are judged against the click you actually heard rather than the one the code fired.

The timing ribbon stacks your last 26 scored taps around a zero line so the shape of your error is visible at a glance: most people discover they are consistently 15 ms ahead rather than randomly scattered, and the dashed average line makes that bias impossible to miss. It opens on a finished 16-bar sample run — 88%, +14 ms late, four notes dropped — so you can read the report card before you play a note. Press Play, take the free count-in bar, then tap 16 scored bars with space, mouse, or touch. Try Offbeats at 96 BPM first: nothing sounds where your hands go, which is exactly where rushing shows up. Tempo and pattern changes land at the next bar, so you can make it harder mid-run without stopping.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/rhythm-trainer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Rhythm Trainer — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/rhythm-trainer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1271 lines 57.0 KB index.html
demos/rhythm-trainer/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Rhythm Trainer — 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 {
  color-scheme: light;
  --shadow: 0 1px 2px rgba(26,29,33,.05);
  --shadow-lift: 0 6px 18px rgba(26,29,33,.07);
  --well: #F1F0EC;
  --grid: #DDDCD5;
  --glow: rgba(192,86,0,.20);
  --btn-ink: #FFFFFF;
  --danger-soft: #FBEDEC;
  --lane-h: 116px;
  --rib-h: 132px;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --shadow: 0 1px 2px rgba(0,0,0,.35);
    --shadow-lift: 0 8px 22px rgba(0,0,0,.45);
    --well: #16181D; --grid: #30343D;
    --glow: rgba(255,180,84,.26); --btn-ink: #14161A;
    --danger-soft: #2B1A19;
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --shadow: 0 1px 2px rgba(0,0,0,.35);
  --shadow-lift: 0 8px 22px rgba(0,0,0,.45);
  --well: #16181D; --grid: #30343D;
  --glow: rgba(255,180,84,.26); --btn-ink: #14161A;
  --danger-soft: #2B1A19;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 900px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 13px;
}
@media (max-width: 460px) { .wrap { padding: 15px 9px 20px; gap: 10px; } }

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; }

.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 14px;
}
@media (max-width: 460px) { .card { padding: 10px 9px; } }

.cardhead {
  display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
  color: var(--muted); margin-bottom: 10px;
}
.cardhead .tag { color: var(--ink); font-weight: 600; }

/* ── transport ────────────────────────────────────────────────────────────── */
.transport {
  display: flex; flex-wrap: wrap; align-items: center; gap: 9px 13px;
}
.btn {
  font: inherit; font-size: 13.5px; font-weight: 600;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 8px 13px; cursor: pointer;
  display: inline-flex; align-items: center; gap: 7px;
  transition: background .12s ease, border-color .12s ease, color .12s ease, transform .06s ease;
  -webkit-tap-highlight-color: transparent;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible, .chip:focus-visible, .pad:focus-visible, input[type=range]:focus-visible {
  outline: 2px solid var(--ink); outline-offset: 2px;
}
.btn.primary {
  background: var(--accent); border-color: var(--accent); color: var(--btn-ink);
  min-width: 122px; justify-content: center; padding: 9px 15px;
}
.btn.primary:hover { filter: brightness(1.07); background: var(--accent); }
.btn.primary.on { background: var(--surface); color: var(--ink); border-color: var(--ink); }
.btn .gl { font-size: 11px; line-height: 1; }
.btn.small { font-size: 12px; padding: 6px 10px; font-weight: 500; color: var(--muted); }
.btn.small:hover { color: var(--ink); }
.btn.small.off { color: var(--muted); border-style: dashed; }

.status {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .08em;
  padding: 5px 9px; border-radius: 99px;
  background: var(--well); color: var(--muted); border: 1px solid var(--line);
  white-space: nowrap;
}
.status .led { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .45; flex: none; }
.status.live { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
.status.live .led { background: var(--accent); opacity: 1; animation: blink 1.05s steps(1) infinite; }
.status.good { background: var(--run-soft); color: var(--run); border-color: transparent; }
.status.good .led { background: var(--run); opacity: 1; }
@keyframes blink { 0%,55% { opacity: 1 } 56%,100% { opacity: .25 } }

.readout {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--muted);
  display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap;
}
.readout b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.readout .sep { opacity: .45; }
.readout b.good { color: var(--run); }
.spacer { flex: 1 1 auto; }

.controls {
  display: flex; flex-wrap: wrap; align-items: center; gap: 12px 18px;
  margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line);
}
.knob { flex: 1 1 210px; min-width: 165px; max-width: 320px; }
.knob .kl {
  display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
  color: var(--muted); margin-bottom: 3px;
}
.knob .kv { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }

input[type=range] {
  -webkit-appearance: none; appearance: none;
  width: 100%; background: transparent; margin: 0; height: 20px; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 5px; border-radius: 99px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 17px; height: 17px; margin-top: -6px;
  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-track { height: 5px; border-radius: 99px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.25);
}

.pat { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.pat .grouplabel {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
  color: var(--muted); margin-right: 2px;
}
.chip {
  font: inherit; font-size: 12.5px; color: var(--muted);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 99px; padding: 6px 12px; cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
  -webkit-tap-highlight-color: transparent;
}
.chip:hover { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
.chip[aria-pressed="true"] { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.pending {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .05em;
  color: var(--accent); background: var(--accent-soft);
  border-radius: 99px; padding: 4px 9px; white-space: nowrap;
}
.pending[hidden] { display: none; }

/* ── lane ─────────────────────────────────────────────────────────────────── */
.lanewrap { position: relative; }
.lane {
  position: relative; height: var(--lane-h);
  background: var(--well); border: 1px solid var(--line);
  border-radius: 8px; overflow: hidden;
}
.gl-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--grid); opacity: .8; }
.gl-line.beat { background: var(--line); width: 1px; opacity: 1; box-shadow: 0 0 0 .5px var(--grid); }
.midline { position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: var(--grid); }

.note {
  position: absolute; top: 50%; width: 15px; height: 15px; margin: -7.5px 0 0 -7.5px;
  border-radius: 50%; border: 2px solid var(--accent); background: var(--surface);
  transition: background .14s ease, border-color .14s ease, transform .14s ease, box-shadow .14s ease;
}
.note.dim { border-color: var(--muted); opacity: .45; }
.note.lit { box-shadow: 0 0 0 5px var(--glow); }
.note.g-tight { background: var(--run); border-color: var(--run); }
.note.g-close { background: var(--accent); border-color: var(--accent); }
.note.g-off   { background: var(--danger); border-color: var(--danger); }
.note.g-miss  { border-color: var(--danger); border-style: dashed; background: transparent; opacity: .85; }

.tapmark {
  position: absolute; top: 14%; bottom: 14%; width: 2px; margin-left: -1px;
  background: var(--muted); border-radius: 2px; opacity: .9;
}
.tapmark.g-tight { background: var(--run); }
.tapmark.g-close { background: var(--accent); }
.tapmark.g-off   { background: var(--danger); }
.tapmark.g-extra { background: var(--danger); opacity: .5; top: 30%; bottom: 30%; }

.head {
  position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px;
  background: var(--ink); opacity: .8;
}
.head::after {
  content: ""; position: absolute; top: 0; left: -3px; width: 8px; height: 5px;
  background: var(--ink); border-radius: 0 0 3px 3px;
}
.lane.idle .head { opacity: .30; }

.beatnums { position: relative; height: 16px; margin-top: 5px; }
.beatnums span {
  position: absolute; transform: translateX(-50%);
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .06em;
  color: var(--muted); transition: color .1s ease;
}
.beatnums span.on { color: var(--ink); font-weight: 700; }

/* ── tap pad ──────────────────────────────────────────────────────────────── */
.pad {
  display: block; width: 100%; margin-top: 11px;
  font: inherit; text-align: center; cursor: pointer;
  background: var(--surface); border: 1px solid var(--line);
  border-left: 3px solid var(--line);
  border-radius: 9px; padding: 12px 12px 13px;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  transition: background .16s ease, border-color .16s ease;
  overflow: hidden;
}
.pad:hover { border-color: var(--accent); }
.padtop {
  display: block; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .14em; color: var(--muted); margin-bottom: 3px;
}
.padbig {
  display: block; font-family: var(--font-mono); font-size: clamp(23px, 6.4vw, 34px);
  font-weight: 700; line-height: 1.05; color: var(--ink); font-variant-numeric: tabular-nums;
}
.padsub {
  display: block; font-size: 12.5px; color: var(--muted); margin-top: 4px; min-height: 1.2em;
}
.pad.g-tight { background: var(--run-soft); border-color: var(--run); border-left-color: var(--run); }
.pad.g-tight .padbig, .pad.g-tight .padtop { color: var(--run); }
.pad.g-close { background: var(--accent-soft); border-color: var(--accent); border-left-color: var(--accent); }
.pad.g-close .padbig, .pad.g-close .padtop { color: var(--accent); }
.pad.g-off, .pad.g-extra { background: var(--danger-soft); border-color: var(--danger); border-left-color: var(--danger); }
.pad.g-off .padbig, .pad.g-off .padtop, .pad.g-extra .padbig, .pad.g-extra .padtop { color: var(--danger); }
.pad.idle .padbig { font-size: clamp(16px, 4.2vw, 21px); font-weight: 600; letter-spacing: -.01em; }

/* ── ribbon ───────────────────────────────────────────────────────────────── */
.rib {
  position: relative; height: var(--rib-h);
  background: var(--well); border: 1px solid var(--line);
  border-radius: 8px; overflow: hidden;
}
.band { position: absolute; top: 0; bottom: 0; }
.band.ontime { left: 30%; right: 30%; background: var(--accent-soft); }
.band.tight  { left: 42%; right: 42%; background: var(--run-soft); }
.edgelab {
  position: absolute; top: 7px; font-family: var(--font-mono); font-size: 10px;
  letter-spacing: .12em; color: var(--muted); opacity: .8;
}
.edgelab.l { left: 9px; } .edgelab.r { right: 9px; }
.zero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--ink); opacity: .45; }
.gline { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--grid); }
.mk {
  position: absolute; width: 3px; height: 10px; margin-left: -1.5px;
  border-radius: 2px; background: var(--muted);
  transition: left .18s ease, bottom .18s ease, opacity .18s ease;
}
.mk.tight { background: var(--run); }
.mk.close { background: var(--accent); }
.mk.off   { background: var(--danger); }
.meanline {
  position: absolute; top: 0; bottom: 0; width: 1px; margin-left: -.5px;
  background: var(--ink); opacity: .55;
  border-left: 1px dashed var(--ink);
  transition: left .2s ease;
}
.meanline span {
  position: absolute; top: 3px; left: 50%; transform: translateX(-50%);
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em;
  background: var(--surface); border: 1px solid var(--line); border-radius: 99px;
  padding: 2px 6px; color: var(--ink); white-space: nowrap;
}
/* near an edge the label grows inwards, so it never runs off the ribbon */
.meanline.at-l span { left: 0; transform: none; }
.meanline.at-r span { left: auto; right: 0; transform: none; }
.meanline[hidden] { display: none; }
.ribaxis { position: relative; height: 15px; margin-top: 5px; }
.ribaxis span {
  position: absolute; transform: translateX(-50%);
  font-family: var(--font-mono); font-size: 10px; color: var(--muted);
}
.legend {
  display: flex; flex-wrap: wrap; gap: 5px 14px; margin-top: 9px;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .05em; color: var(--muted);
}
.legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; vertical-align: baseline; }
.legend i.t { background: var(--run); } .legend i.c { background: var(--accent); } .legend i.o { background: var(--danger); }

/* ── summary ──────────────────────────────────────────────────────────────── */
.sum { display: flex; flex-wrap: wrap; gap: 12px; align-items: stretch; }
/* Six tiles, so the column count is pinned to 6 / 3 / 2 rather than left to
   auto-fit — every layout lands on full rows instead of orphaning one tile. */
.hero {
  flex: 1 1 100%; min-width: 148px;
  background: var(--well); border: 1px solid var(--line); border-radius: 8px;
  padding: 11px 14px 12px; display: flex; flex-direction: column; justify-content: center;
}
@media (min-width: 620px) { .hero { flex: 0 1 auto; max-width: 190px; } }
.hero .hv {
  font-family: var(--font-mono); font-size: clamp(34px, 8.5vw, 46px); font-weight: 700;
  line-height: 1; letter-spacing: -.02em; font-variant-numeric: tabular-nums; color: var(--ink);
}
.hero .hv.good { color: var(--run); }
.hero .hl { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; color: var(--muted); margin-top: 7px; }
.hero .hn { font-size: 12.5px; color: var(--muted); margin-top: 4px; }

.tiles {
  flex: 1 1 300px; min-width: 0;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;
}
@media (min-width: 540px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 880px) { .tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
.tile { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px 9px; min-width: 0; }
.tile .tv {
  font-family: var(--font-mono); font-size: 17px; font-weight: 600;
  font-variant-numeric: tabular-nums; color: var(--ink); line-height: 1.15;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tile .tv.good { color: var(--run); }
.tile .tl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em; color: var(--muted); margin-top: 4px; }

.spark { margin-top: 12px; }
.sparkbars {
  display: grid; grid-template-columns: repeat(16, 1fr); gap: 3px;
  height: 46px; align-items: end;
  padding: 6px 7px; background: var(--well); border: 1px solid var(--line); border-radius: 8px;
}
.sb { border-radius: 2px 2px 0 0; background: var(--line); min-height: 2px; transition: height .2s ease, background .2s ease; }
.sb.good { background: var(--run); } .sb.mid { background: var(--accent); } .sb.bad { background: var(--danger); }
.sb.none { background: transparent; border: 1px dashed var(--line); height: 100% !important; border-radius: 3px; }
.sparklab {
  display: flex; justify-content: space-between; margin-top: 4px;
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .08em; color: var(--muted);
}
.verdict { margin: 11px 0 0; font-size: 13.5px; line-height: 1.55; color: var(--muted); }
.verdict b { color: var(--ink); font-weight: 600; }

.hint { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.65; }
.hint kbd {
  font-family: var(--font-mono); font-size: 11px;
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
  padding: 1px 5px; background: var(--surface); color: var(--ink);
}
.note-audio { font-family: var(--font-mono); font-size: 11.5px; color: var(--danger); margin: 9px 0 0; }
footer { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); letter-spacing: .04em; padding-top: 2px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }

@media (max-width: 460px) {
  :root { --lane-h: 96px; --rib-h: 112px; }
  .transport { gap: 8px 10px; }
  .chip { font-size: 12px; padding: 6px 10px; }
  .btn.primary { min-width: 108px; }
  .tile .tv { font-size: 15.5px; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Rhythm Trainer</h1>
    <p class="lede">The click marks the beat. Tap along with the dots &mdash; every tap is measured
      against the grid and reported in <b>milliseconds early or late</b>, then rolled into a
      16&#8209;bar report card.</p>
  </header>

  <section class="card" aria-label="Transport and settings">
    <div class="transport">
      <button id="play" class="btn primary" type="button"><span class="gl" aria-hidden="true">&#9654;</span><span id="playTxt">Play</span></button>
      <span id="stat" class="status"><i class="led" aria-hidden="true"></i><span id="statTxt">READY</span></span>
      <span class="readout">BAR&nbsp;<b id="barNo">&mdash;&mdash;</b><span class="sep">/</span><b>16</b></span>
      <span class="readout">ACC&nbsp;<b id="accNo">&mdash;&mdash;</b></span>
      <span class="readout">STREAK&nbsp;<b id="strNo">0</b></span>
      <span class="spacer"></span>
      <button id="mute" class="btn small" type="button">Sound on</button>
    </div>
    <div class="controls">
      <div class="knob">
        <label class="kl" for="tempo"><span>TEMPO</span><span class="kv"><span id="tempoV">96</span> BPM</span></label>
        <input id="tempo" type="range" min="50" max="180" step="1" value="96" aria-label="Tempo in beats per minute">
      </div>
      <div class="pat" role="group" aria-label="Pattern">
        <span class="grouplabel">PATTERN</span>
        <button class="chip" type="button" data-pat="quarters" aria-pressed="true">Quarters</button>
        <button class="chip" type="button" data-pat="eighths" aria-pressed="false">Eighths</button>
        <button class="chip" type="button" data-pat="offbeats" aria-pressed="false">Offbeats</button>
        <span id="pending" class="pending" hidden></span>
      </div>
    </div>
    <p id="audioNote" class="note-audio" hidden>Sound is unavailable in this browser &mdash; the visual metronome and scoring still work.</p>
  </section>

  <section class="card" aria-label="Bar view">
    <div class="cardhead"><span class="tag">THIS BAR</span><span id="laneNote">clicks on 1 2 3 4 &middot; tap every dot</span></div>
    <div class="lanewrap">
      <div class="lane idle" id="lane">
        <div class="midline"></div>
        <div id="glines"></div>
        <div id="notes"></div>
        <div id="taps"></div>
        <div class="head" id="head" style="left:3.5%"></div>
      </div>
      <div class="beatnums" id="beatnums"></div>
    </div>
    <button class="pad idle" id="pad" type="button" aria-label="Tap in time with the beat">
      <span class="padtop" id="padTop">TAP HERE</span>
      <span class="padbig" id="padBig">Tap here &mdash; or press Play &mdash; to start</span>
      <span class="padsub" id="padSub">space bar, mouse, or touch &mdash; whatever you have</span>
    </button>
  </section>

  <section class="card" aria-label="Timing ribbon">
    <div class="cardhead"><span class="tag">TIMING RIBBON</span><span>last 26 scored taps &middot; newest at the bottom</span></div>
    <div class="rib" id="rib">
      <div class="band ontime"></div>
      <div class="band tight"></div>
      <div class="gline" style="left:30%"></div>
      <div class="gline" style="right:30%"></div>
      <div class="zero"></div>
      <span class="edgelab l">EARLY</span><span class="edgelab r">LATE</span>
      <div id="marks"></div>
      <div class="meanline" id="meanline" hidden><span id="meanTxt">+0 ms</span></div>
    </div>
    <div class="ribaxis" id="ribaxis"></div>
    <div class="legend">
      <span><i class="t"></i>TIGHT &plusmn;20 MS</span>
      <span><i class="c"></i>ON TIME &plusmn;50 MS</span>
      <span><i class="o"></i>OFF THE GRID</span>
      <span>DASHED LINE = YOUR AVERAGE</span>
    </div>
  </section>

  <section class="card" aria-label="Session summary">
    <div class="cardhead"><span class="tag">16-BAR SESSION</span><span id="sumNote">sample run &middot; 96 BPM &middot; quarters</span></div>
    <div class="sum">
      <div class="hero">
        <div class="hv" id="sAcc">&mdash;</div>
        <div class="hl">ACCURACY</div>
        <div class="hn" id="sAccNote">taps inside &plusmn;50 ms</div>
      </div>
      <div class="tiles">
        <div class="tile"><div class="tv" id="sOn">&mdash;</div><div class="tl">ON TIME</div></div>
        <div class="tile"><div class="tv" id="sTight">&mdash;</div><div class="tl">TIGHT &plusmn;20 MS</div></div>
        <div class="tile"><div class="tv" id="sAvg">&mdash;</div><div class="tl">AVG OFFSET</div></div>
        <div class="tile"><div class="tv" id="sSd">&mdash;</div><div class="tl">SPREAD</div></div>
        <div class="tile"><div class="tv" id="sStreak">&mdash;</div><div class="tl">BEST STREAK</div></div>
        <div class="tile"><div class="tv" id="sMiss">&mdash;</div><div class="tl">MISSED / EXTRA</div></div>
      </div>
    </div>
    <div class="spark">
      <div class="sparkbars" id="spark"></div>
      <div class="sparklab"><span>BAR 1</span><span>BAR 8</span><span>BAR 16</span></div>
    </div>
    <p class="verdict" id="verdict"></p>
  </section>

  <p class="hint"><kbd>Space</kbd> tap &middot; <kbd>Enter</kbd> on Play to start &middot; <kbd>Esc</kbd> stop.
    A one-bar count-in runs first, then 16 bars are scored. Nothing leaves this page.</p>
  <footer>demo &middot; sloprun.dev</footer>
  <div class="sr" id="live" role="status" aria-live="polite"></div>
</div>

<script>
(function () {
  "use strict";

  var root = document.documentElement;
  function $(id) { return document.getElementById(id); }

  var reduced = false;
  try { reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}

  /* ── geometry: bar position 0..1 → percent across the lane ───────────────── */
  var PAD_L = 3.5, SPAN = 93;
  function X(p) { return PAD_L + p * SPAN; }
  function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
  function pad2(n) { return n < 10 ? "0" + n : "" + n; }

  /* ── the exercise ─────────────────────────────────────────────────────────
     The metronome always clicks the four quarter notes. The pattern says which
     sixteenth-grid slots you are supposed to tap. Offbeats is the hard one:
     nothing sounds where your hands go.                                       */
  var PATTERNS = {
    quarters: { slots: [0, 4, 8, 12],                 label: "quarters",
                note: "clicks on 1 2 3 4 · tap every click" },
    eighths:  { slots: [0, 2, 4, 6, 8, 10, 12, 14],   label: "eighths",
                note: "clicks on 1 2 3 4 · tap twice per click" },
    offbeats: { slots: [2, 6, 10, 14],                label: "offbeats",
                note: "clicks on 1 2 3 4 · tap only between the clicks" }
  };
  var BARS = 16, TIGHT = 20, ONTIME = 50, MARKS = 26, RANGE = 125;

  /* ── audio ────────────────────────────────────────────────────────────────
     Every sound here is synthesised on the spot — no files, no network.       */
  var ctx = null, master = null, bus = null, audioDead = false, lat = 0, muted = false;

  function ensureAudio() {
    if (ctx || audioDead) return;
    var AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) { audioDead = true; $("audioNote").hidden = false; return; }
    try {
      ctx = new AC();
      master = ctx.createGain();
      master.gain.value = muted ? 0 : 0.9;
      master.connect(ctx.destination);
      bus = ctx.createGain(); bus.connect(master);
    } catch (e) { ctx = null; audioDead = true; $("audioNote").hidden = false; }
  }
  var lastResume = -9;
  function resume() {
    /* throttled: if the browser is still holding audio back, do not retry on
       every single tap (each attempt logs a warning). */
    if (!ctx || ctx.state !== "suspended") return;
    var n = T();
    if (n - lastResume < 1) return;
    lastResume = n;
    try { ctx.resume(); } catch (e) {}
  }

  function click(tp, accent) {
    if (!ctx || !bus) return;
    try {
      /* tp is on the page clock; convert to the audio clock at schedule time.
         The two are read microseconds apart, so the offset is exact enough. */
      var t = ctx.currentTime + (tp - T());
      if (t < ctx.currentTime) t = ctx.currentTime;
      var o = ctx.createOscillator(), g = ctx.createGain();
      var f = accent ? 1568 : 1046;
      o.type = "sine";
      o.frequency.setValueAtTime(f, t);
      o.frequency.exponentialRampToValueAtTime(f * 0.7, t + 0.04);
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(accent ? 0.5 : 0.28, t + 0.002);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.055);
      o.connect(g); g.connect(bus);
      o.start(t); o.stop(t + 0.09);
    } catch (e) {}
  }
  function tapSound() {
    if (!ctx || !master) return;
    try {
      var t = ctx.currentTime;
      var o = ctx.createOscillator(), g = ctx.createGain(), f = ctx.createBiquadFilter();
      o.type = "triangle";
      o.frequency.setValueAtTime(600, t);
      o.frequency.exponentialRampToValueAtTime(280, t + 0.045);
      f.type = "lowpass"; f.frequency.value = 2400;
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(0.15, t + 0.002);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
      o.connect(f); f.connect(g); g.connect(master);
      o.start(t); o.stop(t + 0.1);
    } catch (e) {}
  }

  /* ── clock: audio clock when we have one, wall clock when we don't ───────── */
  var perf0 = (window.performance && performance.now) ? performance.now() : Date.now();
  function pnow() { return ((window.performance && performance.now) ? performance.now() : Date.now()) - perf0; }
  function T() { return pnow() / 1000; }

  /* ── session state ───────────────────────────────────────────────────────── */
  var state = "idle";        /* idle | countin | running | done */
  var playing = false;
  var bars = [], notes = [], extras = 0;
  var barIndex = 0, barStart = 0, schedTimer = null, endTime = 0, doneAt = -99;
  var cfg  = { bpm: 96, pat: "quarters" };
  var want = { bpm: 96, pat: "quarters" };
  var shownBar = -99, curBar = null, streak = 0, lastLit = -2, lastBeat = -1, lastCount = -1;
  var recent = [], gradeMap = {};
  var sample = null;

  function barDurOf(bpm) { return 4 * 60 / bpm; }
  function winFor(pat, bpm) {
    var slotDur = barDurOf(bpm) / 16;
    var s = PATTERNS[pat].slots;
    var gap = (s.length > 1 ? (s[1] - s[0]) : 4) * slotDur;
    return Math.min(0.2, gap * 0.48);
  }
  function gradeOf(ms) { var a = Math.abs(ms); return a <= TIGHT ? "tight" : (a <= ONTIME ? "close" : "off"); }
  function isOn(g) { return g === "tight" || g === "close"; }
  function fmtMs(ms) {
    var r = Math.round(ms);
    return (r > 0 ? "+" : (r < 0 ? "−" : "±")) + Math.abs(r) + " ms";
  }
  function mono(txt) {
    return '<b style="font-family:var(--font-mono);font-variant-numeric:tabular-nums">' + txt + "</b>";
  }

  /* ── scheduling ───────────────────────────────────────────────────────────
     Whole bars are queued a fraction of a second ahead, each event pinned to an
     absolute time on the audio clock, so repaints never nudge the beat.        */
  function scheduleBar(i, t0, dur, c) {
    var slotDur = dur / 16, b, k;
    for (b = 0; b < 4; b++) click(t0 + b * 4 * slotDur, b === 0);
    if (i >= 1) {
      /* Each note carries the window it was written with. Bars are queued up to
         0.45 s early, so by the time you tap the last note of a bar the live
         config may already belong to the next one — a note judged by someone
         else's tempo would be scored against a grid that is not on screen. */
      var slots = PATTERNS[c.pat].slots, w = winFor(c.pat, c.bpm);
      for (k = 0; k < slots.length; k++) {
        notes.push({ bar: i, slot: slots[k], time: t0 + slots[k] * slotDur + lat,
                     win: w, resolved: false, off: 0, grade: null });
      }
    }
  }
  function adoptPending() {
    if (cfg.bpm !== want.bpm || cfg.pat !== want.pat) {
      cfg = { bpm: want.bpm, pat: want.pat };
      updatePending();
    }
  }
  /* The engine owns everything the score depends on: queueing bars, advancing the
     bar view, expiring missed notes and ending the session. It runs on a timer,
     not on animation frames — a backgrounded tab throttles frames to nothing, and
     a session that silently froze there would be worse than one that keeps time. */
  function engine() {
    if (!playing) return;
    var now = T(), i, n;

    while (barIndex <= BARS && barStart < now + 0.45) {
      adoptPending();
      var dur = barDurOf(cfg.bpm);
      scheduleBar(barIndex, barStart, dur, cfg);
      bars.push({ i: barIndex, t0: barStart + lat, dur: dur, pat: cfg.pat, bpm: cfg.bpm });
      barStart += dur; barIndex++;
    }
    if (barIndex > BARS && !endTime) {
      var lb = bars[bars.length - 1];
      endTime = lb.t0 + lb.dur + winFor(lb.pat, lb.bpm) + 0.05;
    }

    var cb = null;
    for (i = bars.length - 1; i >= 0; i--) { if (bars[i].t0 <= now) { cb = bars[i]; break; } }
    if (cb) {
      curBar = cb;
      if (cb.i !== shownBar) {
        shownBar = cb.i; lastLit = -2; lastCount = -1;
        clearTaps(); renderLane(cb.i, cb.pat); setLaneNote(cb.pat);
        if (cb.i >= 1) state = "running";
        setStatus(); updateReadouts();
        if (cb.i >= 1) {
          paintSummary(computeStats(notes, extras), "recording · bar " + cb.i + " of " + BARS, false);
        }
      }
    }

    var changed = false;
    for (i = 0; i < notes.length; i++) {
      n = notes[i];
      if (!n.resolved && now > n.time + n.win) {
        n.resolved = true; n.grade = "miss";
        gradeMap[n.bar + ":" + n.slot] = "miss";
        streak = 0; changed = true; markNoteEl(n);
      }
    }
    if (changed) updateReadouts();
    if (endTime && now > endTime) finish();
  }

  /* ── transport ────────────────────────────────────────────────────────────── */
  function start() {
    ensureAudio(); resume();
    lat = 0;
    if (ctx) {
      lat = (typeof ctx.outputLatency === "number" && ctx.outputLatency > 0)
        ? ctx.outputLatency : (ctx.baseLatency || 0);
      if (lat > 0.35) lat = 0.35;
      try { if (bus) bus.disconnect(); } catch (e) {}
      bus = ctx.createGain(); bus.connect(master);
    }
    notes = []; bars = []; extras = 0; recent = []; gradeMap = {};
    streak = 0; shownBar = -99; curBar = null; lastLit = -2; lastBeat = -1; lastCount = -1;
    endTime = 0; doneAt = -99;
    cfg = { bpm: want.bpm, pat: want.pat };
    barIndex = 0; barStart = T() + 0.4;
    playing = true; state = "countin";

    $("lane").classList.remove("idle");
    clearTaps(); renderLane(0, cfg.pat); setLaneNote(cfg.pat);
    setHead(0); setBeat(0);
    renderRibbon(recent); updateReadouts(); setPlayBtn();
    paintSummary(computeStats(notes, 0), "count-in · nothing scored yet", false);
    updatePending(); setStatus();
    setPad("", "COUNT-IN", "–", "one free bar, then 16 scored bars");
    say("Count-in. Tap along with the click.");
    schedTimer = setInterval(engine, 25);
    engine();
    try { $("pad").focus({ preventScroll: true }); } catch (e) { try { $("pad").focus(); } catch (e2) {} }
  }

  function silence() {
    if (ctx && bus) {
      try {
        var n = ctx.currentTime;
        bus.gain.cancelScheduledValues(n);
        bus.gain.setValueAtTime(0, n);
      } catch (e) {}
    }
  }

  function finish() {
    if (!playing) return;
    playing = false;
    if (schedTimer) { clearInterval(schedTimer); schedTimer = null; }
    silence();
    var now = T(), keep = [], i, n;
    for (i = 0; i < notes.length; i++) {
      n = notes[i];
      if (!n.resolved) {
        if (n.time <= now) { n.resolved = true; n.grade = "miss"; gradeMap[n.bar + ":" + n.slot] = "miss"; }
        else continue;                     /* never happened — do not score it */
      }
      keep.push(n);
    }
    notes = keep;
    state = "done"; doneAt = now;
    var st = computeStats(notes, extras);
    var barsPlayed = shownBar >= 1 ? shownBar : 0;
    var label = (barsPlayed >= BARS ? "complete · "
                 : (barsPlayed ? "stopped at bar " + barsPlayed + " · " : "stopped in the count-in · "))
      + cfg.bpm + " BPM · " + PATTERNS[cfg.pat].label;
    $("lane").classList.add("idle");
    if (st.n) paintSummary(st, label, true); else showSample();
    setPlayBtn(); setStatus();
    if (st.n) updateReadouts();   /* nothing scored → keep the sample readouts */
    if (st.n) {
      setPad(st.acc >= 0.9 ? "g-tight" : "", "SESSION DONE",
             Math.round(st.acc * 100) + "%",
             "of " + st.n + " notes landed inside ±50 ms · tap for another 16 bars");
      say("Session finished. " + Math.round(st.acc * 100) + " percent on time, average offset "
        + Math.round(st.mean) + " milliseconds.");
    } else {
      setPad("", "SESSION DONE", "NO SCORE", "it stopped before a scored bar · tap for another 16 bars");
      say("Session finished.");
    }
  }

  /* ── tapping and scoring ─────────────────────────────────────────────────── */
  function tap() {
    ensureAudio(); resume();
    if (!playing) {
      if (state === "done" && T() - doneAt < 0.9) return;   /* stray tap on the last note */
      start(); return;
    }
    var t = T();
    tapSound();
    var best = null, bd = 1e9, i, n, d;
    for (i = 0; i < notes.length; i++) {
      n = notes[i];
      if (n.resolved) continue;
      d = Math.abs(t - n.time);
      if (d < bd) { bd = d; best = n; }
    }
    if (best && bd <= best.win) {
      var off = (t - best.time) * 1000;
      best.resolved = true; best.off = off; best.grade = gradeOf(off);
      gradeMap[best.bar + ":" + best.slot] = best.grade;
      recent.unshift({ off: off, grade: best.grade });
      if (recent.length > MARKS) recent.pop();
      streak = isOn(best.grade) ? streak + 1 : 0;
      addTapMark(t, best.grade); markNoteEl(best);
      renderRibbon(recent); updateReadouts();
      setPad("g-" + best.grade,
             best.grade === "tight" ? "TIGHT" : (best.grade === "close" ? "ON TIME" : "OFF THE GRID"),
             fmtMs(off),
             Math.round(off) === 0 ? "dead on the grid line"
               : (off < 0 ? "ahead of the beat" : "behind the beat"));
    } else if (state === "countin") {
      setPad("", "COUNT-IN", "–", "warming up — scoring starts at bar 1");
    } else {
      extras++; streak = 0;
      addTapMark(t, "extra"); updateReadouts();
      setPad("g-extra", "NO NOTE THERE", "–", "that tap was not near any dot");
    }
  }

  /* ── frame loop: purely what you can see ─────────────────────────────────── */
  function frame() {
    requestAnimationFrame(frame);
    var p;
    if (playing) {
      if (!curBar) return;
      p = clamp((T() - curBar.t0) / curBar.dur, 0, 1);
      setHead(p); setBeat(p); lightNotes(p, curBar.pat);
      if (state === "countin") countIn(p);
    } else if (state === "idle" && !reduced) {
      var dur = barDurOf(want.bpm);
      p = (pnow() / 1000 % dur) / dur;
      setHead(p); setBeat(p); lightNotes(p, want.pat);
    }
  }

  /* ── lane rendering ──────────────────────────────────────────────────────── */
  function buildGrid() {
    var html = "", s;
    for (s = 0; s < 16; s++) {
      html += '<div class="gl-line' + (s % 4 === 0 ? " beat" : "") + '" style="left:' + X(s / 16) + '%"></div>';
    }
    $("glines").innerHTML = html;
    var b, bn = "";
    for (b = 0; b < 4; b++) bn += '<span data-b="' + b + '" style="left:' + X(b / 4) + '%">' + (b + 1) + "</span>";
    $("beatnums").innerHTML = bn;
  }
  function renderLane(barIdx, pat) {
    var slots = PATTERNS[pat].slots, html = "", k, s, g;
    for (k = 0; k < slots.length; k++) {
      s = slots[k];
      g = barIdx >= 1 ? gradeMap[barIdx + ":" + s] : null;
      html += '<div class="note' + (barIdx === 0 ? " dim" : "") + (g ? " g-" + g : "")
        + '" data-slot="' + s + '" style="left:' + X(s / 16) + '%"></div>';
    }
    $("notes").innerHTML = html;
  }
  function markNoteEl(n) {
    if (n.bar !== shownBar) return;
    var el = $("notes").querySelector('[data-slot="' + n.slot + '"]');
    if (el) el.className = "note g-" + n.grade;
  }
  function clearTaps() { $("taps").innerHTML = ""; }
  function addTapMark(t, grade) {
    if (!curBar) return;
    var p = clamp((t - curBar.t0) / curBar.dur, 0, 1);
    var d = document.createElement("div");
    d.className = "tapmark g-" + grade;
    d.style.left = X(p) + "%";
    $("taps").appendChild(d);
  }
  function setHead(p) { $("head").style.left = X(reduced ? Math.floor(p * 16) / 16 : p) + "%"; }
  function setBeat(p) {
    var b = Math.min(3, Math.floor(p * 4));
    if (b === lastBeat) return;
    lastBeat = b;
    var els = $("beatnums").children, i;
    for (i = 0; i < els.length; i++) els[i].className = (i === b ? "on" : "");
  }
  function lightNotes(p, pat) {
    var slots = PATTERNS[pat].slots, cur = -1, k;
    for (k = 0; k < slots.length; k++) if (p >= slots[k] / 16) cur = slots[k];
    if (cur === lastLit) return;
    lastLit = cur;
    var els = $("notes").children, i;
    for (i = 0; i < els.length; i++) els[i].classList.remove("lit");
    if (cur >= 0) {
      var e = $("notes").querySelector('[data-slot="' + cur + '"]');
      if (e) e.classList.add("lit");
    }
  }
  function setLaneNote(pat) { $("laneNote").textContent = PATTERNS[pat].note; }
  function countIn(p) {
    var c = Math.min(4, 1 + Math.floor(p * 4));
    if (c === lastCount) return;
    lastCount = c;
    setPad("", "COUNT-IN", String(c), "one free bar, then 16 scored bars");
  }

  /* ── the timing ribbon ───────────────────────────────────────────────────── */
  var markEls = [], ribH = 132, ribList = [];
  function measureRib() {
    var h = $("rib").clientHeight;
    if (h > 40) ribH = h;
  }
  /* marks stack upward from the floor; the gap between them is derived from the
     ribbon height so all of them fit, and the top strip stays free for the
     average label. */
  function markStep() {
    var s = (ribH - 44) / (MARKS - 1);
    return clamp(s, 2.4, 4.4);
  }
  function buildRibbon() {
    var frag = document.createDocumentFragment(), i, d;
    for (i = 0; i < MARKS; i++) {
      d = document.createElement("div");
      d.className = "mk"; d.style.opacity = "0"; d.style.left = "50%"; d.style.bottom = "7px";
      frag.appendChild(d); markEls.push(d);
    }
    $("marks").appendChild(frag);
    var vals = [-100, -50, 0, 50, 100], html = "";
    for (i = 0; i < vals.length; i++) {
      html += '<span style="left:' + (50 + vals[i] / (RANGE * 2) * 100) + '%">'
        + (vals[i] > 0 ? "+" : (vals[i] < 0 ? "−" : "")) + Math.abs(vals[i])
        + (vals[i] === 0 ? " ms" : "") + "</span>";
    }
    $("ribaxis").innerHTML = html;
  }
  function renderRibbon(list) {
    ribList = list;
    var i, d, sum = 0, step = markStep();
    for (i = 0; i < MARKS; i++) {
      d = list[i];
      if (!d) { markEls[i].style.opacity = "0"; continue; }
      markEls[i].className = "mk " + d.grade;
      markEls[i].style.left = (50 + clamp(d.off, -RANGE, RANGE) / (RANGE * 2) * 100) + "%";
      markEls[i].style.bottom = (7 + i * step) + "px";
      markEls[i].style.opacity = String(Math.max(0.14, 1 - i * 0.033));
      sum += d.off;
    }
    var ml = $("meanline");
    if (list.length >= 4) {
      var m = sum / list.length;
      var pos = 50 + clamp(m, -RANGE, RANGE) / (RANGE * 2) * 100;
      ml.hidden = false;
      ml.style.left = pos + "%";
      ml.className = "meanline" + (pos < 22 ? " at-l" : (pos > 78 ? " at-r" : ""));
      $("meanTxt").textContent = fmtMs(m) + (m < -3 ? " early" : (m > 3 ? " late" : ""));
    } else ml.hidden = true;
  }

  /* ── statistics ──────────────────────────────────────────────────────────── */
  function computeStats(list, extraCount) {
    var st = { n: 0, on: 0, tight: 0, missed: 0, acc: 0, mean: 0, sd: 0, best: 0,
               extras: extraCount || 0, perBar: [] }, i, b;
    for (b = 0; b < BARS; b++) st.perBar.push({ n: 0, on: 0 });
    var offs = [], run = 0;
    for (i = 0; i < list.length; i++) {
      var n = list[i];
      if (!n.resolved) continue;
      st.n++;
      var pb = st.perBar[n.bar - 1];
      if (pb) pb.n++;
      if (n.grade === "miss") { st.missed++; run = 0; }
      else {
        offs.push(n.off);
        if (n.grade === "tight") st.tight++;
        if (isOn(n.grade)) {
          st.on++; if (pb) pb.on++;
          run++; if (run > st.best) st.best = run;
        } else run = 0;
      }
    }
    st.acc = st.n ? st.on / st.n : 0;
    if (offs.length) {
      var s = 0;
      for (i = 0; i < offs.length; i++) s += offs[i];
      st.mean = s / offs.length;
      var v = 0;
      for (i = 0; i < offs.length; i++) v += (offs[i] - st.mean) * (offs[i] - st.mean);
      st.sd = Math.sqrt(v / offs.length);
    }
    return st;
  }

  function paintSummary(st, note, final) {
    $("sumNote").textContent = note;
    var accEl = $("sAcc");
    accEl.textContent = st.n ? Math.round(st.acc * 100) + "%" : "–";
    accEl.className = "hv" + (st.n && st.acc >= 0.9 ? " good" : "");
    $("sAccNote").textContent = st.n
      ? st.on + " of " + st.n + " notes inside ±50 ms"
      : "taps inside ±50 ms of the grid";
    $("sOn").textContent = st.n ? st.on + "/" + st.n : "–";
    $("sOn").className = "tv" + (st.n && st.acc >= 0.9 ? " good" : "");
    $("sTight").textContent = st.n ? String(st.tight) : "–";
    $("sAvg").textContent = st.n - st.missed > 0 ? fmtMs(st.mean) : "–";
    $("sAvg").parentNode.querySelector(".tl").textContent =
      "AVG OFFSET" + (st.n - st.missed > 0 ? (st.mean < -3 ? " · EARLY" : (st.mean > 3 ? " · LATE" : " · CENTRED")) : "");
    $("sSd").textContent = st.n - st.missed > 0 ? "±" + Math.round(st.sd) + " ms" : "–";
    $("sStreak").textContent = st.n ? String(st.best) : "–";
    $("sMiss").textContent = st.n ? st.missed + " / " + st.extras : "–";

    var html = "", i, pb, ratio, cls;
    for (i = 0; i < BARS; i++) {
      pb = st.perBar[i];
      if (!pb.n) { html += '<div class="sb none" title="bar ' + (i + 1) + ': not played"></div>'; continue; }
      ratio = pb.on / pb.n;
      cls = ratio >= 0.9 ? "good" : (ratio >= 0.5 ? "mid" : "bad");
      html += '<div class="sb ' + cls + '" style="height:' + Math.max(8, ratio * 100) + '%" title="bar '
        + (i + 1) + ": " + pb.on + "/" + pb.n + ' on time"></div>';
    }
    $("spark").innerHTML = html;
    $("verdict").innerHTML = verdict(st, final);
  }

  function verdict(st, final) {
    if (!st.n) {
      return final ? "Nothing was scored — the count-in is free, the 16 bars after it are not."
                   : "The report card fills in bar by bar, starting the moment bar 1 begins.";
    }
    var pct = Math.round(st.acc * 100), m = Math.round(st.mean), sd = Math.round(st.sd);
    if (!final) {
      return "Recording. " + mono(pct + "%") + " on time so far, longest clean run "
        + mono(st.best) + " notes.";
    }
    var s1;
    if (st.acc >= 0.95) s1 = "Locked in — " + mono(pct + "%") + " of your taps landed inside " + mono("50 ms") + " of the grid.";
    else if (st.acc >= 0.9) s1 = "Solid: " + mono(pct + "%") + " on time, with " + mono(st.tight) + " taps inside the tight " + mono("20 ms") + " band.";
    else if (st.acc >= 0.7) s1 = "Getting there — " + mono(pct + "%") + " on time. The " + mono("90%") + " line is what turns this panel green.";
    else s1 = "Rough run — " + mono(pct + "%") + " on time. Drop the tempo and let the click lead you.";
    var s2;
    if (st.n - st.missed === 0) s2 = "No taps landed near a dot, so there is no offset to report yet.";
    else if (m < -12) s2 = "You sit ahead of the click by " + mono(Math.abs(m) + " ms") + " on average — the classic rush. Let the click arrive before you move.";
    else if (m > 12) s2 = "You land behind the click by " + mono(m + " ms") + " on average. Aiming a hair early usually fixes it.";
    else s2 = "Your average sits " + mono(Math.abs(m) + " ms") + " from the grid line, which is genuinely centred.";
    var s3 = (st.n - st.missed) < 4 ? ""
      : (sd <= 18 ? " Spread is " + mono("±" + sd + " ms") + ", so the timing is repeatable, not lucky."
                  : " Spread is " + mono("±" + sd + " ms") + " — tightening that is the next win.");
    var s4 = st.missed ? " " + mono(st.missed) + (st.missed === 1 ? " note" : " notes") + " went by untapped." : "";
    return s1 + " " + s2 + s3 + s4;
  }

  /* ── readouts and chrome ─────────────────────────────────────────────────── */
  function rollingAcc() {
    var r = [], i;
    for (i = notes.length - 1; i >= 0 && r.length < 16; i--) if (notes[i].resolved) r.push(notes[i]);
    if (!r.length) return null;
    var ok = 0;
    for (i = 0; i < r.length; i++) if (isOn(r[i].grade)) ok++;
    return ok / r.length;
  }
  function updateReadouts() {
    $("barNo").textContent = shownBar >= 1 ? pad2(Math.min(shownBar, BARS)) : "——";
    /* while you play these track the last 16 notes, so they react as you go; once
       the session is over they switch to the whole run and agree with the card. */
    var a, s;
    if (state === "done") {
      var st = computeStats(notes, extras);
      a = st.n ? st.acc : null; s = st.n ? st.best : 0;
    } else { a = rollingAcc(); s = streak; }
    var el = $("accNo");
    if (a === null) { el.textContent = "——"; el.className = ""; }
    else { el.textContent = Math.round(a * 100) + "%"; el.className = a >= 0.9 ? "good" : ""; }
    $("strNo").textContent = String(s);
  }
  function setStatus() {
    var el = $("stat"), t = $("statTxt");
    el.className = "status";
    if (state === "countin") { el.className = "status live"; t.textContent = "COUNT-IN"; }
    else if (state === "running") { el.className = "status live"; t.textContent = "BAR " + pad2(Math.min(shownBar, BARS)) + "/16"; }
    else if (state === "done") {
      var st = computeStats(notes, extras);
      if (st.n && st.acc >= 0.9) { el.className = "status good"; t.textContent = "DONE · " + Math.round(st.acc * 100) + "%"; }
      else t.textContent = "DONE";
    } else t.textContent = "READY";
  }
  function setPlayBtn() {
    var b = $("play");
    var gl = b.querySelector(".gl");
    if (playing) {
      b.classList.add("on");
      $("playTxt").textContent = "Stop";
      if (gl) gl.innerHTML = "&#9632;";
    } else {
      b.classList.remove("on");
      $("playTxt").textContent = state === "done" ? "Run it again" : "Play";
      if (gl) gl.innerHTML = "&#9654;";
    }
  }
  function setPad(cls, top, big, sub) {
    var p = $("pad");
    p.className = "pad " + (cls || "");
    $("padTop").textContent = top;
    $("padBig").textContent = big;
    $("padSub").textContent = sub;
  }
  function updatePending() {
    var el = $("pending");
    if (playing && (cfg.bpm !== want.bpm || cfg.pat !== want.pat)) {
      var parts = [];
      if (cfg.bpm !== want.bpm) parts.push(want.bpm + " BPM");
      if (cfg.pat !== want.pat) parts.push(PATTERNS[want.pat].label);
      el.textContent = "→ " + parts.join(" · ") + " at the next bar";
      el.hidden = false;
    } else el.hidden = true;
  }
  var liveEl, sayTimer = null;
  function say(msg) {
    clearTimeout(sayTimer);
    liveEl.textContent = "";
    sayTimer = setTimeout(function () { liveEl.textContent = msg; }, 110);
  }

  /* ── the sample run shown on load ────────────────────────────────────────── */
  function makeSample() {
    var seed = 20260813;
    function rnd() { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }
    function gauss() { return (rnd() + rnd() + rnd() + rnd() + rnd() + rnd() - 3) / 1.0; }
    var list = [], missAt = { 19: 1, 34: 1, 51: 1, 62: 1 }, b, k, idx = 0;
    var slots = PATTERNS.quarters.slots;
    for (b = 1; b <= BARS; b++) {
      for (k = 0; k < slots.length; k++) {
        var n = { bar: b, slot: slots[k], time: 0, resolved: true, off: 0, grade: null };
        if (missAt[idx]) { n.grade = "miss"; }
        else {
          var drift = 16 + (b - 8) * 0.9;            /* creeps later as the run goes on */
          n.off = drift + gauss() * 30;
          n.grade = gradeOf(n.off);
        }
        list.push(n); idx++;
      }
    }
    return list;
  }
  function showSample() {
    sample = makeSample();
    var st = computeStats(sample, 2);
    paintSummary(st, "sample run · 96 BPM · quarters", true);
    var r = [], i;
    for (i = sample.length - 1; i >= 0 && r.length < MARKS; i--) {
      if (sample[i].grade !== "miss") r.push({ off: sample[i].off, grade: sample[i].grade });
    }
    renderRibbon(r);
    $("barNo").textContent = "——";
    $("accNo").textContent = Math.round(st.acc * 100) + "%";
    $("accNo").className = st.acc >= 0.9 ? "good" : "";
    $("strNo").textContent = "——";
    $("sumNote").textContent = "sample run · 96 BPM · quarters · press Play for your own";
  }

  /* ── controls ────────────────────────────────────────────────────────────── */
  function wire() {
    $("play").addEventListener("click", function () {
      ensureAudio(); resume();
      if (playing) { finish(); say("Stopped."); } else start();
    });

    $("tempo").addEventListener("input", function () {
      want.bpm = +this.value;
      $("tempoV").textContent = want.bpm;
      if (!playing) { cfg.bpm = want.bpm; }
      updatePending();
    });

    var chips = document.querySelectorAll(".chip[data-pat]");
    Array.prototype.forEach.call(chips, function (c) {
      c.addEventListener("click", function () {
        want.pat = c.getAttribute("data-pat");
        Array.prototype.forEach.call(chips, function (o) {
          o.setAttribute("aria-pressed", o === c ? "true" : "false");
        });
        if (!playing) {
          cfg.pat = want.pat; lastLit = -2;
          renderLane(-1, want.pat); setLaneNote(want.pat);
        }
        updatePending();
        say(PATTERNS[want.pat].label + ": " + PATTERNS[want.pat].note);
      });
    });

    /* The label carries the state ("Sound on" / "Sound off"), so no aria-pressed:
       a toggle announced as "Sound off, pressed" reads as its own opposite. */
    $("mute").addEventListener("click", function () {
      muted = !muted;
      this.className = "btn small" + (muted ? " off" : "");
      this.textContent = muted ? "Sound off" : "Sound on";
      ensureAudio();
      if (master) { try { master.gain.value = muted ? 0 : 0.9; } catch (e) {} }
      say(muted ? "Sound off. The visual metronome and scoring keep running."
                : "Sound on.");
    });

    var pad = $("pad");
    function padDown(e) {
      if (e.cancelable) e.preventDefault();
      try { pad.focus({ preventScroll: true }); } catch (e2) { try { pad.focus(); } catch (e3) {} }
      tap();
    }
    if (window.PointerEvent) pad.addEventListener("pointerdown", padDown);
    else {
      pad.addEventListener("touchstart", padDown, { passive: false });
      pad.addEventListener("mousedown", padDown);
    }
    pad.addEventListener("click", function (e) { if (e.detail === 0) tap(); });   /* Enter key */

    document.addEventListener("keydown", function (e) {
      if (e.key === "Escape") { if (playing) { finish(); say("Stopped."); } return; }
      if (e.code === "Space" || e.key === " " || e.key === "Spacebar") {
        var a = document.activeElement;
        if (a && a.tagName === "BUTTON" && a !== pad) return;      /* let the button take it */
        if (a && (a.tagName === "INPUT" || a.tagName === "SELECT")) return;
        e.preventDefault();
        if (!e.repeat) tap();
      }
    });

    var rzTimer = null;
    window.addEventListener("resize", function () {
      clearTimeout(rzTimer);
      rzTimer = setTimeout(function () { measureRib(); renderRibbon(ribList); }, 120);
    });

    /* A hidden tab cannot be tapped, and every note in it would score as missed.
       Ending the run there is the honest outcome. */
    document.addEventListener("visibilitychange", function () {
      if (document.hidden && playing) {
        finish();
        /* finish() writes a generic sub-line; say why it really stopped, on
           screen and not only in the live region. */
        $("padSub").textContent = "the page went to the background, so the run stopped there";
        say("Stopped — the page went to the background.");
      }
    });

    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 ────────────────────────────────────────────────────────────────── */
  liveEl = $("live");
  /* Say up front that there will be no sound, rather than after the first tap:
     ensureAudio() only runs on a gesture, so the check has to be cheap and
     context-free (creating one here would just leave a suspended context). */
  if (!(window.AudioContext || window.webkitAudioContext)) {
    audioDead = true; $("audioNote").hidden = false;
  }
  buildGrid(); measureRib(); buildRibbon();
  renderLane(-1, want.pat); setLaneNote(want.pat);
  showSample();
  wire();
  setStatus(); setPlayBtn();
  if (reduced) { setHead(0); setBeat(0); }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong