← Sequencer results

Sequencer

Qwen 3.6 27b MTP · full

Initial view of Sequencer

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 28,900 bytes · SHA-256 64566f2c906a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STEP SEQ-16 — Drum Sequencer</title>
<style>
/* ============================================
   CSS — Dark Hardware Drum Machine Aesthetic
   ============================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  background: #12122a;
  color: #d0d0e0;
  font-family: 'Courier New', monospace;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  min-height: 100vh;
  padding: 16px;
  user-select: none;
}

#app {
  background: linear-gradient(145deg, #1a1a3e, #12122a);
  border-radius: 14px;
  padding: 28px 32px;
  box-shadow: 0 0 60px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.04);
  border: 1px solid #2a2a5e;
  max-width: 1160px;
  width: 100%;
}

/* ── Header ── */
.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
  padding-bottom: 16px;
  border-bottom: 2px solid #2a2a5e;
  flex-wrap: wrap;
  gap: 12px;
}

.title {
  font-size: 22px;
  font-weight: bold;
  color: #ff3366;
  text-shadow: 0 0 14px rgba(255,51,102,0.5);
  letter-spacing: 4px;
}

/* ── Transport Controls ── */
.transport {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

.btn {
  background: #2a2a5e;
  color: #d0d0e0;
  border: 1px solid #4a3a8e;
  padding: 9px 20px;
  border-radius: 6px;
  cursor: pointer;
  font-family: inherit;
  font-size: 13px;
  letter-spacing: 1px;
  transition: all 0.15s;
}

.btn:hover { background: #4a3a8e; }
.btn.active {
  background: #ff3366;
  border-color: #ff3366;
  box-shadow: 0 0 16px rgba(255,51,102,0.5);
  color: #fff;
}

/* ── Tempo Display ── */
.tempo-group {
  display: flex;
  align-items: center;
  gap: 8px;
}

.bpm-display {
  background: #0a0a1e;
  color: #ff3366;
  font-size: 24px;
  font-weight: bold;
  padding: 4px 14px;
  border-radius: 5px;
  min-width: 72px;
  text-align: center;
  border: 1px solid #3a3a6e;
  text-shadow: 0 0 10px rgba(255,51,102,0.7);
  font-variant-numeric: tabular-nums;
}

.bpm-label { font-size: 11px; color: #888; }

/* ── Sliders ── */
.slider-group {
  display: flex;
  align-items: center;
  gap: 6px;
}

.slider-group label {
  font-size: 11px;
  color: #999;
  white-space: nowrap;
}

input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 80px;
  height: 6px;
  background: #0a0a1e;
  border-radius: 3px;
  outline: none;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  background: #ff3366;
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 6px rgba(255,51,102,0.4);
}

input[type="range"]::-moz-range-thumb {
  width: 14px;
  height: 14px;
  background: #ff3366;
  border-radius: 50%;
  cursor: pointer;
  border: none;
}

/* ── Sequencer Grid ── */
.sequencer-wrapper {
  overflow-x: auto;
  margin-top: 18px;
  padding-bottom: 4px;
}

.grid-container {
  display: flex;
  gap: 2px;
  min-width: fit-content;
}

.track-row {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
}

.track-label {
  font-size: 10px;
  color: #777;
  text-align: center;
  width: 100%;
  padding-bottom: 2px;
  white-space: nowrap;
  letter-spacing: 1px;
}

.steps-row {
  display: flex;
  gap: 3px;
}

.step-cell {
  width: 50px;
  height: 42px;
  background: #1a1a3e;
  border-radius: 5px;
  cursor: pointer;
  transition: background 0.08s, box-shadow 0.08s;
  position: relative;
  border: 1px solid transparent;
}

.step-cell:hover { border-color: #4a3a8e; }

/* Velocity states */
.step-cell.off { background: #16162e; }
.step-cell.normal {
  background: linear-gradient(135deg, #4a3a8e, #3a2a7e);
  box-shadow: inset 0 0 10px rgba(74,58,142,0.6), 0 0 4px rgba(74,58,142,0.3);
}
.step-cell.accent {
  background: linear-gradient(135deg, #ff3366, #cc2255);
  box-shadow: inset 0 0 14px rgba(255,51,102,0.7), 0 0 8px rgba(255,51,102,0.4);
}

/* Beat grouping */
.step-cell.beat-start { border-left: 2px solid #3a3a6e; }

/* Playhead highlight — LED strip effect */
.step-cell.playhead::after {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 3px;
  background: #fff;
  border-radius: 5px 5px 0 0;
  box-shadow: 0 0 12px rgba(255,255,255,0.8), 0 0 24px rgba(255,255,255,0.3);
}

/* ── Track Controls Row ── */
.track-controls-row {
  display: flex;
  align-items: center;
  gap: 3px;
  margin-top: 4px;
}

.mute-btn {
  width: 22px;
  height: 18px;
  background: #2a2a5e;
  border: none;
  color: #999;
  font-size: 9px;
  cursor: pointer;
  border-radius: 3px;
  padding: 2px;
  letter-spacing: 0.5px;
}

.mute-btn.muted { background: #ff3366; color: #fff; }

.track-vol {
  width: 48px !important;
  height: 4px !important;
}

/* ── Step Numbers ── */
.step-numbers {
  display: flex;
  gap: 3px;
  margin-left: 76px;
  margin-bottom: 5px;
}

.step-number {
  width: 50px;
  text-align: center;
  font-size: 10px;
  color: #444;
}

/* ── Utility Buttons Row ── */
.util-row {
  display: flex;
  gap: 8px;
  margin-top: 16px;
  padding-top: 12px;
  border-top: 1px solid #2a2a5e;
}

/* ── Status Bar ── */
.status-bar {
  margin-top: 14px;
  font-size: 11px;
  color: #555;
  display: flex;
  justify-content: space-between;
}

/* Dimmed track when muted */
.track-row.muted .step-cell { opacity: 0.25; }
.track-row.muted .track-label { color: #3a3a6e; }

/* ── Responsive ── */
@media (max-width: 900px) {
  .step-cell { width: 38px; height: 34px; }
  .step-number { width: 38px; }
}
</style>
</head>
<body>

<div id="app">
  <div class="header">
    <div class="title">⚡ STEP SEQ-16</div>
    <div class="transport">
      <button class="btn" id="playBtn">▶ PLAY</button>
      <button class="btn" id="stopBtn">■ STOP</button>
      <div class="tempo-group">
        <span class="bpm-label">BPM</span>
        <input type="range" id="bpmSlider" min="60" max="200" value="120">
        <div class="bpm-display" id="bpmDisplay">120</div>
      </div>
      <div class="slider-group">
        <label>SWING</label>
        <input type="range" id="swingSlider" min="0" max="60" value="0">
        <span id="swingDisplay" style="font-size:12px;min-width:32px;text-align:right;">0%</span>
      </div>
      <div class="slider-group">
        <label>VOL</label>
        <input type="range" id="masterVolSlider" min="0" max="100" value="80">
      </div>
    </div>
  </div>

  <!-- Step numbers -->
  <div class="step-numbers" id="stepNumbers"></div>

  <!-- Sequencer Grid -->
  <div class="sequencer-wrapper">
    <div class="grid-container" id="gridContainer"></div>
  </div>

  <!-- Utility buttons -->
  <div class="util-row">
    <button class="btn" id="clearBtn">CLEAR</button>
    <button class="btn" id="randomBtn">RANDOM</button>
    <span style="font-size:11px;color:#555;margin-left:auto;">Left-click = cycle velocity · Right-click = accent · Drag to paint/erase</span>
  </div>

  <!-- Status -->
  <div class="status-bar">
    <span id="statusText">Ready — press Play or Space</span>
    <span id="debugInfo"></span>
  </div>
</div>

<script>
/* ================================================================
   TUNABLE CONSTANTS
   ================================================================ */
const CONFIG = {
  STEPS: 16,
  LOOKAHEAD_INTERVAL_MS: 25,     // Scheduler tick interval (ms)
  SCHEDULE_AHEAD_SEC: 0.12,      // How far ahead to schedule audio (seconds)
  BPM_MIN: 60,
  BPM_MAX: 200,
  DEFAULT_BPM: 120,
  DEFAULT_SWING: 0,
  MASTER_VOL_DEFAULT: 80,

  // Instrument synthesis parameters
  KICK:       { freqStart: 150, freqEnd: 45, decay: 0.35 },
  SNARE:      { noiseDecay: 0.2, toneFreq: 180, toneDecay: 0.12 },
  HIHAT_CLOSED: { filterFreq: 7800, decay: 0.06 },
  HIHAT_OPEN:   { filterFreq: 9500, decay: 0.35 },
  CLAP:       { filterFreq: 2400, burstCount: 5, burstInterval: 0.012, decay: 0.25 },
  TOM:        { freqStart: 200, freqEnd: 75, decay: 0.3 }
};

/* ================================================================
   STATE
   ================================================================ */
const INSTRUMENTS = [
  { name: 'KICK',       icon: '🥁' },
  { name: 'SNARE',      icon: '💥' },
  { name: 'HI-HAT C',   icon: '🔔' },
  { name: 'HI-HAT O',   icon: '🔔' },
  { name: 'CLAP',       icon: '👏' },
  { name: 'TOM',        icon: '🪘' }
];

// pattern[track][step] = velocity: 0=off, 1=normal, 2=accent
let pattern = INSTRUMENTS.map(() => new Array(CONFIG.STEPS).fill(0));
let trackVolumes = INSTRUMENTS.map(() => 80);
let trackMutes   = INSTRUMENTS.map(() => false);

// Scheduler state
let isPlaying       = false;
let currentStep     = 0;    // absolute step counter (never resets during playback)
let nextNoteTime    = 0;
let schedulerTimerId = null;
let audioCtx        = null;
let masterGainNode  = null;
let swingAmount     = CONFIG.DEFAULT_SWING; // 0–60

// Drag painting state
let isDragging = false;
let dragValue  = -1;

/* ================================================================
   AUDIO ENGINE — Instrument Synthesis (Web Audio API)
   ================================================================ */

function initAudio() {
  if (audioCtx) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  masterGainNode = audioCtx.createGain();
  masterGainNode.gain.value = CONFIG.MASTER_VOL_DEFAULT / 100;
  masterGainNode.connect(audioCtx.destination);
}

function resumeAudio() {
  if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}

// ── Kick Drum: sine with fast pitch envelope + amplitude envelope ──
function playKick(time, velocity) {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  const vol = (velocity / 2) * (trackVolumes[0] / 100);

  osc.type = 'sine';
  osc.frequency.setValueAtTime(CONFIG.KICK.freqStart, time);
  osc.frequency.exponentialRampToValueAtTime(CONFIG.KICK.freqEnd, time + 0.08);

  gain.gain.setValueAtTime(vol * 1.3, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + CONFIG.KICK.decay);

  osc.connect(gain).connect(masterGainNode);
  osc.start(time);
  osc.stop(time + CONFIG.KICK.decay + 0.05);
}

// ── Snare: filtered noise burst + tonal body (triangle) ──
function playSnare(time, velocity) {
  const vol = (velocity / 2) * (trackVolumes[1] / 100);
  const brightBoost = velocity === 2 ? 1.4 : 1.0; // accent → brighter

  // Noise component
  const nLen = audioCtx.sampleRate * CONFIG.SNARE.noiseDecay;
  const buf = audioCtx.createBuffer(1, nLen, audioCtx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < nLen; i++) d[i] = Math.random() * 2 - 1;

  const noiseSrc = audioCtx.createBufferSource();
  noiseSrc.buffer = buf;
  const hp = audioCtx.createBiquadFilter();
  hp.type = 'highpass';
  hp.frequency.value = 1000 * brightBoost;
  const nGain = audioCtx.createGain();
  nGain.gain.setValueAtTime(vol * 0.7, time);
  nGain.gain.exponentialRampToValueAtTime(0.001, time + CONFIG.SNARE.noiseDecay);

  noiseSrc.connect(hp).connect(nGain).connect(masterGainNode);
  noiseSrc.start(time);
  noiseSrc.stop(time + CONFIG.SNARE.noiseDecay + 0.05);

  // Tonal body
  const osc = audioCtx.createOscillator();
  const oGain = audioCtx.createGain();
  osc.type = 'triangle';
  osc.frequency.value = CONFIG.SNARE.toneFreq;
  oGain.gain.setValueAtTime(vol * 0.5, time);
  oGain.gain.exponentialRampToValueAtTime(0.001, time + CONFIG.SNARE.toneDecay);

  osc.connect(oGain).connect(masterGainNode);
  osc.start(time);
  osc.stop(time + CONFIG.SNARE.toneDecay + 0.05);
}

// ── Hi-Hat (closed / open): highpass-filtered noise, short vs long decay ──
function playHiHat(time, velocity, isOpen) {
  const cfg = isOpen ? CONFIG.HIHAT_OPEN : CONFIG.HIHAT_CLOSED;
  const tIdx = isOpen ? 3 : 2;
  const vol = (velocity / 2) * (trackVolumes[tIdx] / 100);

  const nLen = audioCtx.sampleRate * cfg.decay * 2;
  const buf = audioCtx.createBuffer(1, nLen, audioCtx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < nLen; i++) d[i] = Math.random() * 2 - 1;

  const src = audioCtx.createBufferSource();
  src.buffer = buf;
  const hp = audioCtx.createBiquadFilter();
  hp.type = 'highpass';
  hp.frequency.value = cfg.filterFreq;
  const g = audioCtx.createGain();
  g.gain.setValueAtTime(vol * 0.45, time);
  g.gain.exponentialRampToValueAtTime(0.001, time + cfg.decay);

  src.connect(hp).connect(g).connect(masterGainNode);
  src.start(time);
  src.stop(time + cfg.decay + 0.05);
}

// ── Clap: several short noise bursts through a bandpass filter ──
function playClap(time, velocity) {
  const vol = (velocity / 2) * (trackVolumes[4] / 100);

  for (let b = 0; b < CONFIG.CLAP.burstCount; b++) {
    const bt = time + b * CONFIG.CLAP.burstInterval;
    const nLen = audioCtx.sampleRate * 0.025;
    const buf = audioCtx.createBuffer(1, nLen, audioCtx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < nLen; i++) d[i] = Math.random() * 2 - 1;

    const src = audioCtx.createBufferSource();
    src.buffer = buf;
    const bp = audioCtx.createBiquadFilter();
    bp.type = 'bandpass';
    bp.frequency.value = CONFIG.CLAP.filterFreq;
    bp.Q.value = 3;
    const g = audioCtx.createGain();
    g.gain.setValueAtTime(vol * 0.5, bt);
    g.gain.exponentialRampToValueAtTime(0.001, bt + CONFIG.CLAP.decay / CONFIG.CLAP.burstCount);

    src.connect(bp).connect(g).connect(masterGainNode);
    src.start(bt);
    src.stop(bt + CONFIG.CLAP.decay + 0.05);
  }
}

// ── Tom: pitched sine with decay, musically related to kick ──
function playTom(time, velocity) {
  const vol = (velocity / 2) * (trackVolumes[5] / 100);

  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(CONFIG.TOM.freqStart, time);
  osc.frequency.exponentialRampToValueAtTime(CONFIG.TOM.freqEnd, time + 0.12);

  gain.gain.setValueAtTime(vol * 1.0, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + CONFIG.TOM.decay);

  osc.connect(gain).connect(masterGainNode);
  osc.start(time);
  osc.stop(time + CONFIG.TOM.decay + 0.05);
}

// Dispatch table: index matches INSTRUMENTS order
const playFn = [playKick, playSnare, (t,v) => playHiHat(t,v,false), (t,v) => playHiHat(t,v,true), playClap, playTom];

/* ================================================================
   SCHEDULER — Lookahead Pattern (sample-accurate timing)
   ================================================================ */

let bpmValue = CONFIG.DEFAULT_BPM;

// Compute the duration between step i and step i+1 with swing.
// Even→odd: shortened by swing compensation, odd→even: lengthened.
// Symmetric so bar duration stays constant regardless of swing amount.
function stepDuration(stepIdx) {
  const base = 60.0 / bpmValue / 4; // quarter-note in seconds
  if (stepIdx % 2 === 0) return base * (1 - swingAmount / 200);
  return base * (1 + swingAmount / 200);
}

// Main scheduler: runs every LOOKAHEAD_INTERVAL_MS, schedules notes ahead.
function scheduler() {
  if (!isPlaying || !audioCtx) return;

  const now = audioCtx.currentTime;

  // Schedule all notes that fall within the lookahead window
  while (nextNoteTime < now + CONFIG.SCHEDULE_AHEAD_SEC) {
    scheduleNote(currentStep, nextNoteTime);

    // Debug: record scheduled step times for verification
    if (!window._scheduledSteps) window._scheduledSteps = [];
    window._scheduledSteps.push({
      step: currentStep % CONFIG.STEPS,
      time: nextNoteTime,
      scheduledAt: now
    });
    if (window._scheduledSteps.length > 200) {
      window._scheduledSteps = window._scheduledSteps.slice(-100);
    }

    // Advance to next step time using incremental calculation from current position
    nextNoteTime += stepDuration(currentStep % CONFIG.STEPS);
    currentStep++;
  }

  // Update visual playhead from audio clock (not timer-driven)
  updatePlayheadFromClock();
}

function scheduleNote(step, time) {
  const stepInBar = step % CONFIG.STEPS;

  for (let t = 0; t < INSTRUMENTS.length; t++) {
    if (trackMutes[t]) continue;
    const vel = pattern[t][stepInBar];
    if (vel > 0) playFn[t](time, vel);
  }
}

// Derive current visual step from audio clock — not timer-driven.
function updatePlayheadFromClock() {
  if (!audioCtx || window._barStartTime === undefined) return;

  const elapsed = audioCtx.currentTime - window._barStartTime;
  if (elapsed < 0) return; // first note hasn't fired yet

  const base = 60.0 / bpmValue / 4;
  const barDur = base * CONFIG.STEPS; // constant regardless of swing
  let normElapsed = elapsed % barDur;

  let acc = 0, found = CONFIG.STEPS - 1;
  for (let i = 0; i < CONFIG.STEPS; i++) {
    acc += stepDuration(i);
    if (normElapsed < acc) { found = i; break; }
    found = i;
  }

  const prev = window._currentVisualStep;
  window._currentVisualStep = found;
  if (prev !== found) renderPlayhead(found);
}

function startPlayback() {
  initAudio();
  resumeAudio();

  isPlaying   = true;
  currentStep = 0;
  nextNoteTime = audioCtx.currentTime + 0.05; // small initial delay for clean start
  window._barStartTime = nextNoteTime;
  window._scheduledSteps = [];

  schedulerTimerId = setInterval(scheduler, CONFIG.LOOKAHEAD_INTERVAL_MS);

  document.getElementById('playBtn').classList.add('active');
  document.getElementById('statusText').textContent = `▶ Playing at ${bpmValue} BPM`;
}

function stopPlayback() {
  isPlaying = false;
  if (schedulerTimerId) { clearInterval(schedulerTimerId); schedulerTimerId = null; }
  currentStep = 0;
  window._barStartTime = undefined;
  window._currentVisualStep = -1;
  window._scheduledSteps = [];

  document.querySelectorAll('.step-cell.playhead').forEach(el => el.classList.remove('playhead'));
  document.getElementById('playBtn').classList.remove('active');
  document.getElementById('statusText').textContent = '■ Stopped';
}

/* ================================================================
   UI — Grid Rendering & Interaction
   ================================================================ */

function buildGrid() {
  const container = document.getElementById('gridContainer');
  const stepNums  = document.getElementById('stepNumbers');
  container.innerHTML = '';
  stepNums.innerHTML = '';

  // Step numbers
  for (let s = 0; s < CONFIG.STEPS; s++) {
    const n = document.createElement('div');
    n.className = 'step-number';
    n.textContent = s + 1;
    if (s % 4 === 0) n.style.color = '#888';
    stepNums.appendChild(n);
  }

  // Track rows
  INSTRUMENTS.forEach((inst, tIdx) => {
    const row = document.createElement('div');
    row.className = 'track-row' + (trackMutes[tIdx] ? ' muted' : '');
    row.dataset.track = tIdx;

    const label = document.createElement('div');
    label.className = 'track-label';
    label.textContent = `${inst.icon} ${inst.name}`;
    row.appendChild(label);

    const stepsRow = document.createElement('div');
    stepsRow.className = 'steps-row';

    for (let s = 0; s < CONFIG.STEPS; s++) {
      const cell = document.createElement('div');
      cell.className = `step-cell ${velClass(pattern[tIdx][s])}`;
      if (s % 4 === 0) cell.classList.add('beat-start');
      cell.dataset.track = tIdx;
      cell.dataset.step  = s;

      cell.addEventListener('mousedown', onCellMouseDown);
      cell.addEventListener('mouseenter', onCellMouseEnter);
      cell.addEventListener('contextmenu', onCellRightClick);
      stepsRow.appendChild(cell);
    }
    row.appendChild(stepsRow);

    // Track controls: mute + volume
    const ctrls = document.createElement('div');
    ctrls.className = 'track-controls-row';

    const mBtn = document.createElement('button');
    mBtn.className = `mute-btn${trackMutes[tIdx] ? ' muted' : ''}`;
    mBtn.textContent = 'M';
    mBtn.title = 'Toggle Mute';
    mBtn.addEventListener('click', () => toggleMute(tIdx));
    ctrls.appendChild(mBtn);

    const vSlider = document.createElement('input');
    vSlider.type = 'range';
    vSlider.className = 'track-vol';
    vSlider.min = 0; vSlider.max = 100;
    vSlider.value = trackVolumes[tIdx];
    vSlider.title = `Vol: ${trackVolumes[tIdx]}%`;
    vSlider.addEventListener('input', e => {
      trackVolumes[tIdx] = +e.target.value;
      e.target.title = `Vol: ${trackVolumes[tIdx]}%`;
      saveState();
    });
    ctrls.appendChild(vSlider);

    row.appendChild(ctrls);
    container.appendChild(row);
  });
}

function velClass(v) { return v === 0 ? 'off' : v === 1 ? 'normal' : 'accent'; }

function renderPlayhead(stepIdx) {
  document.querySelectorAll('.step-cell.playhead').forEach(el => el.classList.remove('playhead'));
  document.querySelectorAll(`.step-cell[data-step="${stepIdx}"]`).forEach(el => el.classList.add('playhead'));
}

// ── Cell interaction (click / drag / right-click) ──
function onCellMouseDown(e) {
  if (e.button !== 0) return; // only left click
  e.preventDefault();
  const t = +this.dataset.track, s = +this.dataset.step;
  isDragging = true;
  pattern[t][s] = (pattern[t][s] + 1) % 3;
  dragValue = pattern[t][s];
  updateCell(this);
  saveState();
}

function onCellMouseEnter(e) {
  if (!isDragging) return;
  const t = +this.dataset.track, s = +this.dataset.step;
  pattern[t][s] = dragValue;
  updateCell(this);
  saveState();
}

function onCellRightClick(e) {
  e.preventDefault();
  const t = +this.dataset.track, s = +this.dataset.step;
  // Cycle: off→accent, accent→normal, normal→off
  pattern[t][s] = (pattern[t][s] === 2 ? 1 : (pattern[t][s] + 1));
  updateCell(this);
  saveState();
}

function updateCell(cell) {
  const t = +cell.dataset.track, s = +cell.dataset.step;
  cell.className = `step-cell ${velClass(pattern[t][s])}`;
  if (s % 4 === 0) cell.classList.add('beat-start');
  if (window._currentVisualStep === s && isPlaying) cell.classList.add('playhead');
}

document.addEventListener('mouseup', () => { isDragging = false; });

function toggleMute(tIdx) {
  trackMutes[tIdx] = !trackMutes[tIdx];
  const row = document.querySelector(`.track-row[data-track="${tIdx}"]`);
  if (row) {
    row.classList.toggle('muted', trackMutes[tIdx]);
    row.querySelector('.mute-btn').classList.toggle('muted', trackMutes[tIdx]);
  }
  saveState();
}

/* ================================================================
   DEFAULT PATTERN — A Groovy Beat
   ================================================================ */

function loadDefaultPattern() {
  pattern = INSTRUMENTS.map(() => new Array(CONFIG.STEPS).fill(0));

  // Kick: four-on-the-floor + syncopated hits
  [0,2,4,6,8,10,14].forEach(s => { pattern[0][s] = s === 4 ? 2 : 1; });

  // Snare: backbeat on steps 3 and 11 (accented)
  pattern[1][3] = 2; pattern[1][11] = 2;

  // Closed hi-hat: steady eighths with accents
  for (let i = 0; i < 16; i += 2) pattern[2][i] = 1;
  pattern[2][6] = 2; pattern[2][14] = 2;

  // Open hi-hat: off-beat flourishes
  pattern[3][7] = 1; pattern[3][15] = 1;

  // Clap: on backbeat with snare
  pattern[4][3] = 1; pattern[4][11] = 1;

  // Tom: fills at end of phrases
  pattern[5][6] = 1; pattern[5][7] = 2; pattern[5][14] = 1;

  bpmValue    = CONFIG.DEFAULT_BPM;
  swingAmount = 8;
}

function randomPattern() {
  pattern = INSTRUMENTS.map(() => new Array(CONFIG.STEPS).fill(0));

  // Kick: 4-6 hits, favoring even steps
  for (let i = 0; i < 4 + Math.floor(Math.random()*3); i++) {
    const s = Math.floor(Math.random() * CONFIG.STEPS);
    pattern[0][s] = Math.random() > 0.7 ? 2 : 1;
  }

  // Snare: 2-3 hits on backbeat positions
  for (let i = 0; i < 2 + Math.floor(Math.random()*2); i++) {
    const s = [3,7,11,15][Math.floor(Math.random()*4)];
    pattern[1][s] = Math.random() > 0.5 ? 2 : 1;
  }

  // Closed hi-hat: fill most even steps
  for (let i = 0; i < CONFIG.STEPS; i += 2) {
    if (Math.random() > 0.3) pattern[2][i] = Math.random() > 0.8 ? 2 : 1;
  }

  // Open hi-hat: occasional off-beats
  for (let i = 1; i < CONFIG.STEPS; i += 2) {
    if (Math.random() > 0.75) pattern[3][i] = 1;
  }

  // Clap: 1-2 hits
  for (let i = 0; i < Math.floor(Math.random()*3); i++) {
    const s = [3,7,11][Math.floor(Math.random()*3)];
    pattern[4][s] = 1;
  }

  // Tom: random fills
  for (let i = 0; i < CONFIG.STEPS; i++) {
    if (Math.random() > 0.85) pattern[5][i] = Math.random() > 0.5 ? 2 : 1;
  }

  rebuildGridFromData();
  saveState();
}

function clearPattern() {
  pattern = INSTRUMENTS.map(() => new Array(CONFIG.STEPS).fill(0));
  rebuildGridFromData();
  saveState();
}

function rebuildGridFromData() {
  document.querySelectorAll('.step-cell').forEach(cell => updateCell(cell));
}

/* ================================================================
   PERSISTENCE — localStorage
   ================================================================ */

function saveState() {
  try {
    const state = {
      pattern, trackVolumes, trackMutes,
      bpm: bpmValue, swing: swingAmount,
      masterVol: +document.getElementById('masterVolSlider').value
    };
    localStorage.setItem('stepSeq16', JSON.stringify(state));
  } catch(e) {}
}

function loadState() {
  try {
    const raw = localStorage.getItem('stepSeq16');
    if (!raw) return false;
    const s = JSON.parse(raw);
    pattern      = s.pattern || pattern;
    trackVolumes = s.trackVolumes || trackVolumes;
    trackMutes   = s.trackMutes || trackMutes;
    bpmValue     = s.bpm ?? CONFIG.DEFAULT_BPM;
    swingAmount  = s.swing ?? CONFIG.DEFAULT_SWING;

    document.getElementById('bpmSlider').value      = bpmValue;
    document.getElementById('bpmDisplay').textContent = bpmValue;
    document.getElementById('swingSlider').value     = swingAmount;
    document.getElementById('swingDisplay').textContent = swingAmount + '%';
    if (s.masterVol !== undefined) {
      document.getElementById('masterVolSlider').value = s.masterVol;
    }
    return true;
  } catch(e) { return false; }
}

/* ================================================================
   CONTROLS WIRING
   ================================================================ */

function wireControls() {
  // Play / Stop
  document.getElementById('playBtn').addEventListener('click', () => {
    isPlaying ? stopPlayback() : startPlayback();
  });
  document.getElementById('stopBtn').addEventListener('click', stopPlayback);

  // BPM slider + display
  const bpmSlider = document.getElementById('bpmSlider');
  const bpmDisp   = document.getElementById('bpmDisplay');
  bpmSlider.addEventListener('input', () => {
    bpmValue = +bpmSlider.value;
    bpmDisp.textContent = bpmValue;
    saveState();
  });

  // Swing slider + display
  const swingSlider = document.getElementById('swingSlider');
  const swingDisp   = document.getElementById('swingDisplay');
  swingSlider.addEventListener('input', () => {
    swingAmount = +swingSlider.value;
    swingDisp.textContent = swingAmount + '%';
    saveState();
  });

  // Master volume
  const mvSlider = document.getElementById('masterVolSlider');
  mvSlider.addEventListener('input', () => {
    if (masterGainNode) masterGainNode.gain.value = +mvSlider.value / 100;
    saveState();
  });

  // Utility buttons
  document.getElementById('clearBtn').addEventListener('click', clearPattern);
  document.getElementById('randomBtn').addEventListener('click', randomPattern);

  // Keyboard: Space = play/stop
  document.addEventListener('keydown', e => {
    if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
      e.preventDefault();
      isPlaying ? stopPlayback() : startPlayback();
    }
  });
}

/* ================================================================
   INITIALIZATION
   ================================================================ */

function init() {
  const hasSaved = loadState();
  if (!hasSaved) loadDefaultPattern();

  buildGrid();
  wireControls();

  // Expose for debugging / probing
  window._sequencer = {
    get pattern()     { return pattern; },
    get isPlaying()   { return isPlaying; },
    get currentStep() { return currentStep % CONFIG.STEPS; },
    get bpm()         { return bpmValue; },
    get swing()       { return swingAmount; }
  };

  document.getElementById('statusText').textContent =
    hasSaved ? 'Pattern restored from save — press Play or Space' : 'Default pattern loaded — press Play or Space';
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"b4e4004f-a5c1-461e-ab4d-3c343713ddde","tokensIn":1255884,"tokensOut":35744,"tokensTotal":1291628,"turns":33,"toolCalls":32,"failedToolCalls":1,"timestamp":"2026-07-29T23:22:47.477Z"} -->