$sloprun.dev

Step Sequencer

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

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

▶ 8 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

Sixteen steps, four drums, and not one audio file behind them: the kick is a sine falling from 142 Hz to 43, the snare is filtered noise over two detuned triangles, and the clap is three noise bursts fired 12 ms apart with a longer fourth trailing after. Every hit is scheduled ahead against the Web Audio clock, so the groove stays locked even while the browser is busy repainting the playhead. It opens on a loaded backbeat — press Play, then tap a pad to cycle it off → hit → accent (accents hit the kick harder and open the hi-hat), or drag along a row to paint a run of 16ths. Push Swing toward 40% to hear straight time turn into a shuffle, and use Randomize for a weighted roll that always keeps the downbeat and the backbeat.

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/drum-machine" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Step Sequencer — 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/drum-machine

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1005 lines 39.9 KB index.html
demos/drum-machine/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>Step Sequencer — sloprun.dev</title>
<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 {
  --shadow: 0 1px 2px rgba(26,29,33,.05);
  --shadow-lift: 0 6px 18px rgba(26,29,33,.07);
  --well: #F1F0EC;        /* the recessed panel the pads sit in */
  --pad: #FBFAF7;         /* an empty pad */
  --pad-edge: #E3E2DC;
  --glow: rgba(192,86,0,.22);
  --btn-ink: #FFFFFF;
  --labw: clamp(60px, 14vw, 98px);
  --cellh: clamp(30px, 6.4vw, 46px);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --shadow: 0 1px 2px rgba(0,0,0,.35);
    --shadow-lift: 0 8px 22px rgba(0,0,0,.45);
    --well: #16181D; --pad: #22262E; --pad-edge: #30343D;
    --glow: rgba(255,180,84,.26); --btn-ink: #14161A;
  }
}
:root[data-theme="dark"] {
  --shadow: 0 1px 2px rgba(0,0,0,.35);
  --shadow-lift: 0 8px 22px rgba(0,0,0,.45);
  --well: #16181D; --pad: #22262E; --pad-edge: #30343D;
  --glow: rgba(255,180,84,.26); --btn-ink: #14161A;
}

* { 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: 940px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 14px;
}
@media (max-width: 460px) { .wrap { padding: 16px 6px 20px; gap: 11px; } }

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 66ch; 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: 9px 8px; } }

/* ── transport ────────────────────────────────────────────────────────────── */
.transport {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
  padding-bottom: 12px; margin-bottom: 12px;
  border-bottom: 1px dashed var(--line);
}
.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, 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, .pad:focus-visible, .trk: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: 108px; justify-content: center; padding: 9px 15px;
}
.btn.primary:hover { filter: brightness(1.07); }
.btn.primary.on { background: var(--surface); color: var(--ink); border-color: var(--ink); }
.btn .gl { font-size: 11px; line-height: 1; }

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

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

.knobs { display: flex; gap: 18px; flex-wrap: wrap; flex: 1 1 260px; min-width: 0; margin-left: auto; }
.knob { flex: 1 1 150px; min-width: 118px; max-width: 230px; }
.knob .kl {
  display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .07em;
  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);
}

/* ── sequencer grid ───────────────────────────────────────────────────────── */
.well {
  background: var(--well); border: 1px solid var(--line);
  border-radius: 8px; padding: 10px 10px 11px;
}
@media (max-width: 460px) {
  .well { padding: 8px 5px 9px; }
  .seq { column-gap: 1px; }
  /* 16 columns in a phone's width leaves each pad barely 14px across, so buy
     back what we can on the label column and make the pads taller to hit */
  :root { --labw: 44px; --cellh: 40px; }
  .transport { gap: 9px 10px; }
}
.seq {
  display: grid;
  grid-template-columns: var(--labw) repeat(16, minmax(0, 1fr));
  column-gap: 2px; row-gap: 4px;
}
.gap4 { margin-left: 5px; }
@media (max-width: 460px) { .gap4 { margin-left: 2px; } }

.tick {
  font-family: var(--font-mono); font-size: 10px; color: var(--muted);
  text-align: center; line-height: 1; padding-bottom: 5px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  opacity: .92; min-width: 0; overflow: hidden;
}
.tick .bar { width: 100%; height: 3px; border-radius: 2px; background: transparent; }
.tick.beat { opacity: 1; }
.tick.now { color: var(--ink); opacity: 1; font-weight: 700; }
.tick.now .bar { background: var(--accent); }

