← Mike Higgins Home
Custom Code Experiment •

16 Step Sequencer

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>SEQ-16</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Instrument+Sans:wght@400;500&display=swap');

* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --black: #000;
  --white: #fff;
  --dim: rgba(255,255,255,0.12);
  --mid: rgba(255,255,255,0.35);
  --mono: 'DM Mono', monospace;
  --sans: 'Instrument Sans', sans-serif;
}

html, body {
  width: 100%; height: 100%;
  background: var(--black);
  color: var(--white);
  font-family: var(--sans);
  overflow: hidden;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}

#app {
  display: grid;
  grid-template-rows: auto 1fr auto;
  height: 100vh;
  padding: 14px 20px 12px;
  gap: 10px;
  max-width: 960px;
  margin: 0 auto;
}

/* ── TOP BAR ── */
#topbar {
  display: flex;
  align-items: center;
  gap: 20px;
  flex-wrap: wrap;
}

.tag {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  color: rgba(255,255,255,0.3);
  text-transform: uppercase;
}

.transport {
  display: flex;
  gap: 6px;
}

.btn {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--mid);
  background: none;
  border: 1px solid rgba(255,255,255,0.14);
  padding: 5px 14px;
  cursor: pointer;
  border-radius: 2px;
  transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.btn:hover { color: var(--white); border-color: rgba(255,255,255,0.4); }
.btn.active { color: var(--black); background: var(--white); border-color: var(--white); }

.knob-row {
  display: flex;
  align-items: center;
  gap: 18px;
  margin-left: auto;
}
.knob-item {
  display: flex;
  align-items: center;
  gap: 8px;
}
.knob-item label {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.28);
}
.knob-item input[type=range] {
  -webkit-appearance: none;
  width: 80px; height: 1px;
  background: rgba(255,255,255,0.16);
  outline: none;
  cursor: pointer;
}
.knob-item input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--white);
  border: none;
}
.knob-item input[type=range]::-moz-range-thumb {
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--white);
  border: none;
}
.knob-val {
  font-family: var(--mono);
  font-size: 10px;
  color: var(--white);
  min-width: 28px;
}

/* ── GRID ── */
#grid-wrap {
  display: flex;
  flex-direction: column;
  min-height: 0;         /* allow shrink inside grid row */
  overflow: hidden;
}

/* sticky beat header — doesn't scroll */
.beat-header {
  display: grid;
  grid-template-columns: 46px 1fr;
  gap: 8px;
  margin-bottom: 4px;
  flex-shrink: 0;
}
.beat-nums {
  display: grid;
  grid-template-columns: repeat(16, 1fr);
  gap: 3px;
}
.beat-num {
  font-family: var(--mono);
  font-size: 8px;
  text-align: center;
  color: rgba(255,255,255,0.13);
}
.beat-num.b1 { color: rgba(255,255,255,0.38); }
.beat-num:nth-child(5),
.beat-num:nth-child(9),
.beat-num:nth-child(13) { margin-left: 4px; }

/* scrollable track area */
#tracks {
  overflow-y: auto;
  overflow-x: hidden;
  flex: 1;
  min-height: 0;
  /* hide scrollbar but keep functionality */
  scrollbar-width: none;
}
#tracks::-webkit-scrollbar { display: none; }

.track-row {
  display: grid;
  grid-template-columns: 46px 1fr;
  align-items: center;
  gap: 8px;
  padding: 2px 0;
  border-bottom: 1px solid rgba(255,255,255,0.03);
}
.track-row:last-child { border-bottom: none; }

.track-name {
  font-family: var(--mono);
  font-size: 8px;
  letter-spacing: 0.05em;
  color: rgba(255,255,255,0.22);
  text-transform: uppercase;
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  padding-right: 1px;
  transition: color 0.15s;
}
.track-row.has-on .track-name { color: rgba(255,255,255,0.6); }

.steps {
  display: grid;
  grid-template-columns: repeat(16, 1fr);
  gap: 3px;
}
.step-cell:nth-child(5),
.step-cell:nth-child(9),
.step-cell:nth-child(13) { margin-left: 4px; }

