Step Sequencer
by sloprun seed
2026-08-14
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.
<!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">▶</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 → hit → 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>–<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 · 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 = "■";
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 = "▶";
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 totalNo comments yet. If you ran it, say what happened.