.trk {
  font: inherit; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .06em;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 7px;
  padding: 4px 6px; cursor: pointer; text-align: left;
  display: flex; align-items: center; gap: 6px; min-width: 0;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
  -webkit-tap-highlight-color: transparent;
}
.trk .led2 {
  width: 6px; height: 6px; border-radius: 50%; flex: none;
  background: var(--muted); opacity: .35; transition: none;
}
.trk .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trk .nm.short { display: none; }
.trk.hit .led2 { background: var(--accent); opacity: 1; }
.trk.hit { border-color: var(--accent); }
.trk[aria-pressed="true"] { color: var(--muted); background: var(--well); border-style: dashed; }
.trk[aria-pressed="true"] .nm { text-decoration: line-through; text-decoration-thickness: 1px; }
.trk[aria-pressed="true"] .led2 { background: var(--muted); opacity: .3; }
.trk[aria-pressed="true"].hit { border-color: var(--line); }
.trk[aria-pressed="true"].hit .led2 { background: var(--muted); opacity: .35; }

.pad {
  appearance: none; -webkit-appearance: none;
  position: relative; padding: 0; min-width: 0;
  height: var(--cellh); border-radius: 6px; cursor: pointer;
  background: var(--pad); border: 1px solid var(--pad-edge);
  overflow: hidden; touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
  /* no transition on the pad shell: at 170bpm a fading border leaves two
     columns looking lit at once, which blurs where the playhead actually is */
}
.pad:hover { border-color: var(--accent); }
.pad .fill {
  position: absolute; inset: 2px; border-radius: 4px;
  background: var(--accent); opacity: 0;
  transform: scaleY(.35); transform-origin: 50% 100%;
  transition: opacity .11s ease, transform .11s ease;
}
.pad[data-v="1"] .fill { opacity: .5; transform: scaleY(.66); }
.pad[data-v="2"] .fill { opacity: 1; transform: scaleY(1); }
.pad.col { background: var(--accent-soft); border-color: var(--accent); }
/* keyed off the value too: clearing or reloading a pattern under the playhead
   used to leave the lit column glowing until the playhead stepped off it */
.pad[data-v="1"].trig .fill,
.pad[data-v="2"].trig .fill { transform: scaleY(1) scale(1.02); opacity: 1; box-shadow: 0 0 0 2px var(--glow); }
.pad.mutepad[data-v="1"] .fill { background: var(--muted); opacity: .26; }
.pad.mutepad[data-v="2"] .fill { background: var(--muted); opacity: .38; }
.pad.mutepad.trig .fill { box-shadow: none; }
.pad.mutepad { background: var(--well); }