.step-cell {
  /* fixed small height instead of aspect-ratio so rows stay compact */
  height: 22px;
  border-radius: 2px;
  cursor: pointer;
  background: rgba(255,255,255,0.055);
  border: 1px solid rgba(255,255,255,0.07);
  transition: background 0.07s, border-color 0.07s;
}
.step-cell:hover {
  background: rgba(255,255,255,0.14);
  border-color: rgba(255,255,255,0.22);
}
.step-cell.on {
  background: var(--white);
  border-color: var(--white);
}
.step-cell.current {
  outline: 1.5px solid rgba(255,255,255,0.5);
  outline-offset: 1.5px;
}
.step-cell.on.current {
  outline-color: rgba(255,255,255,0.85);
}
@keyframes fp {
  0%   { opacity: 1; }
  50%  { opacity: 0.4; }
  100% { opacity: 1; }
}
.step-cell.flash { animation: fp 0.11s ease-out; }

/* ── BOTTOM BAR ── */
#bottombar {
  display: flex;
  align-items: center;
  gap: 16px;
  border-top: 1px solid rgba(255,255,255,0.06);
  padding-top: 10px;
}

.step-readout {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.1em;
  color: rgba(255,255,255,0.3);
  text-transform: uppercase;
  min-width: 56px;
}
.step-readout span { color: var(--white); }

.beat-dots {
  display: flex;
  gap: 5px;
  align-items: center;
}
.beat-dot {
  width: 4px; height: 4px;
  border-radius: 50%;
  background: rgba(255,255,255,0.12);
  transition: background 0.04s;
}
.beat-dot.lit { background: var(--white); }

.audio-notice {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.12em;
  color: rgba(255,255,255,0.22);
  text-transform: uppercase;
  transition: opacity 0.5s;
}
.audio-notice.gone { opacity: 0; pointer-events: none; }

@media (max-width: 580px) {
  #app { padding: 10px 10px 10px; gap: 8px; }
  .track-row, .beat-header { grid-template-columns: 32px 1fr; gap: 6px; }
  .steps, .beat-nums { gap: 2px; }
  .step-cell { height: 18px; }
  .step-cell:nth-child(5),
  .step-cell:nth-child(9),
  .step-cell:nth-child(13),
  .beat-num:nth-child(5),
  .beat-num:nth-child(9),
  .beat-num:nth-child(13) { margin-left: 3px; }
  .knob-item input[type=range] { width: 55px; }
  .knob-row { gap: 10px; }
}
</style>
</head>
<body>
<div id="app">

  <div id="topbar">
    <span class="tag">SEQ&#8202;/&#8202;16</span>
    <div class="transport">
      <button class="btn" id="playBtn">Play</button>
      <button class="btn" id="clearBtn">Clear</button>
    </div>
    <div class="knob-row">
      <div class="knob-item">
        <label>BPM</label>
        <input type="range" id="bpmSlider" min="60" max="200" value="120">
        <span class="knob-val" id="bpmVal">120</span>
      </div>
      <div class="knob-item">
        <label>Swing</label>
        <input type="range" id="swingSlider" min="0" max="75" value="0">
      </div>
    </div>
  </div>

  <div id="grid-wrap">
    <div class="beat-header">
      <div></div>
      <div class="beat-nums" id="beatNums"></div>
    </div>
    <div id="tracks"></div>
  </div>

  <div id="bottombar">
    <div class="step-readout">Step <span id="stepDisp">—</span></div>
    <div class="beat-dots" id="beatDots"></div>
    <div class="audio-notice" id="audioNotice">click to activate audio</div>
  </div>

</div>
<script>
const TRACKS = [
  { name:'Kick',   color:'#c0392b' },
  { name:'Sub',    color:'#e74c3c' },
  { name:'Tom',    color:'#e67e22' },
  { name:'HH Cl', color:'#f39c12' },
  { name:'Bell',   color:'#f1c40f' },
  { name:'Snare',  color:'#2ecc71' },
  { name:'Rim',    color:'#27ae60' },
  { name:'Chord',  color:'#1abc9c' },
  { name:'Pad',    color:'#16a085' },
  { name:'Stab',   color:'#3498db' },
  { name:'Pulse',  color:'#2980b9' },
  { name:'Sweep',  color:'#9b59b6' },
  { name:'Vibr',   color:'#8e44ad' },
  { name:'HH Op', color:'#e91e8c' },
  { name:'Clap',   color:'#c2185b' },
  { name:'Arp',    color:'#00bcd4' },
];
const NUM_STEPS = 16;
const grid = TRACKS.map(() => new Array(NUM_STEPS).fill(false));
const cells = [];

