Sequencer
Qwen 3.8 27B GGUF · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 39,378 bytes · SHA-256 107f6d1ad935
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PS-16 · Step Sequencer</title>
<style>
/* ============================================================
PS-16 — CSS
============================================================ */
:root{
--bg:#0a0c11; --panel:#131720; --panel2:#171c27; --line:#232a3a;
--text:#e8eaf0; --dim:#8b93a7; --faint:#525b70;
--led:#ff4d5e; --led-off:#3a2327;
--led-green:#45e07c;
--grid-gutter:10px; --row-h:56px;
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{
background:
radial-gradient(1200px 600px at 50% -10%, #151b28 0%, rgba(21,27,40,0) 60%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
var(--bg);
color:var(--text);
font-family:'Segoe UI', system-ui, -apple-system, sans-serif;
display:flex; justify-content:center; align-items:flex-start;
padding:28px 16px 48px;
user-select:none; -webkit-user-select:none;
}
#app{
width:980px; max-width:100%;
background:linear-gradient(180deg,#161b26 0%, #10141d 100%);
border:1px solid #2a3145; border-radius:14px;
box-shadow:0 30px 60px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.06);
padding:18px 20px 20px;
position:relative;
}
#app::before{ /* top edge highlight */
content:""; position:absolute; inset:0 0 auto 0; height:1px;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.15), transparent);
border-radius:14px 14px 0 0;
}
/* ---------- header ---------- */
header{
display:flex; align-items:center; justify-content:space-between; gap:16px;
padding-bottom:14px; border-bottom:1px solid var(--line);
}
.brand{display:flex; align-items:baseline; gap:10px;}
.brand .logo{
font-family:"Consolas","SF Mono",monospace; font-weight:700; font-size:26px;
letter-spacing:2px; color:#fff;
text-shadow:0 0 18px rgba(255,77,94,.45);
}
.brand .logo b{color:var(--led);}
.brand .tag{font-size:10px; letter-spacing:3px; color:var(--dim); text-transform:uppercase;}
.transport{display:flex; align-items:center; gap:10px;}
.tbtn{
width:52px; height:44px; border-radius:9px; border:1px solid #2c3448;
background:linear-gradient(180deg,#232a3b,#171c29);
color:var(--dim); font-size:17px; cursor:pointer;
display:flex; align-items:center; justify-content:center;
box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.08);
transition:transform .05s, color .15s, box-shadow .15s;
}
.tbtn:hover{color:var(--text);}
.tbtn:active{transform:translateY(2px); box-shadow:0 1px 0 #0a0d14;}
.tbtn.play{
width:64px; color:var(--led);
border-color:#5a2a33;
}
.tbtn.play.on{
color:#1a0507; background:linear-gradient(180deg,#ff6d7c,#e53245);
box-shadow:0 3px 0 #6d1420, 0 0 22px rgba(255,77,94,.45), inset 0 1px 0 rgba(255,255,255,.4);
text-shadow:none;
}
.tbtn.play.on:hover{filter:brightness(1.05);}
/* ---------- controls ---------- */
#controls{
display:flex; align-items:flex-end; gap:22px; flex-wrap:wrap;
padding:16px 0 14px; border-bottom:1px solid var(--line);
}
.ctl{display:flex; flex-direction:column; gap:7px;}
.ctl .lbl{
font-size:9px; letter-spacing:2.5px; color:var(--faint); text-transform:uppercase;
display:flex; justify-content:space-between; gap:8px;
}
.ctl .lbl .val{color:var(--dim); letter-spacing:1px; font-family:"Consolas",monospace; font-size:10px;}
.knob-row{display:flex; align-items:center; gap:12px;}
.knob{
width:46px; height:46px; border-radius:50%;
background:
radial-gradient(circle at 35% 30%, #39415a 0%, #1c2230 55%, #10141d 100%);
border:1px solid #313a52; position:relative; cursor:ns-resize;
box-shadow:0 4px 10px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.12);
touch-action:none;
}
.knob:focus-visible{outline:2px solid #45e07c55;}
.knob .ptr{
position:absolute; left:50%; top:50%; width:3px; height:17px; border-radius:2px;
background:#ff5d6e; transform-origin:50% 0%;
box-shadow:0 0 8px rgba(255,77,94,.8);
transform:translate(-50%,0) rotate(var(--a,0deg));
}
input[type=range]{
-webkit-appearance:none; appearance:none;
width:150px; height:26px; background:transparent; cursor:pointer;
}
input[type=range]::-webkit-slider-runnable-track{
height:6px; border-radius:3px;
background:linear-gradient(180deg,#0b0e14,#1a2030);
border:1px solid #262e42; box-shadow:inset 0 1px 3px rgba(0,0,0,.7);
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none; width:22px; height:16px; border-radius:4px; margin-top:-6px;
background:linear-gradient(180deg,#3b445e,#232a3c);
border:1px solid #4a5578; box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.18);
}
input[type=range]::-moz-range-track{
height:6px; border-radius:3px; background:#141926; border:1px solid #262e42;
}
input[type=range]::-moz-range-thumb{
width:20px; height:14px; border-radius:4px;
background:linear-gradient(180deg,#3b445e,#232a3c); border:1px solid #4a5578;
}
#bpmRead{
font-family:"Consolas","SF Mono",monospace; font-size:30px; font-weight:700;
letter-spacing:1px; color:#45e07c;
background:#060a08; border:1px solid #1d3a29; border-radius:8px;
padding:2px 12px 0; min-width:118px; text-align:right;
text-shadow:0 0 14px rgba(69,224,124,.55);
box-shadow:inset 0 2px 8px rgba(0,0,0,.8);
line-height:1.15;
}
#bpmRead small{font-size:9px; letter-spacing:2px; color:#2e7a4c; display:block; text-align:right;}
.spacer{flex:1;}
.mini-btns{display:flex; gap:8px;}
.mbtn{
padding:10px 14px; border-radius:8px; border:1px solid #2c3448;
background:linear-gradient(180deg,#202636,#161b28);
color:var(--dim); font-size:10px; letter-spacing:2px; cursor:pointer; text-transform:uppercase;
box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.07);
}
.mbtn:hover{color:var(--text);}
.mbtn:active{transform:translateY(2px); box-shadow:0 1px 0 #0a0d14;}
.mbtn.danger:hover{color:#ff7583; border-color:#5a2a33;}
/* ---------- grid ---------- */
#gridWrap{padding:16px 0 6px;}
.grid{
display:grid;
grid-template-columns:150px repeat(16, minmax(34px,1fr));
column-gap:var(--grid-gutter);
}
.corner{display:flex; align-items:center; justify-content:flex-end; padding-right:14px;}
.corner .stepleds{display:flex; gap:3px;}
.corner .stepleds i{
width:5px; height:5px; border-radius:50%;
background:#1c2333; border:1px solid #2a3348;
transition:background .08s, box-shadow .08s;
}
.corner .stepleds i.on{background:var(--led-green); box-shadow:0 0 8px rgba(69,224,124,.9); border-color:var(--led-green);}
.corner .stepleds i.on.beat{background:var(--led); box-shadow:0 0 10px rgba(255,77,94,.9); border-color:var(--led);}
.cellhead{
height:30px; display:flex; align-items:center; justify-content:center;
font-family:"Consolas",monospace; font-size:11px; color:var(--faint);
border-bottom:1px solid var(--line); margin-bottom:2px;
}
.cellhead.beat{color:var(--dim); font-weight:700;}
.trackhead{
display:flex; flex-direction:column; align-items:flex-start; justify-content:center;
gap:4px; padding-right:14px;
}
.trackhead .tname{font-size:11px; letter-spacing:1.5px; color:var(--text); text-transform:uppercase;}
.trackhead .ticon{font-size:13px; line-height:1;}
.trackhead .tsub{display:flex; gap:6px; align-items:center;}
.tmute,.tsolo{
font-family:inherit; font-size:9px; letter-spacing:1px; cursor:pointer;
padding:3px 7px; border-radius:4px; border:1px solid #2c3448;
background:#171c29; color:var(--faint);
box-shadow:0 2px 0 #0a0d14;
}
.tmute:hover,.tsolo:hover{color:var(--dim);}
.tmute.on{color:#1a0507; background:var(--led); border-color:#ff7583; box-shadow:0 2px 0 #6d1420, 0 0 10px rgba(255,77,94,.5); font-weight:700;}
.tsolo.on{color:#0a1a0f; background:var(--led-green); border-color:#7dffb0; box-shadow:0 2px 0 #1d5c36, 0 0 10px rgba(69,224,124,.5); font-weight:700;}
.vol{
-webkit-appearance:none; appearance:none;
width:100px; height:18px; background:transparent; cursor:pointer;
}
.vol::-webkit-slider-runnable-track{height:4px; border-radius:2px; background:#0d1119; border:1px solid #262e42;}
.vol::-webkit-slider-thumb{
-webkit-appearance:none; width:12px; height:12px; border-radius:50%; margin-top:-5px;
background:radial-gradient(circle at 35% 30%, #55618a, #232a3c);
border:1px solid #4a5578; box-shadow:0 1px 3px rgba(0,0,0,.6);
}
.vol::-moz-range-track{height:4px; border-radius:2px; background:#0d1119; border:1px solid #262e42;}
.vol::-moz-range-thumb{width:11px; height:11px; border-radius:50%; background:#3b445e; border:1px solid #4a5578;}
.track{display:contents;}
.trackhead, .track .cell{grid-row:var(--row);}
.cell{
height:var(--row-h); border-radius:7px; cursor:pointer; position:relative;
background:linear-gradient(180deg,#1d2331 0%, #151a25 100%);
border:1px solid #262e42;
box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.05);
transition:background .07s, box-shadow .07s, border-color .07s, opacity .15s, transform .05s;
}
.cell.beat{border-left:2px solid #3a4460; border-radius:7px 4px 4px 7px;}
.cell:hover{border-color:#3d4a6b;}
.cell.accent{box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.09);}
.cell.accent::after{
content:""; position:absolute; right:6px; bottom:5px; width:5px; height:5px; border-radius:50%;
background:var(--c-dim); box-shadow:0 0 5px var(--c-glow);
}
.cell.active{
background:linear-gradient(180deg, var(--c-hi) 0%, var(--c-lo) 100%);
border-color:var(--c-edge);
box-shadow:0 3px 0 #0a0d14, 0 0 14px var(--c-glow), inset 0 1px 0 rgba(255,255,255,.35);
}
.cell.accent.active::after{background:#fff; box-shadow:0 0 8px #fff;}
.track.muted .cell{opacity:.35; filter:saturate(.4);}
.track.muted .trackhead .tname{color:var(--faint);}
.track.dimmed .cell{opacity:.18; filter:saturate(.25);}
.track.dimmed .trackhead{opacity:.4;}
.cell.playhead{
border-color:#fff;
box-shadow:0 0 0 1px #fff, 0 0 18px rgba(255,255,255,.55), 0 3px 0 #0a0d14;
z-index:2;
}
.cell.active.playhead{
background:linear-gradient(180deg, #ffffff 0%, var(--c-hi) 70%);
box-shadow:0 0 0 1.5px #fff, 0 0 26px var(--c-glow), 0 0 10px #fff;
}
.cell.paint{border-color:#fff;}
/* ---------- footer ---------- */
footer{
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;
padding-top:14px; margin-top:8px; border-top:1px solid var(--line);
font-size:10px; color:var(--faint); letter-spacing:.5px;
}
footer .hint b{color:var(--dim); font-weight:600;}
#status{font-family:"Consolas",monospace; color:var(--dim); letter-spacing:1px;}
#status.live{color:var(--led); text-shadow:0 0 8px rgba(255,77,94,.5);}
</style>
</head>
<body>
<div id="app">
<!-- ======================= HEADER ======================= -->
<header>
<div class="brand">
<div class="logo">PS<b>·</b>16</div>
<div class="tag">Step Sequencer<br>Six Synthesized Voices</div>
</div>
<div class="transport">
<button class="tbtn" id="btnStop" title="Stop (Space)">■</button>
<button class="tbtn play" id="btnPlay" title="Play (Space)">▶</button>
</div>
</header>
<!-- ======================= CONTROLS ======================= -->
<div id="controls">
<div class="ctl">
<div class="lbl">Tempo <span class="val" id="bpmVal">120 BPM</span></div>
<div class="knob-row">
<div class="knob" id="knobBpm" tabindex="0" role="slider" aria-label="Tempo" aria-valuemin="60" aria-valuemax="200" aria-valuenow="120"><div class="ptr"></div></div>
<div id="bpmRead">120<small>BPM</small></div>
</div>
</div>
<div class="ctl">
<div class="lbl">Swing <span class="val" id="swingVal">0%</span></div>
<div class="knob-row">
<div class="knob" id="knobSwing" tabindex="0" role="slider" aria-label="Swing" aria-valuemin="0" aria-valuemax="60" aria-valuenow="0"><div class="ptr"></div></div>
</div>
</div>
<div class="ctl">
<div class="lbl">Master <span class="val" id="masterVal">80%</span></div>
<div class="knob-row">
<div class="knob" id="knobMaster" tabindex="0" role="slider" aria-label="Master volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80"><div class="ptr"></div></div>
</div>
</div>
<div class="spacer"></div>
<div class="mini-btns">
<button class="mbtn" id="btnRandom">Random</button>
<button class="mbtn danger" id="btnClear">Clear</button>
</div>
</div>
<!-- ======================= GRID ======================= -->
<div id="gridWrap"><div class="grid" id="grid"></div></div>
<!-- ======================= FOOTER ======================= -->
<footer>
<div class="hint">
<b>Click</b> cell = on · <b>Click again</b> = accent · <b>Click again</b> = off ·
<b>Right-click</b> = accent toggle · <b>Drag</b> = paint / erase · <b>Space</b> = play/stop
</div>
<div id="status">STOPPED</div>
</footer>
</div>
<script>
'use strict';
/* ============================================================
1. TUNABLE CONSTANTS
============================================================ */
const CFG = {
STEPS: 16,
BPM_MIN: 60, BPM_MAX: 200,
SCHEDULER_INTERVAL_MS: 25, // how often the scheduler wakes (ms)
LOOKAHEAD_S: 0.12, // how far ahead notes are scheduled (s)
SAVE_DEBOUNCE_MS: 400,
STORAGE_KEY: 'ps16-pattern-v1',
// Velocity levels: 0=off, 1=normal, 2=accent
VEL_NORMAL: 0.9, VEL_ACCENT: 1.35,
// Instrument patch parameters
PATCH: {
kick: { f0: 165, f1: 46, fEnv: 0.10, amp: 0.95, aDec: 0.42, click: 0.22, clickDur: 0.012 },
snare: { body: 190, bodyDecay: 0.16, noiseAmp: 0.55, noiseDecay: 0.19, hp: 1600, bp: 3200, q: 0.9,
brightNormal: 2600, brightAccent: 4800 },
hatC: { hp: 7800, amp: 0.5, decay: 0.05, bp: 11000, q: 1.2 },
hatO: { hp: 8200, amp: 0.42, decay: 0.55, bp: 11000, q: 1.1 },
clap: { bp: 1100, q: 1.6, amp: 0.6, decay: 0.13, offsets: [0, 0.018, 0.036] },
tom: { f0: 131, f1: 62, fEnv: 0.14, amp: 0.8, aDec: 0.48 } // G3-ish, musically a 5th above kick's E
}
};
const TRACKS = [
{ id:'kick', name:'Kick', icon:'◉', color:'#ff5d6e', hi:'#ff8d99', lo:'#c2273a', edge:'#ff7583', glow:'rgba(255,93,110,.45)', dim:'rgba(255,93,110,.8)' },
{ id:'snare', name:'Snare', icon:'◍', color:'#ffb14d', hi:'#ffc778', lo:'#d17a17', edge:'#ffc06e', glow:'rgba(255,177,77,.45)', dim:'rgba(255,177,77,.8)' },
{ id:'hatc', name:'Hat C', icon:'◦', color:'#5fd0ff', hi:'#8fdcff', lo:'#1e8fc7', edge:'#7fdcff', glow:'rgba(95,208,255,.45)', dim:'rgba(95,208,255,.8)' },
{ id:'hato', name:'Hat O', icon:'◌', color:'#45e07c', hi:'#7defa6', lo:'#1d9e52', edge:'#7defa6', glow:'rgba(69,224,124,.45)', dim:'rgba(69,224,124,.8)' },
{ id:'clap', name:'Clap', icon:'◈', color:'#c792ff', hi:'#dcb6ff', lo:'#8a4fd1', edge:'#d3aaff', glow:'rgba(199,146,255,.45)', dim:'rgba(199,146,255,.8)' },
{ id:'tom', name:'Tom', icon:'◎', color:'#ff8fd8', hi:'#ffbadf', lo:'#c74ba3', edge:'#ffb0e2', glow:'rgba(255,143,216,.45)', dim:'rgba(255,143,216,.8)' }
];
/* ============================================================
2. DEFAULT PATTERN (a grooving house/rock hybrid)
============================================================ */
function defaultPattern(){
const P = {};
TRACKS.forEach(t => P[t.id] = new Array(CFG.STEPS).fill(0));
// kick: 1 . . . | . . . K | 1 . . . | . . . .
P.kick = [1,0,0,0, 0,0,0,1, 1,0,0,0, 0,0,0,0];
// snare: backbeats on 3 & 7 (accented on 7)
P.snare = [0,0,0,0, 1,0,0,0, 0,0,0,0, 2,0,0,0];
// hat c: driving 8ths, accents on the downbeats
P.hatc = [1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0];
// hat o: open on the "and" before the beat, classic
P.hato = [0,0,0,0, 0,0,0,1, 0,0,0,0, 0,0,0,1];
// clap: layered ghost on the snare 7
P.clap = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,0];
// tom: little fill at the end of the bar
P.tom = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,2];
return P;
}
const DEFAULT_STATE = {
bpm: 120, swing: 0, master: 0.8,
vols: { kick:0.9, snare:0.75, hatc:0.55, hato:0.5, clap:0.7, tom:0.7 },
mutes: { kick:false, snare:false, hatc:false, hato:false, clap:false, tom:false },
solos: { kick:false, snare:false, hatc:false, hato:false, clap:false, tom:false },
pattern: defaultPattern()
};
/* ============================================================
3. AUDIO ENGINE — one shared AudioContext, created on first
user gesture. All voices are scheduled at exact future
times with source.start(t). Envelopes start/end at zero.
============================================================ */
let actx = null;
let masterGain = null, comp = null, analyser = null;
let noiseBuf = null;
function initAudio(){
if (actx) { if (actx.state === 'suspended') actx.resume(); return; }
actx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = actx.createGain();
masterGain.gain.value = state.master;
comp = actx.createDynamicsCompressor();
comp.threshold.value = -14; comp.knee.value = 24; comp.ratio.value = 3.5;
comp.attack.value = 0.003; comp.release.value = 0.12;
masterGain.connect(comp); comp.connect(actx.destination);
// analysis tap (for level metering / testing)
analyser = actx.createAnalyser();
analyser.fftSize = 2048;
comp.connect(analyser);
// pre-render 2s of white noise (shared by all noise voices)
const len = actx.sampleRate * 2;
noiseBuf = actx.createBuffer(1, len, actx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
}
// tiny helpers
function makeNoise(t, dur){
const src = actx.createBufferSource();
src.buffer = noiseBuf;
src.start(t, Math.random() * 1.2, dur + 0.05);
return src;
}
function envGain(t, peak, decay, attack){
// gain node with a zero-start envelope (no clicks)
const g = actx.createGain();
const a = attack || 0.001;
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(peak, t + a);
g.gain.exponentialRampToValueAtTime(0.0001, t + a + decay);
return g;
}
const velocityFor = lvl => lvl >= 2 ? CFG.VEL_ACCENT : CFG.VEL_NORMAL;
/* ---------------- voice patches ---------------- */
function playKick(t, vel){
const p = CFG.PATCH.kick;
const osc = actx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(p.f0, t);
osc.frequency.exponentialRampToValueAtTime(p.f1, t + p.fEnv);
const g = actx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(p.amp * vel, t + 0.002);
g.gain.exponentialRampToValueAtTime(0.0001, t + p.aDec);
osc.connect(g); g.connect(masterGain);
osc.start(t); osc.stop(t + p.aDec + 0.05);
// click transient
const n = makeNoise(t, p.clickDur);
const ng = actx.createGain();
ng.gain.setValueAtTime(0.0001, t);
ng.gain.linearRampToValueAtTime(p.click * vel, t + 0.0005);
ng.gain.exponentialRampToValueAtTime(0.0001, t + p.clickDur + 0.004);
const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1200;
n.connect(hp); hp.connect(ng); ng.connect(masterGain);
n.stop(t + p.clickDur + 0.05);
}
function playSnare(t, vel, accent){
const p = CFG.PATCH.snare;
// tonal body
const osc = actx.createOscillator(); osc.type = 'triangle';
osc.frequency.setValueAtTime(p.body, t);
osc.frequency.exponentialRampToValueAtTime(p.body * 0.72, t + p.bodyDecay);
const og = envGain(t, 0.5 * vel, p.bodyDecay, 0.0015);
osc.connect(og); og.connect(masterGain);
osc.start(t); osc.stop(t + p.bodyDecay + 0.05);
// noise burst — accent brightens the bandpass (timbre change)
const n = makeNoise(t, p.noiseDecay + 0.05);
const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = p.hp;
const bp = actx.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = accent ? p.brightAccent : p.brightNormal; bp.Q.value = p.q;
const ng = envGain(t, p.noiseAmp * vel, p.noiseDecay, 0.001);
n.connect(hp); hp.connect(bp); bp.connect(ng); ng.connect(masterGain);
n.stop(t + p.noiseDecay + 0.1);
}
function playHat(t, vel, open){
const p = open ? CFG.PATCH.hato : CFG.PATCH.hatC;
const n = makeNoise(t, p.decay + 0.05);
const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = p.hp;
const bp = actx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = p.bp; bp.Q.value = p.q;
const ng = envGain(t, p.amp * vel, p.decay, 0.0008);
n.connect(hp); hp.connect(bp); bp.connect(ng); ng.connect(masterGain);
n.stop(t + p.decay + 0.1);
}
function playClap(t, vel){
const p = CFG.PATCH.clap;
p.offsets.forEach((off, i) => {
const tt = t + off;
const n = makeNoise(tt, 0.06);
const bp = actx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = p.bp; bp.Q.value = p.q;
const decay = i === p.offsets.length - 1 ? p.decay : 0.03;
const ng = envGain(tt, (i === 2 ? 1 : 0.6) * p.amp * vel, decay, 0.0008);
n.connect(bp); bp.connect(ng); ng.connect(masterGain);
n.stop(tt + decay + 0.08);
});
}
function playTom(t, vel){
const p = CFG.PATCH.tom;
const osc = actx.createOscillator(); osc.type = 'sine';
osc.frequency.setValueAtTime(p.f0, t);
osc.frequency.exponentialRampToValueAtTime(p.f1, t + p.fEnv);
const g = actx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(p.amp * vel, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.0001, t + p.aDec);
osc.connect(g); g.connect(masterGain);
osc.start(t); osc.stop(t + p.aDec + 0.05);
}
const VOICES = { kick: playKick, snare: (t,v,a)=>playSnare(t,v,a), hatc: (t,v)=>playHat(t,v,false),
hato: (t,v)=>playHat(t,v,true), clap: playClap, tom: playTom };
/* ============================================================
4. STATE + PERSISTENCE (localStorage)
============================================================ */
let state = loadState();
function loadState(){
try {
const raw = localStorage.getItem(CFG.STORAGE_KEY);
if (!raw) return structuredClone(DEFAULT_STATE);
const s = JSON.parse(raw);
const base = structuredClone(DEFAULT_STATE);
base.bpm = clamp(Number(s.bpm) || base.bpm, CFG.BPM_MIN, CFG.BPM_MAX);
base.swing = clamp(Number(s.swing) || 0, 0, 60);
base.master = clamp(Number(s.master) == null ? base.master : Number(s.master), 0, 1);
TRACKS.forEach(tr => {
base.vols[tr.id] = clamp(Number(s.vols?.[tr.id]) == null ? base.vols[tr.id] : Number(s.vols[tr.id]), 0, 1);
base.mutes[tr.id] = !!s.mutes?.[tr.id];
base.solos[tr.id] = !!s.solos?.[tr.id];
if (Array.isArray(s.pattern?.[tr.id])) {
base.pattern[tr.id] = s.pattern[tr.id].slice(0, CFG.STEPS)
.map(v => v === 1 || v === 2 ? v : 0);
while (base.pattern[tr.id].length < CFG.STEPS) base.pattern[tr.id].push(0);
}
});
return base;
} catch(e){ return structuredClone(DEFAULT_STATE); }
}
let saveTimer = null;
function saveState(){
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(state)); } catch(e){}
}, CFG.SAVE_DEBOUNCE_MS);
}
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
/* ============================================================
5. SCHEDULER — the heart. Lookahead scheduling pattern:
a 25ms setInterval only *decides* what to schedule; the
Web Audio clock decides *when*. Step times are derived
from an absolute step counter:
t = nextNoteTime + (step % 16) * stepDur + swingOffset(step)
so there is no accumulated drift. Swing is applied in
this time math — never by nudging the playhead.
============================================================ */
const seq = {
playing: false,
currentStep: 0, // absolute step counter (monotonic)
nextNoteTime: 0, // absolute audio-clock time of the next grid step
timerId: null,
stepQueue: [], // scheduled steps {step, time} — playhead source
_lastStep: -1, // last step whose time has passed (playhead)
_lastStepTime: 0
};
const stepDur = () => 60 / state.bpm / 4; // 16th-note duration in seconds
// Swing: off-beat 16ths (odd steps) are delayed by swing% of one step.
const swingDelay = (step) => (step % 2 === 1) ? (state.swing / 100) * stepDur() : 0;
const stepTime = (step, base) => base + (step % CFG.STEPS) * stepDur() + swingDelay(step);
window.__sched = { events: [], lastDelta: 0 }; // instrumentation for testing
function scheduleStep(step, t){
const trackStep = step % CFG.STEPS;
const anySolo = TRACKS.some(tr => state.solos[tr.id]);
TRACKS.forEach(tr => {
const lvl = state.pattern[tr.id][trackStep];
if (!lvl) return;
if (anySolo ? !state.solos[tr.id] : state.mutes[tr.id]) return;
const vel = velocityFor(lvl) * state.vols[tr.id];
if (vel <= 0.001) return;
VOICES[tr.id](t, vel, lvl === 2);
window.__sched.events.push({ step, track: tr.id, time: t, vel });
});
}
// If the browser suspends the context while the tab is hidden, resume on return
document.addEventListener('visibilitychange', () => {
if (!document.hidden && actx && actx.state === 'suspended') actx.resume();
});
function schedulerTick(){
if (!seq.playing) return;
// In background tabs the timer is throttled (~1/s) but the audio clock
// keeps running — widen the lookahead so one tick always covers the gap.
const lookahead = document.hidden ? 2.0 : CFG.LOOKAHEAD_S;
const horizon = actx.currentTime + lookahead;
while (seq.nextNoteTime < horizon) {
// nextNoteTime = absolute time of the current BAR start.
// t is derived from it — no accumulation, no drift.
const t = stepTime(seq.currentStep, seq.nextNoteTime);
scheduleStep(seq.currentStep, t);
seq.stepQueue.push({ step: seq.currentStep, time: t }); // for the playhead
const ev = window.__sched.events[window.__sched.events.length - 1];
if (ev) {
// uniformity instrumentation: delta between consecutive scheduled step times
if (seq._schedPrev !== undefined) {
window.__sched.lastDelta = t - seq._schedPrev;
window.__sched.deltas = window.__sched.deltas || [];
window.__sched.deltas.push(window.__sched.lastDelta);
if (window.__sched.deltas.length > 200) window.__sched.deltas.shift();
}
seq._schedPrev = t;
}
seq.currentStep++;
if (seq.currentStep % CFG.STEPS === 0) seq.nextNoteTime += CFG.STEPS * stepDur(); // advance the bar
}
}
function startPlayback(){
initAudio();
if (actx.state === 'suspended') actx.resume();
if (seq.playing) return;
seq.playing = true;
seq.currentStep = 0; // Play always restarts cleanly on step 1
seq.nextNoteTime = actx.currentTime + 0.08; // small grace period
seq._lastStep = -1; seq._lastStepTime = 0;
seq.stepQueue = [];
seq._schedPrev = undefined;
window.__sched.events = []; window.__sched.deltas = [];
seq.timerId = setInterval(schedulerTick, CFG.SCHEDULER_INTERVAL_MS);
schedulerTick(); // prime immediately
updateTransportUI();
setStatus('LIVE');
}
function stopPlayback(){
if (!seq.playing) return;
seq.playing = false;
clearInterval(seq.timerId); seq.timerId = null;
// Kill everything already scheduled — stop is immediate, no tail:
// swap out the master gain node so all pending sources dangle.
if (actx) {
try { masterGain.disconnect(); } catch(e){}
masterGain = actx.createGain();
masterGain.gain.value = state.master;
masterGain.connect(comp);
}
updateTransportUI();
setStatus('STOPPED');
clearPlayhead();
}
/* ============================================================
6. PLAYHEAD — driven from the audio clock, not the timer.
rAF only reads actx.currentTime and finds which scheduled
step's time we have crossed.
============================================================ */
function clearPlayhead(){
document.querySelectorAll('.cell.playhead').forEach(el => el.classList.remove('playhead'));
document.querySelectorAll('.stepleds i.on').forEach(el => el.classList.remove('on'));
seq._lastStep = -1;
}
function rAFPlayhead(){
requestAnimationFrame(rAFPlayhead);
if (!seq.playing || !actx) return;
const now = actx.currentTime;
// advance through every scheduled step whose time has passed —
// including silent ones, so the playhead never stalls
let last = seq._lastStep;
while (seq.stepQueue.length && seq.stepQueue[0].time <= now) {
last = seq.stepQueue[0].step;
seq.stepQueue.shift();
}
if (last < 0 || last === seq._lastStep) return;
seq._lastStep = last;
const col = last % CFG.STEPS;
document.querySelectorAll('.cell.playhead').forEach(el => el.classList.remove('playhead'));
document.querySelectorAll(`.cell[data-col="${col}"]`).forEach(el => el.classList.add('playhead'));
document.querySelectorAll('.stepleds i').forEach((el, i) => {
el.classList.toggle('on', i === col);
el.classList.toggle('beat', i === col && col % 4 === 0);
});
}
requestAnimationFrame(rAFPlayhead);
/* ============================================================
7. UI BUILD
============================================================ */
const gridEl = document.getElementById('grid');
const cellEls = {}; // cellEls[trackId][step]
function buildGrid(){
gridEl.innerHTML = '';
// corner: step LEDs
const corner = document.createElement('div');
corner.className = 'corner';
const leds = document.createElement('div'); leds.className = 'stepleds';
for (let s = 0; s < CFG.STEPS; s++){ const i = document.createElement('i'); leds.appendChild(i); }
corner.appendChild(leds);
gridEl.appendChild(corner);
// step number headers
for (let s = 0; s < CFG.STEPS; s++){
const h = document.createElement('div');
h.className = 'cellhead' + (s % 4 === 0 ? ' beat' : '');
h.textContent = s + 1;
gridEl.appendChild(h);
}
// tracks
TRACKS.forEach((tr, ti) => {
const row = document.createElement('div');
row.className = 'track'; row.dataset.track = tr.id; row.style.setProperty('--row', ti + 2);
const head = document.createElement('div');
head.className = 'trackhead';
head.style.setProperty('--row', ti + 2);
head.innerHTML = `
<div class="tname"><span class="ticon" style="color:${tr.color}">${tr.icon}</span> ${tr.name}</div>
<div class="tsub">
<button class="tmute" title="Mute">M</button>
<button class="tsolo" title="Solo">S</button>
</div>
<input class="vol" type="range" min="0" max="100" value="${Math.round(state.vols[tr.id]*100)}" title="${tr.name} volume">`;
row.appendChild(head);
cellEls[tr.id] = [];
for (let s = 0; s < CFG.STEPS; s++){
const c = document.createElement('div');
c.className = 'cell' + (s % 4 === 0 ? ' beat' : '');
c.dataset.track = tr.id; c.dataset.step = s; c.dataset.col = s;
c.style.setProperty('--c-hi', tr.hi);
c.style.setProperty('--c-lo', tr.lo);
c.style.setProperty('--c-edge', tr.edge);
c.style.setProperty('--c-glow', tr.glow);
c.style.setProperty('--c-dim', tr.dim);
row.appendChild(c);
cellEls[tr.id][s] = c;
}
gridEl.appendChild(row);
// track head controls
const muteBtn = head.querySelector('.tmute');
const soloBtn = head.querySelector('.tsolo');
const vol = head.querySelector('.vol');
muteBtn.addEventListener('click', e => { e.stopPropagation(); state.mutes[tr.id] = !state.mutes[tr.id]; refreshTrackUI(tr.id); saveState(); });
soloBtn.addEventListener('click', e => { e.stopPropagation(); state.solos[tr.id] = !state.solos[tr.id]; refreshTrackUI(tr.id); saveState(); });
vol.addEventListener('input', () => { state.vols[tr.id] = vol.value / 100; saveState(); });
});
refreshAllUI();
}
function refreshCell(trId, s){
const el = cellEls[trId][s];
const lvl = state.pattern[trId][s];
el.classList.toggle('active', lvl > 0);
el.classList.toggle('accent', lvl === 2);
}
function refreshTrackUI(trId){
for (let s = 0; s < CFG.STEPS; s++) refreshCell(trId, s);
const row = gridEl.querySelector(`.track[data-track="${trId}"]`);
row.classList.toggle('muted', state.mutes[trId]);
row.classList.toggle('dimmed', TRACKS.some(t => t.id !== trId && state.solos[t.id]));
const head = row.querySelector('.trackhead');
head.querySelector('.tmute').classList.toggle('on', state.mutes[trId]);
head.querySelector('.tsolo').classList.toggle('on', state.solos[trId]);
head.querySelector('.vol').value = Math.round(state.vols[trId] * 100);
}
function refreshAllUI(){ TRACKS.forEach(t => refreshTrackUI(t.id)); }
/* ---------------- grid interaction: click / drag / right-click ---------------- */
let painting = false, paintMode = 0; // 0 = off, 1 = on, 2 = accent
function cellFromEvent(e){
const el = e.target.closest('.cell');
return el ? { trId: el.dataset.track, s: +el.dataset.step, el } : null;
}
function applyPaint(trId, s, mode){
const cur = state.pattern[trId][s];
if (mode === 2){ state.pattern[trId][s] = cur === 2 ? 1 : 2; }
else if (mode === 1){ state.pattern[trId][s] = cur === 1 ? 2 : 1; } // click cycles on→accent
else { state.pattern[trId][s] = 0; }
refreshCell(trId, s);
}
gridEl.addEventListener('pointerdown', e => {
const c = cellFromEvent(e);
if (!c) return;
initAudio();
if (e.button === 2){ // right-click: accent toggle (no paint drag)
e.preventDefault();
applyPaint(c.trId, c.s, 2); saveState(); return;
}
if (e.button !== 0) return;
painting = true;
const cur = state.pattern[c.trId][c.s];
paintMode = cur === 1 ? 2 : (cur === 2 ? 0 : 1); // decide the cycle step this press starts on
applyPaint(c.trId, c.s, paintMode);
c.el.setPointerCapture?.(e.pointerId);
saveState();
});
gridEl.addEventListener('pointermove', e => {
if (!painting) return;
const c = cellFromEvent(e);
if (!c) return;
if (state.pattern[c.trId][c.s] !== (paintMode === 0 ? 0 : paintMode)) applyPaint(c.trId, c.s, paintMode);
saveState();
});
window.addEventListener('pointerup', () => { painting = false; });
gridEl.addEventListener('contextmenu', e => e.preventDefault());
/* ============================================================
8. CONTROLS — knobs (drag), sliders, transport, buttons
============================================================ */
function makeKnob(el, { min, max, value, onChange }){
const ptr = el.querySelector('.ptr');
function set(v, fire){
v = clamp(Math.round(v), min, max);
el.setAttribute('aria-valuenow', v);
const frac = (v - min) / (max - min);
ptr.style.setProperty('--a', (-135 + frac * 270) + 'deg');
if (fire) onChange(v);
return v;
}
el._set = set;
let dragging = false, startY = 0, startV = value;
el.addEventListener('pointerdown', e => {
dragging = true; startY = e.clientY; startV = Number(el.getAttribute('aria-valuenow')) || value;
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
el.addEventListener('pointermove', e => {
if (!dragging) return;
const dy = startY - e.clientY;
set(startV + (dy / 150) * (max - min), true);
});
el.addEventListener('pointerup', () => dragging = false);
el.addEventListener('wheel', e => {
e.preventDefault();
set(Number(el.getAttribute('aria-valuenow')) + (e.deltaY < 0 ? 1 : -1) * (max > 100 ? 1 : 1), true);
}, { passive: false });
el.addEventListener('dblclick', () => set(value, true));
set(value, false);
return el;
}
const bpmVal = document.getElementById('bpmVal');
const bpmRead = document.getElementById('bpmRead');
const swingVal = document.getElementById('swingVal');
const masterVal = document.getElementById('masterVal');
function setBpm(v){
state.bpm = clamp(Math.round(v), CFG.BPM_MIN, CFG.BPM_MAX);
bpmVal.textContent = state.bpm + ' BPM';
bpmRead.innerHTML = state.bpm + '<small>BPM</small>';
knobBpm._set(state.bpm, false);
saveState();
}
function setSwing(v){
state.swing = clamp(Math.round(v), 0, 60);
swingVal.textContent = state.swing + '%';
knobSwing._set(state.swing, false);
saveState();
}
function setMaster(v){
state.master = clamp(v, 0, 1);
masterVal.textContent = Math.round(state.master * 100) + '%';
knobMaster._set(Math.round(state.master * 100), false);
if (actx && masterGain) masterGain.gain.setTargetAtTime(state.master, actx.currentTime, 0.02);
saveState();
}
const knobBpm = makeKnob(document.getElementById('knobBpm'),
{ min: CFG.BPM_MIN, max: CFG.BPM_MAX, value: state.bpm, onChange: v => setBpm(v) });
const knobSwing = makeKnob(document.getElementById('knobSwing'),
{ min: 0, max: 60, value: state.swing, onChange: v => setSwing(v) });
const knobMaster = makeKnob(document.getElementById('knobMaster'),
{ min: 0, max: 100, value: Math.round(state.master * 100), onChange: v => setMaster(v / 100) });
// keyboard: arrow keys on focused knobs
[['knobBpm', 1], ['knobSwing', 1], ['knobMaster', 1]].forEach(([id, step]) => {
const el = document.getElementById(id);
el.addEventListener('keydown', e => {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight'){ e.preventDefault(); el._set(Number(el.getAttribute('aria-valuenow')) + step, true); }
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft'){ e.preventDefault(); el._set(Number(el.getAttribute('aria-valuenow')) - step, true); }
});
});
/* ---------------- transport & buttons ---------------- */
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');
const statusEl = document.getElementById('status');
function updateTransportUI(){
btnPlay.classList.toggle('on', seq.playing);
btnPlay.textContent = seq.playing ? '❚❚' : '▶';
}
function setStatus(txt){
statusEl.textContent = txt;
statusEl.classList.toggle('live', txt === 'LIVE');
}
function togglePlay(){ seq.playing ? stopPlayback() : startPlayback(); }
btnPlay.addEventListener('click', togglePlay);
btnStop.addEventListener('click', stopPlayback);
window.addEventListener('keydown', e => {
if (e.code === 'Space' && !/INPUT|BUTTON/.test(document.activeElement?.tagName || '')){
e.preventDefault(); togglePlay();
}
});
document.getElementById('btnClear').addEventListener('click', () => {
TRACKS.forEach(t => { state.pattern[t.id] = new Array(CFG.STEPS).fill(0); refreshTrackUI(t.id); });
saveState();
});
document.getElementById('btnRandom').addEventListener('click', () => {
// musical random: kick on beats, snares on backbeats, hats busy
const dens = { kick: 0.42, snare: 0.18, hatc: 0.62, hato: 0.12, clap: 0.10, tom: 0.15 };
TRACKS.forEach(tr => {
for (let s = 0; s < CFG.STEPS; s++){
let on = Math.random() < dens[tr.id];
if (tr.id === 'kick' && s % 4 === 0) on = Math.random() < 0.85;
if (tr.id === 'snare' && s % 4 === 2) on = Math.random() < 0.8;
const acc = on && Math.random() < 0.22 ? 2 : (on ? 1 : 0);
state.pattern[tr.id][s] = acc;
}
refreshTrackUI(tr.id);
});
saveState();
});
/* ============================================================
9. BOOT
============================================================ */
buildGrid();
// knobs first (setters reference them), then apply persisted values
setBpm(state.bpm); setSwing(state.swing); setMaster(state.master);
setStatus('STOPPED');
</script>
</body>
</html>