/* ── pattern bar ──────────────────────────────────────────────────────────── */
.bar2 {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line);
}
.presets, .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.actions { margin-left: auto; }
@media (max-width: 620px) {
  .actions { margin-left: 0; width: 100%; }
  .actions .btn { flex: 1 1 auto; justify-content: center; }
  .presets .chip { flex: 1 1 auto; text-align: center; }
}
.bar2 .grouplabel {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .07em;
  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 11px; 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:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.chip[aria-pressed="true"] { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }

.hint {
  margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.6;
}
.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 {
  font-family: var(--font-mono); font-size: 12px; color: var(--danger);
  margin: 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;
}

/* these must sit after the base .trk / .chip rules or they lose on source order */
@media (max-width: 460px) {
  .trk .nm.full { display: none; }
  .trk .nm.short { display: inline; }
  .trk { font-size: 10px; padding: 3px 4px; gap: 4px; letter-spacing: .02em; }
  .chip { font-size: 12px; padding: 6px 9px; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
      transition-duration: .001ms !important; }
  .status.live .led { animation: none; opacity: 1; }
  .pad[data-v="1"].trig .fill,
  .pad[data-v="2"].trig .fill { transform: scaleY(1); }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Step Sequencer</h1>
    <p class="lede">
      Sixteen steps, four drum sounds, <b>no recordings anywhere</b> — every kick, snare, hat and clap is
      built out of oscillators and noise the moment it fires. A pattern is already loaded: press
      <b>Play</b>, then start rewriting it.
    </p>
  </header>

  <section class="card">
    <div class="transport">
      <button class="btn primary" id="play" type="button" aria-label="Play the pattern">
        <span class="gl" id="playglyph" aria-hidden="true">&#9654;</span><span id="playlabel">Play</span>
      </button>
      <span class="status" id="status"><span class="led" aria-hidden="true"></span><span id="statustext">STOPPED</span></span>
      <div class="readout" aria-hidden="true">
        <span>STEP</span><b id="roStep">01</b><span>/16</span>
      </div>
      <div class="knobs">
        <div class="knob">
          <label class="kl" for="tempo">TEMPO<span class="kv"><span id="tempoV">96</span> BPM</span></label>
          <input type="range" id="tempo" min="60" max="180" step="1" value="96">
        </div>
        <div class="knob">
          <label class="kl" for="swing">SWING<span class="kv"><span id="swingV">0</span>%</span></label>
          <input type="range" id="swing" min="0" max="55" step="1" value="0">
        </div>
      </div>
    </div>

    <div class="well">
      <div class="seq" id="seq"></div>
    </div>

    <div class="bar2">
      <div class="presets">
        <span class="grouplabel">PATTERN</span>
        <button class="chip" type="button" data-preset="backbeat" aria-pressed="true">Backbeat</button>
        <button class="chip" type="button" data-preset="four">Four on the floor</button>
        <button class="chip" type="button" data-preset="break">Breakbeat</button>
        <button class="chip" type="button" data-preset="half">Half time</button>
      </div>
      <div class="actions">
        <button class="btn" id="rand" type="button">Randomize</button>
        <button class="btn" id="clear" type="button">Clear</button>
      </div>
    </div>
  </section>

  <p class="hint">
    Tap a pad to cycle it <b>off &rarr; hit &rarr; accent</b>, or drag across the row to paint several.
    <kbd>Space</kbd> plays, <kbd>R</kbd> randomizes, <kbd>C</kbd> clears, <kbd>1</kbd>&ndash;<kbd>4</kbd> mute a
    track. Inside the grid, arrow keys move and <kbd>Enter</kbd> toggles.
  </p>
  <p class="note" id="audioNote" hidden>This browser has no Web Audio support, so the grid is silent — the sequencer still runs and the pattern still edits.</p>

  <footer>demo &middot; sloprun.dev</footer>
</div>

<div class="sr" id="live" role="status" aria-live="polite"></div>

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

  var root = document.documentElement;
  var STEPS = 16;
  var TRACKS = [
    { id: "kick",  name: "KICK",  abbr: "KCK", gain: 1.00 },
    { id: "snare", name: "SNARE", abbr: "SNR", gain: 0.80 },
    { id: "hat",   name: "HAT",   abbr: "HAT", gain: 0.46 },
    { id: "clap",  name: "CLAP",  abbr: "CLP", gain: 0.62 }
  ];

  /* patterns: "." silent, "x" hit, "X" accent — 16 characters each */
  var PRESETS = {
    backbeat: ["X.....x...x.....", "....X.......X...", "X.x.X.x.X.x.X.x.", "....x.......X.x."],
    four:     ["X...X...X...X...", "....x.......x...", "..X...X...X...X.", "....X.......X..."],
    "break":  ["X..X......X.....", "....X..x....X..x", "x.x.X.x.x.x.X.x.", "...........x...."],
    half:     ["X.....x.........", "........X.......", "X...x...X...x...", "........X....x.."]
  };

  var pattern = [];       /* 4 x 16 of 0 | 1 | 2 */
  var muted   = [false, false, false, false];

  /* ── audio ──────────────────────────────────────────────────────────────── */
  var Ctor = window.AudioContext || window.webkitAudioContext;
  var ctx = null, master = null, busses = [], noiseBuf = null, audioDead = !Ctor;

  function ensureAudio() {
    if (audioDead) return null;
    if (!ctx) {
      /* the whole graph build is guarded, not just the constructor: a context
         that exists but failed half way through wiring would leave `busses`
         empty and every later fire() would throw */
      try {
        ctx = new Ctor();
        var comp = ctx.createDynamicsCompressor();
        comp.threshold.value = -14; comp.knee.value = 22; comp.ratio.value = 5;
        comp.attack.value = 0.004; comp.release.value = 0.16;
        master = ctx.createGain();
        master.gain.value = 0.85;
        master.connect(comp); comp.connect(ctx.destination);

        busses = TRACKS.map(function (t) {
          var g = ctx.createGain();
          g.gain.value = t.gain;
          g.connect(master);
          return g;
        });

        var n = Math.floor(ctx.sampleRate * 1.5);
        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;
      } catch (err) {
        ctx = null; busses = [];
        audioDead = true;
        document.getElementById("audioNote").hidden = false;
        return null;
      }
    }
    if (ctx.state === "suspended" && ctx.resume) ctx.resume();
    return ctx;
  }

  function noise(t, dur) {
    var s = ctx.createBufferSource();
    s.buffer = noiseBuf;
    s.playbackRate.value = 1;
    s.start(t, Math.random() * 1.0, dur);
    return s;
  }
  function env(t, peak, dur, hold) {
    var g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), t + 0.004);
    if (hold) g.gain.setValueAtTime(Math.max(peak, 0.0002), t + hold);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    return g;
  }

  var VOICES = {
    kick: function (t, v) {
      var acc = v > 1;
      var o = ctx.createOscillator(), g = env(t, acc ? 1.0 : 0.72, acc ? 0.46 : 0.36);
      o.type = "sine";
      o.frequency.setValueAtTime(acc ? 168 : 142, t);
      o.frequency.exponentialRampToValueAtTime(43, t + 0.10);
      o.connect(g); g.connect(busses[0]);
      o.start(t); o.stop(t + (acc ? 0.5 : 0.4));

      var c = ctx.createOscillator(), cg = env(t, acc ? 0.34 : 0.24, 0.024);
      c.type = "triangle"; c.frequency.setValueAtTime(1250, t);
      c.connect(cg); cg.connect(busses[0]);
      c.start(t); c.stop(t + 0.05);
    },
    snare: function (t, v) {
      var acc = v > 1;
      var hp = ctx.createBiquadFilter();
      hp.type = "highpass"; hp.frequency.value = 1250; hp.Q.value = 0.7;
      var ng = env(t, acc ? 0.85 : 0.5, acc ? 0.21 : 0.15);
      noise(t, 0.3).connect(hp); hp.connect(ng); ng.connect(busses[1]);

      [188, 331].forEach(function (f, i) {
        var o = ctx.createOscillator(), g = env(t, (acc ? 0.5 : 0.34) * (i ? 0.5 : 1), 0.11);
        o.type = "triangle"; o.frequency.setValueAtTime(f, t);
        o.frequency.exponentialRampToValueAtTime(f * 0.78, t + 0.09);
        o.connect(g); g.connect(busses[1]);
        o.start(t); o.stop(t + 0.14);
      });
    },
    hat: function (t, v) {
      var acc = v > 1;                 /* accent = open hat */
      var dur = acc ? 0.30 : 0.052;
      var bp = ctx.createBiquadFilter();
      bp.type = "bandpass"; bp.frequency.value = 10000; bp.Q.value = 0.9;
      var hp = ctx.createBiquadFilter();
      hp.type = "highpass"; hp.frequency.value = 7200;
      var g = env(t, acc ? 0.55 : 0.62, dur, acc ? 0.02 : 0);
      bp.connect(hp); hp.connect(g); g.connect(busses[2]);

      var base = 40, ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
      for (var i = 0; i < ratios.length; i++) {
        var o = ctx.createOscillator();
        o.type = "square";
        o.frequency.setValueAtTime(base * ratios[i], t);
        o.connect(bp);
        o.start(t); o.stop(t + dur + 0.02);
      }
    },
    clap: function (t, v) {
      var acc = v > 1;
      var bp = ctx.createBiquadFilter();
      bp.type = "bandpass"; bp.frequency.value = 1050; bp.Q.value = 1.3;
      var out = ctx.createGain(); out.gain.value = acc ? 1 : 0.66;
      bp.connect(out); out.connect(busses[3]);

      var offs = [0, 0.012, 0.024];
      for (var i = 0; i < offs.length; i++) {
        var g = env(t + offs[i], 0.7, 0.028);
        noise(t + offs[i], 0.05).connect(g); g.connect(bp);
      }
      var tg = env(t + 0.034, 0.55, 0.18);
      noise(t + 0.034, 0.25).connect(tg); tg.connect(bp);
    }
  };

  function fire(track, t, v) {
    if (!ctx || busses.length !== TRACKS.length) return;
    VOICES[TRACKS[track].id](t, v);
  }

  /* ── DOM build ──────────────────────────────────────────────────────────── */
  var seq = document.getElementById("seq");
  var pads = [], labels = [], ticks = [];

  (function build() {
    var frag = document.createDocumentFragment();

    var corner = document.createElement("div");
    corner.setAttribute("aria-hidden", "true");
    frag.appendChild(corner);

    for (var s = 0; s < STEPS; s++) {
      var tk = document.createElement("div");
      tk.className = "tick" + (s % 4 === 0 ? " beat" : "") + (s % 4 === 0 && s > 0 ? " gap4" : "");
      tk.setAttribute("aria-hidden", "true");
      var bar = document.createElement("span"); bar.className = "bar";
      var lbl = document.createElement("span");
      lbl.textContent = s % 4 === 0 ? String(s / 4 + 1) : "·";
      tk.appendChild(bar); tk.appendChild(lbl);
      ticks.push(tk); frag.appendChild(tk);
    }

    TRACKS.forEach(function (tr, ti) {
      var lab = document.createElement("button");
      lab.type = "button";
      lab.className = "trk";
      lab.setAttribute("aria-pressed", "false");
      lab.setAttribute("aria-label", "Mute " + tr.name.toLowerCase() + " track");
      lab.title = "Mute / unmute " + tr.name.toLowerCase();
      var led = document.createElement("span"); led.className = "led2"; led.setAttribute("aria-hidden", "true");
      var nm = document.createElement("span"); nm.className = "nm full"; nm.textContent = tr.name;
      var nms = document.createElement("span"); nms.className = "nm short"; nms.textContent = tr.abbr;
      lab.appendChild(led); lab.appendChild(nm); lab.appendChild(nms);
      lab.addEventListener("click", function () { toggleMute(ti); });
      labels.push(lab); frag.appendChild(lab);

      pads[ti] = [];
      for (var s2 = 0; s2 < STEPS; s2++) {
        var p = document.createElement("button");
        p.type = "button";
        p.className = "pad" + (s2 % 4 === 0 && s2 > 0 ? " gap4" : "");
        p.dataset.t = String(ti);
        p.dataset.s = String(s2);
        p.dataset.v = "0";
        p.tabIndex = -1;
        var f = document.createElement("span"); f.className = "fill"; f.setAttribute("aria-hidden", "true");
        p.appendChild(f);
        pads[ti].push(p);
        frag.appendChild(p);
      }
    });

    seq.appendChild(frag);
    pads[0][0].tabIndex = 0;
  })();

  function padLabel(t, s) {
    var v = pattern[t][s];
    return TRACKS[t].name.toLowerCase() + ", step " + (s + 1) + ", " +
      (v === 2 ? "accent" : v === 1 ? "hit" : "off");
  }

  function paintPad(t, s) {
    var p = pads[t][s], v = pattern[t][s];
    p.dataset.v = String(v);
    p.setAttribute("aria-label", padLabel(t, s));
  }

  function setCell(t, s, v, preview) {
    if (pattern[t][s] === v) return;
    pattern[t][s] = v;
    paintPad(t, s);
    markCustom();
    if (preview && v > 0 && !playing) {
      if (ensureAudio()) fire(t, ctx.currentTime + 0.005, v);
    }
  }

  function loadPattern(rowsStr) {
    pattern = rowsStr.map(function (str) {
      var arr = [];
      for (var i = 0; i < STEPS; i++) {
        var ch = str.charAt(i);
        arr.push(ch === "X" ? 2 : ch === "x" ? 1 : 0);
      }
      return arr;
    });
    for (var t = 0; t < TRACKS.length; t++) for (var s = 0; s < STEPS; s++) paintPad(t, s);
  }

  /* ── mute ───────────────────────────────────────────────────────────────── */
  function toggleMute(ti) {
    muted[ti] = !muted[ti];
    labels[ti].setAttribute("aria-pressed", muted[ti] ? "true" : "false");
    for (var s = 0; s < STEPS; s++) pads[ti][s].classList.toggle("mutepad", muted[ti]);
    if (muted[ti]) labels[ti].classList.remove("hit");
    say(TRACKS[ti].name.toLowerCase() + (muted[ti] ? " muted" : " unmuted"));
  }

  /* ── transport / scheduling ─────────────────────────────────────────────── */
  var playing = false, timer = null, raf = 0;
  var bpm = 96, swing = 0;
  var nextBase = 0, curStep = 0, uiStep = -1;
  var vqueue = [];
  /* every press of Play gets a ticket; a resume() promise that settles after the
     user already stopped (or restarted) holds a stale ticket and bails, so we
     never end up with two schedulers racing each other */
  var runId = 0;

  var playBtn = document.getElementById("play");
  var playGlyph = document.getElementById("playglyph");
  var playLabel = document.getElementById("playlabel");
  var statusEl = document.getElementById("status");
  var statusText = document.getElementById("statustext");
  var roStep = document.getElementById("roStep");

  function stepDur() { return 60 / bpm / 4; }

  function scheduleStep(step, t) {
    for (var i = 0; i < TRACKS.length; i++) {
      var v = pattern[i][step];
      if (v > 0 && !muted[i]) fire(i, t, v);
    }
  }

  function scheduler() {
    var ahead = 0.12;
    /* a throttled background tab can leave us far behind — resync instead of
       dumping a dozen overdue steps into the same instant */
    if (nextBase < ctx.currentTime - 0.05) { nextBase = ctx.currentTime + 0.02; }
    while (nextBase < ctx.currentTime + ahead) {
      var d = stepDur();
      var t = nextBase + (curStep % 2 ? d * (swing / 100) : 0);
      scheduleStep(curStep, t);
      vqueue.push([curStep, t]);
      nextBase += d;
      curStep = (curStep + 1) % STEPS;
    }
    /* a hidden tab freezes rAF but not the scheduler, so cap the drain queue */
    if (vqueue.length > 64) vqueue.splice(0, vqueue.length - 64);
    timer = setTimeout(scheduler, 25);
  }

  function frame() {
    raf = requestAnimationFrame(frame);
    if (!ctx) return;
    var now = ctx.currentTime, s = -1;
    while (vqueue.length && vqueue[0][1] <= now) { s = vqueue[0][0]; vqueue.shift(); }
    if (s >= 0 && s !== uiStep) showStep(s);
  }

  function showStep(s) {
    if (uiStep >= 0) {
      ticks[uiStep].classList.remove("now");
      for (var i = 0; i < TRACKS.length; i++) {
        pads[i][uiStep].classList.remove("col", "trig");
      }
    }
    uiStep = s;
    if (s < 0) { roStep.textContent = "01"; return; }
    ticks[s].classList.add("now");
    for (var k = 0; k < TRACKS.length; k++) {
      pads[k][s].classList.add("col");
      if (pattern[k][s] > 0) {
        pads[k][s].classList.add("trig");
        if (!muted[k]) flashLabel(k);
      }
    }
    roStep.textContent = (s + 1 < 10 ? "0" : "") + (s + 1);
  }

  var flashTimers = [];
  function flashLabel(i) {
    labels[i].classList.add("hit");
    clearTimeout(flashTimers[i]);
    flashTimers[i] = setTimeout(function () { labels[i].classList.remove("hit"); }, 90);
  }

  /* no Web Audio in this browser: the transport still has to move. A plain
     rAF clock sweeps the playhead so Play, Space and the readout stay alive. */
  var silentLast = 0, silentAcc = 0;
  function nowMs() {
    return (window.performance && performance.now) ? performance.now() : Date.now();
  }
  function silentFrame() {
    raf = requestAnimationFrame(silentFrame);
    var t = nowMs();
    silentAcc += (t - silentLast) / 1000;
    silentLast = t;
    var d = stepDur(), guard = 0;
    while (silentAcc >= d && guard++ < STEPS) {
      silentAcc -= d;
      curStep = (curStep + 1) % STEPS;
    }
    if (guard >= STEPS) silentAcc = 0;
    if (curStep !== uiStep) showStep(curStep);
  }

  function runSilentClock() {
    clearTimeout(timer); timer = null;
    cancelAnimationFrame(raf); raf = 0;
    vqueue = [];
    curStep = 0;
    silentLast = nowMs(); silentAcc = 0;
    showStep(0);
    raf = requestAnimationFrame(silentFrame);
  }

  /* Some environments hand back a context that reports "running" but whose
     clock never actually advances — no output device, or an embed that muted
     us. Nothing would ever drain the visual queue, so the transport would sit
     there claiming RUNNING behind a frozen playhead. Sample the clock shortly
     after starting and drop to the wall-clock sweep if it hasn't moved. */
  function watchClock(ticket, t0) {
    setTimeout(function () {
      if (!playing || ticket !== runId) return;
      if (ctx && ctx.currentTime - t0 < 0.02) runSilentClock();
    }, 400);
  }

  function start() {
    if (playing) return;
    var live = ensureAudio();
    playing = true;
    curStep = 0; uiStep = -1; vqueue = [];

    if (!live) {
      runSilentClock();
    } else {
      /* the clock only really starts once the context is running, so wait for the
         resume to settle before laying down the first step */
      var ticket = ++runId;
      var go = function () {
        if (!playing || ticket !== runId) return;
        if (ctx.state !== "running") { runSilentClock(); return; }
        var t0 = ctx.currentTime;
        nextBase = t0 + 0.06;
        scheduler();
        raf = requestAnimationFrame(frame);
        watchClock(ticket, t0);
      };
      var pr = (ctx.state === "suspended" && ctx.resume) ? ctx.resume() : null;
      if (pr && pr.then) pr.then(go, go); else go();
    }

    playGlyph.innerHTML = "&#9632;";
    playLabel.textContent = "Stop";
    playBtn.classList.add("on");
    playBtn.setAttribute("aria-label", "Stop the pattern");
    statusEl.classList.add("live");
    statusText.textContent = "RUNNING";
    say("playing");
  }

  function stop() {
    if (!playing) return;
    playing = false;
    runId++;
    clearTimeout(timer); timer = null;
    cancelAnimationFrame(raf); raf = 0;
    vqueue = [];
    showStep(-1);
    for (var i = 0; i < TRACKS.length; i++) labels[i].classList.remove("hit");
    playGlyph.innerHTML = "&#9654;";
    playLabel.textContent = "Play";
    playBtn.classList.remove("on");
    playBtn.setAttribute("aria-label", "Play the pattern");
    statusEl.classList.remove("live");
    statusText.textContent = "STOPPED";
    say("stopped");
  }

  playBtn.addEventListener("click", function () { playing ? stop() : start(); });

  /* ── knobs ──────────────────────────────────────────────────────────────── */
  var tempoEl = document.getElementById("tempo");
  var swingEl = document.getElementById("swing");
  var tempoV = document.getElementById("tempoV");
  var swingV = document.getElementById("swingV");

  tempoEl.addEventListener("input", function () {
    bpm = +tempoEl.value;
    tempoV.textContent = bpm;
  });
  swingEl.addEventListener("input", function () {
    swing = +swingEl.value;
    swingV.textContent = swing;
  });

  /* ── pattern bar ────────────────────────────────────────────────────────── */
  var chips = [].slice.call(document.querySelectorAll("[data-preset]"));
  function markPreset(name) {
    chips.forEach(function (c) {
      c.setAttribute("aria-pressed", c.dataset.preset === name ? "true" : "false");
    });
  }
  function markCustom() { markPreset(null); }

  chips.forEach(function (c) {
    c.addEventListener("click", function () {
      loadPattern(PRESETS[c.dataset.preset]);
      markPreset(c.dataset.preset);
      say(c.textContent + " pattern loaded");
    });
  });

  document.getElementById("clear").addEventListener("click", function () {
    loadPattern([".".repeat(16), ".".repeat(16), ".".repeat(16), ".".repeat(16)]);
    markCustom();
    say("grid cleared");
  });

  function chance(p) { return Math.random() < p; }
  function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

  function randomize() {
    var k = new Array(16).fill(0), sn = new Array(16).fill(0),
        h = new Array(16).fill(0), c = new Array(16).fill(0);

    /* kick: always the downbeat, then a family of answers */
    k[0] = 2;
    var fam = pick([[8], [6, 10], [10], [6, 14], [8, 14], [3, 10]]);
    fam.forEach(function (i) { k[i] = 1; });
    [2, 5, 7, 11, 13, 15].forEach(function (i) { if (chance(0.12)) k[i] = 1; });

    /* snare: the backbeat holds, ghosts drift around it */
    sn[4] = 2; sn[12] = 2;
    [7, 10, 14, 15].forEach(function (i) { if (chance(0.22)) sn[i] = 1; });

    /* hat: one of four densities, accents land on the beat */
    var mode = pick(["8th", "8th", "16th", "off", "quarter"]);
    for (var i = 0; i < 16; i++) {
      var on = mode === "16th" ? true
        : mode === "8th" ? i % 2 === 0
        : mode === "off" ? i % 4 === 2
        : i % 4 === 0;
      if (on) h[i] = (i % 4 === 0 && mode !== "off") ? 2 : 1;
    }
    if (chance(0.4)) h[pick([6, 10, 14])] = 2;

    /* clap: a lift, not a carpet */
    if (chance(0.7)) c[12] = 2;
    if (chance(0.45)) c[4] = 1;
    if (chance(0.3)) c[pick([6, 11, 14, 15])] = 1;

    pattern = [k, sn, h, c];
    for (var t = 0; t < 4; t++) for (var s = 0; s < 16; s++) paintPad(t, s);
    markCustom();
    say("new pattern");
  }
  document.getElementById("rand").addEventListener("click", function () {
    ensureAudio();
    randomize();
  });

  /* ── pad interaction: pointer paint + keyboard grid ─────────────────────── */
  var painting = false, paintVal = 0, paintTrack = -1;

  function focusPad(t, s) {
    pads.forEach(function (row) { row.forEach(function (p) { p.tabIndex = -1; }); });
    pads[t][s].tabIndex = 0;
    pads[t][s].focus();
  }

  seq.addEventListener("pointerdown", function (e) {
    /* left button / first finger only: a right-click or a second touch used to
       start its own paint stroke and fight the one already in progress */
    if (e.button > 0 || e.isPrimary === false) return;
    var p = e.target.closest ? e.target.closest(".pad") : null;
    if (!p) return;
    e.preventDefault();
    ensureAudio();
    var t = +p.dataset.t, s = +p.dataset.s;
    focusPad(t, s);
    var nv = (pattern[t][s] + 1) % 3;
    setCell(t, s, nv, true);
    painting = true; paintVal = nv; paintTrack = t;
  });

  window.addEventListener("pointermove", function (e) {
    if (!painting) return;
    var el = document.elementFromPoint(e.clientX, e.clientY);
    var p = el && el.closest ? el.closest(".pad") : null;
    if (!p) return;
    /* stay on the row the drag started on — a slightly diagonal swipe used to
       overwrite the neighbouring tracks too */
    if (+p.dataset.t !== paintTrack) return;
    setCell(paintTrack, +p.dataset.s, paintVal, false);
  });
  window.addEventListener("pointerup", function () { painting = false; paintTrack = -1; });
  window.addEventListener("pointercancel", function () { painting = false; paintTrack = -1; });

  seq.addEventListener("keydown", function (e) {
    var p = e.target.closest ? e.target.closest(".pad") : null;
    if (!p) return;
    var t = +p.dataset.t, s = +p.dataset.s, handled = true;
    switch (e.key) {
      case "ArrowRight": focusPad(t, (s + 1) % STEPS); break;
      case "ArrowLeft":  focusPad(t, (s + STEPS - 1) % STEPS); break;
      case "ArrowDown":  focusPad((t + 1) % TRACKS.length, s); break;
      case "ArrowUp":    focusPad((t + TRACKS.length - 1) % TRACKS.length, s); break;
      case "Home":       focusPad(t, 0); break;
      case "End":        focusPad(t, STEPS - 1); break;
      case "Enter":
        /* auto-repeat would race the pad through off/hit/accent while the key
           is merely held down — swallow it, but still eat the keystroke */
        if (e.repeat) break;
        ensureAudio();
        setCell(t, s, (pattern[t][s] + 1) % 3, true);
        break;
      case " ":
      case "Spacebar":
        /* the grid is a custom widget: space stays the transport, enter edits */
        if (e.repeat) break;
        if (playing) stop(); else start();
        break;
      case "Backspace":
      case "Delete":     setCell(t, s, 0, false); break;
      default: handled = false;
    }
    if (handled) e.preventDefault();
  });

  /* ── global keys ────────────────────────────────────────────────────────── */
  document.addEventListener("keydown", function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    /* every global key here is a one-shot action: without this, holding Space
       strobes the transport and holding R reshuffles the grid ~30x a second */
    if (e.repeat) return;
    var tag = (e.target && e.target.tagName) || "";
    var typing = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
    if (typing) return;
    if (!e.key) return;
    var onButton = tag === "BUTTON";
    if (e.key === " " || e.key === "Spacebar") {
      if (onButton) return;   /* let the focused button handle its own space */
      e.preventDefault();
      playing ? stop() : start();
      return;
    }
    var k = e.key.toLowerCase();
    if (k === "r") { ensureAudio(); randomize(); }
    else if (k === "c") {
      loadPattern([".".repeat(16), ".".repeat(16), ".".repeat(16), ".".repeat(16)]);
      markCustom(); say("grid cleared");
    }
    else if (k >= "1" && k <= "4") toggleMute(+k - 1);
  });

  /* ── announcements ──────────────────────────────────────────────────────── */
  var liveEl = document.getElementById("live"), sayTimer = null;
  function say(msg) {
    clearTimeout(sayTimer);
    liveEl.textContent = "";
    sayTimer = setTimeout(function () { liveEl.textContent = msg; }, 110);
  }

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  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) document.getElementById("audioNote").hidden = false;
  loadPattern(PRESETS.backbeat);
  markPreset("backbeat");
  bpm = +tempoEl.value; tempoV.textContent = bpm;
  swing = +swingEl.value; swingV.textContent = swing;
})();
</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