// Beat header
const beatNumsEl = document.getElementById('beatNums');
for (let s = 0; s < 16; s++) {
  const d = document.createElement('div');
  d.className = 'beat-num' + (s % 4 === 0 ? ' b1' : '');
  d.textContent = s % 4 === 0 ? (s/4+1) : '';
  beatNumsEl.appendChild(d);
}

// Build tracks
const tracksEl = document.getElementById('tracks');
TRACKS.forEach((def, ti) => {
  cells[ti] = [];
  const row = document.createElement('div');
  row.className = 'track-row';

  const lbl = document.createElement('div');
  lbl.className = 'track-name';
  lbl.textContent = def.name;

  const stepsDiv = document.createElement('div');
  stepsDiv.className = 'steps';

  for (let si = 0; si < NUM_STEPS; si++) {
    const cell = document.createElement('div');
    cell.className = 'step-cell';
    cell.addEventListener('pointerdown', e => {
      e.preventDefault();
      unlockAudio();
      grid[ti][si] = !grid[ti][si];
      cell.classList.toggle('on', grid[ti][si]);
      if (grid[ti][si]) tryPlay(def.color);
      row.classList.toggle('has-on', grid[ti].some(Boolean));
    });
    stepsDiv.appendChild(cell);
    cells[ti][si] = cell;
  }

  row.appendChild(lbl);
  row.appendChild(stepsDiv);
  tracksEl.appendChild(row);
});

// Beat dots
const beatDotsEl = document.getElementById('beatDots');
const dotEls = [];
for (let i = 0; i < 16; i++) {
  const d = document.createElement('div');
  d.className = 'beat-dot';
  beatDotsEl.appendChild(d);
  dotEls.push(d);
}
let lastDot = -1;
function setDot(s) {
  if (lastDot >= 0) dotEls[lastDot].classList.remove('lit');
  dotEls[s].classList.add('lit');
  lastDot = s;
}

// Audio
let audioCtx = null, masterNode = null, audioReady = false;
const notice = document.getElementById('audioNotice');

function getCtx() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  return audioCtx;
}
function getMaster() {
  if (masterNode) return masterNode;
  const ctx = getCtx();
  const lim = ctx.createDynamicsCompressor();
  lim.threshold.value=-6; lim.knee.value=3; lim.ratio.value=12;
  lim.attack.value=0.001; lim.release.value=0.1;
  lim.connect(ctx.destination);
  return (masterNode = lim);
}
function unlockAudio() {
  if (audioReady) return;
  const ctx = getCtx();
  if (ctx.state === 'suspended') ctx.resume();
  audioReady = true;
  notice.classList.add('gone');
}
['pointerdown','touchstart','keydown'].forEach(e =>
  document.addEventListener(e, unlockAudio, {once:false, passive:true}));

const soundDefs = {
  '#c0392b':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.frequency.setValueAtTime(120,ctx.currentTime); o.frequency.exponentialRampToValueAtTime(30,ctx.currentTime+0.18); e.gain.setValueAtTime(0.7,ctx.currentTime); e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.25); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.3); },
  '#e74c3c':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sawtooth'; o.frequency.setValueAtTime(200,ctx.currentTime); o.frequency.exponentialRampToValueAtTime(50,ctx.currentTime+0.12); e.gain.setValueAtTime(0.5,ctx.currentTime); e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.18); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.2); },
  '#e67e22':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='triangle';o.frequency.value=392; e.gain.setValueAtTime(0.5,ctx.currentTime); e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.5); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.55); },
  '#f39c12':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='triangle';o.frequency.value=440; e.gain.setValueAtTime(0.45,ctx.currentTime); e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.45); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.5); },
  '#f1c40f':(ctx,out)=>{ [523.25,1046.5].forEach((f,i)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sine';o.frequency.value=f; e.gain.setValueAtTime(i===0?0.4:0.15,ctx.currentTime); e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.8); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.85); }); },
  '#2ecc71':(ctx,out)=>{ const buf=ctx.createBuffer(1,ctx.sampleRate*0.4,ctx.sampleRate),d=buf.getChannelData(0); for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*(1-i/d.length); const src=ctx.createBufferSource();src.buffer=buf; const f=ctx.createBiquadFilter();f.type='bandpass';f.frequency.setValueAtTime(400,ctx.currentTime);f.frequency.exponentialRampToValueAtTime(2000,ctx.currentTime+0.4);f.Q.value=3; const e=ctx.createGain();e.gain.setValueAtTime(0.3,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.4); src.connect(f);f.connect(e);e.connect(out);src.start();src.stop(ctx.currentTime+0.45); },
  '#27ae60':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sine';o.frequency.setValueAtTime(220,ctx.currentTime);o.frequency.exponentialRampToValueAtTime(660,ctx.currentTime+0.3); e.gain.setValueAtTime(0.35,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.35); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.4); },
  '#1abc9c':(ctx,out)=>{ [523.25,659.25,783.99].forEach((f,i)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sine';o.frequency.value=f; e.gain.setValueAtTime(0.18,ctx.currentTime+i*0.04);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.7); o.connect(e);e.connect(out);o.start(ctx.currentTime+i*0.04);o.stop(ctx.currentTime+0.75); }); },
  '#16a085':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sine';o.frequency.value=349.23; e.gain.setValueAtTime(0.4,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.6); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.65); },
  '#3498db':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(),f=ctx.createBiquadFilter(); f.type='lowpass';f.frequency.value=800;f.Q.value=8; o.type='sawtooth';o.frequency.value=110; e.gain.setValueAtTime(0.5,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.4); o.connect(f);f.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.45); },
  '#2980b9':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='square';o.frequency.value=130.81; e.gain.setValueAtTime(0.25,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.35); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.4); },
  '#9b59b6':(ctx,out)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='sine';o.frequency.setValueAtTime(880,ctx.currentTime);o.frequency.exponentialRampToValueAtTime(110,ctx.currentTime+0.5); e.gain.setValueAtTime(0.35,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.55); o.connect(e);e.connect(out);o.start();o.stop(ctx.currentTime+0.6); },
  '#8e44ad':(ctx,out)=>{ const car=ctx.createOscillator(),lfo=ctx.createOscillator(),lg=ctx.createGain(),e=ctx.createGain(); lfo.frequency.value=8;lg.gain.value=60;car.type='sine';car.frequency.value=440; lfo.connect(lg);lg.connect(car.frequency);e.gain.setValueAtTime(0.3,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.5); car.connect(e);e.connect(out);lfo.start();car.start();lfo.stop(ctx.currentTime+0.55);car.stop(ctx.currentTime+0.55); },
  '#e91e8c':(ctx,out)=>{ const buf=ctx.createBuffer(1,ctx.sampleRate*0.08,ctx.sampleRate),d=buf.getChannelData(0); for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*(1-i/d.length); const src=ctx.createBufferSource();src.buffer=buf; const f=ctx.createBiquadFilter();f.type='highpass';f.frequency.value=6000; const e=ctx.createGain();e.gain.setValueAtTime(0.5,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.08); src.connect(f);f.connect(e);e.connect(out);src.start();src.stop(ctx.currentTime+0.1); },
  '#c2185b':(ctx,out)=>{ const buf=ctx.createBuffer(1,ctx.sampleRate*0.05,ctx.sampleRate),d=buf.getChannelData(0); for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.exp(-i/200); const src=ctx.createBufferSource();src.buffer=buf; const e=ctx.createGain();e.gain.setValueAtTime(0.6,ctx.currentTime);e.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.06); src.connect(e);e.connect(out);src.start();src.stop(ctx.currentTime+0.08); },
  '#00bcd4':(ctx,out)=>{ [523.25,659.25,783.99,1046.5].forEach((f,i)=>{ const o=ctx.createOscillator(),e=ctx.createGain(); o.type='triangle';o.frequency.value=f; const t=ctx.currentTime+i*0.06;e.gain.setValueAtTime(0.22,t);e.gain.exponentialRampToValueAtTime(0.001,t+0.15); o.connect(e);e.connect(out);o.start(t);o.stop(t+0.2); }); },
};

const cooldowns = new Map();
function tryPlay(color) {
  if (!audioReady) return;
  const now = Date.now();
  if (now - (cooldowns.get(color)||0) < 50) return;
  cooldowns.set(color, now);
  const fn = soundDefs[color];
  if (!fn) return;
  try { const c=getCtx(); if(c.state==='suspended')c.resume(); fn(c, getMaster()); } catch(e) {}
}

// Sequencer
let playing = false, tempo = 120, swing = 0;
let seqTimer = null, schStep = -1, schTime = 0, dispStep = -1;
const LOOK = 0.1, TICK = 25;

function stepDur() { return (60/tempo)/4; }
function swingOff(si) { return si%2===1 ? (swing/100)*stepDur()*0.5 : 0; }

function scheduler() {
  const ctx = getCtx();
  while (schTime < ctx.currentTime + LOOK) {
    schStep = (schStep + 1) % NUM_STEPS;
    const t = schTime + swingOff(schStep);
    const si = schStep;
    TRACKS.forEach((def, ti) => {
      if (!grid[ti][si]) return;
      const fn = soundDefs[def.color];
      if (!fn) return;
      const delay = Math.max(0, (t - ctx.currentTime) * 1000);
      setTimeout(() => {
        if (!playing) return;
        fn(ctx, getMaster());
        cells[ti][si].classList.add('flash');
        setTimeout(() => cells[ti][si].classList.remove('flash'), 110);
      }, delay);
    });
    const delay = Math.max(0, (t - ctx.currentTime) * 1000);
    setTimeout(() => {
      if (!playing) return;
      if (dispStep >= 0) TRACKS.forEach((_, ti) => cells[ti][dispStep].classList.remove('current'));
      TRACKS.forEach((_, ti) => cells[ti][si].classList.add('current'));
      setDot(si);
      document.getElementById('stepDisp').textContent = si + 1;
      dispStep = si;
    }, delay);
    schTime += stepDur();
  }
}

function startSeq() {
  const ctx = getCtx();
  if (ctx.state === 'suspended') ctx.resume();
  schStep = -1; schTime = ctx.currentTime + 0.03;
  seqTimer = setInterval(scheduler, TICK);
}
function stopSeq() {
  clearInterval(seqTimer);
  if (dispStep >= 0) TRACKS.forEach((_, ti) => cells[ti][dispStep].classList.remove('current'));
  dispStep = -1; lastDot = -1;
  dotEls.forEach(d => d.classList.remove('lit'));
  document.getElementById('stepDisp').textContent = '—';
}

document.getElementById('playBtn').addEventListener('click', () => {
  unlockAudio();
  playing = !playing;
  const btn = document.getElementById('playBtn');
  btn.textContent = playing ? 'Stop' : 'Play';
  btn.classList.toggle('active', playing);
  playing ? startSeq() : stopSeq();
});

document.getElementById('clearBtn').addEventListener('click', () => {
  TRACKS.forEach((_, ti) => {
    grid[ti].fill(false);
    cells[ti].forEach(c => c.classList.remove('on'));
    tracksEl.children[ti].classList.remove('has-on');
  });
});

const bpmSlider = document.getElementById('bpmSlider');
bpmSlider.addEventListener('input', () => {
  tempo = parseInt(bpmSlider.value);
  document.getElementById('bpmVal').textContent = tempo;
});

document.getElementById('swingSlider').addEventListener('input', function() {
  swing = parseInt(this.value);
});

document.addEventListener('keydown', e => {
  if (e.code === 'Space' && e.target === document.body) {
    e.preventDefault();
    document.getElementById('playBtn').click();
  }
});
</script>
</body>
</html>