Drum machine · seed-2.0-mini
Build a 16-step drum machine with a clickable grid for kick, snare, hi-hat, and clap, all sounds synthesized with the Web Audio API, plus play/stop and an adjustable BPM.
transcript · 6 messages
Build a 16-step drum machine with a clickable grid for kick, snare, hi-hat, and clap, all sounds synthesized with the Web Audio API, plus play/stop and an adjustable BPM.
thinking
Got it, let's tackle this drum machine. First, I need to make a single index.html file with everything: Web Audio API for the sounds, a 16-step grid for 4 instruments (kick, snare, hi-hat, clap), play/stop button, BPM adjuster.
First, start with the basic HTML structure. Then the CSS for the grid—each cell should be a button or div, highlight when active. The grid will be 4 rows (instruments) by 16 columns (steps).
Wait, Web Audio setup: need to create an AudioContext, then oscillators or samples? Wait, the user said synthesized, so no external samples, use oscillators and gain nodes for each sound. Let's define each instrument:
1. Kick: Low frequency sine wave, quick decay. Maybe start at 60Hz, gain down quickly over like 0.1s.
2. Snare: Maybe a noise burst with a bandpass filter, plus a lower sine pop? Or a noise source with envelope.
3. Hi-hat: High frequency noise (white noise) with a high-pass filter, quick decay.
4. Clap: More complex noise, maybe a short burst of filtered noise with some repetition? Or a few noise layers.
Then the sequencer logic: track which cells are active, on each step trigger the active instruments, move the playhead, loop back after 16 steps.
BPM control: calculate the interval between steps as (60 / BPM) / 16 seconds, since each step is 1/16 note.
Play/stop toggle: when playing, use setInterval or requestAnimationFrame to step through each column, trigger the sounds for that step's active cells. Wait, better to use a timer that's accurate, but for a simple drum machine, setInterval is okay for a demo.
Wait, let's outline the parts:
1. HTML:
- A header with title, play/stop button, BPM input (number or range, with label)
- A grid container: 4 rows, each row is an instrument label plus 16 cells.
- Maybe a visual indicator for the current step (a vertical line that moves across the grid)
2. CSS:
- Grid layout: .drum-grid { display: grid; grid-template-columns: 100px repeat(16, 1fr); gap: 2px; }
- Each instrument row: display: contents? Or each row is a div with flex? Wait, better: each instrument is a row with .instrument-row, which has a .instrument-label (100px width) and 16 .step-cell divs/buttons.
- Step cells: width: 100%, aspect-ratio: 1 / 1, background: #333, border: none, border-radius: 4px; cursor: pointer. Active cells: background: #0d6efd or another color. Current step: add a highlight, or a border? Wait, the current step should be highlighted differently, like a brighter color or an outline.
- Body styling: background: #1a1a1a; color: white; font-family: sans-serif; padding: 2rem; max-width: 1200px; margin: 0 auto;
3. JavaScript:
- Initialize AudioContext on first user interaction (since browsers require user gesture for audio).
- Define each instrument's sound function: triggerKick(), triggerSnare(), triggerHiHat(), triggerClap().
- Create a grid state: a 2D array, 4 instruments × 16 steps, all false initially. When a cell is clicked, toggle its state.
- Track current step: 0 to 15, loop back.
- Play state: boolean, if playing, clear the interval, else start the interval.
- BPM: default 120, adjust when the input changes, recalculate the step interval.
- For each step, loop through the 4 instruments, if the cell is active, trigger the sound.
- Update the current step indicator: remove the highlight from the previous step, add to the current one.
Wait, let's think about the trigger functions. For kick:
function triggerKick() {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(60, audioContext.currentTime);
gain.gain.setValueAtTime(0.8, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.1);
}
That's a basic kick. For hi-hat: white noise, so create a buffer source with white noise. Oh right, noise isn't an oscillator, so we need to generate a buffer of random values. Let's make a helper function to create noise:
function createNoiseBuffer() {
const buffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.05, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
Then hi-hat:
function triggerHiHat() {
const noiseSource = audioContext.createBufferSource();
const filter = audioContext.createBiquadFilter();
const gain = audioContext.createGain();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(filter);
filter.connect(gain);
gain.connect(audioContext.destination);
filter.type = 'highpass';
filter.frequency.setValueAtTime(7000, audioContext.currentTime);
gain.gain.setValueAtTime(0.5, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
noiseSource.start(audioContext.currentTime);
noiseSource.stop(audioContext.currentTime + 0.1);
}
Snare: mix of noise and a low oscillator. Let's do:
function triggerSnare() {
// Noise part
const noiseSource = audioContext.createBufferSource();
const noiseFilter = audioContext.createBiquadFilter();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(noiseFilter);
noiseFilter.type = 'bandpass';
noiseFilter.frequency.setValueAtTime(2000, audioContext.currentTime);
// Oscillator part
const osc = audioContext.createOscillator();
const oscGain = audioContext.createGain();
osc.connect(oscGain);
oscGain.connect(audioContext.destination);
osc.frequency.setValueAtTime(150, audioContext.currentTime);
oscGain.gain.setValueAtTime(0.3, audioContext.currentTime);
oscGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
// Noise gain
const noiseGain = audioContext.createGain();
noiseFilter.connect(noiseGain);
noiseGain.connect(audioContext.destination);
noiseGain.gain.setValueAtTime(0.5, audioContext.currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
osc.start(audioContext.currentTime);
noiseSource.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.15);
noiseSource.stop(audioContext.currentTime + 0.15);
}
Clap: similar to snare but more noise, maybe multiple noise bursts? Or a longer noise with a bit of envelope shaping. Let's do:
function triggerClap() {
const gain = audioContext.createGain();
gain.gain.setValueAtTime(0, audioContext.currentTime);
gain.gain.linearRampToValueAtTime(0.6, audioContext.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
gain.connect(audioContext.destination);
for (let i = 0; i < 3; i++) {
const noise = audioContext.createBufferSource();
noise.buffer = createNoiseBuffer();
const filter = audioContext.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(3000 + Math.random() * 1000, audioContext.currentTime + i * 0.02);
noise.connect(filter);
filter.connect(gain);
noise.start(audioContext.currentTime + i * 0.02);
noise.stop(audioContext.currentTime + i * 0.02 + 0.05);
}
}
That should make a decent clap.
Then the grid: let's create the HTML elements dynamically? Or hardcode? Well, dynamic is better, but for a single file, maybe hardcode the 16 steps, but better to generate them with JS. Wait, but for simplicity, maybe create the grid on load.
Wait, the HTML structure:
<div class="controls">
<button id="playBtn">Play</button>
<div class="bpm-control">
<label for="bpmInput">BPM:</label>
<input type="number" id="bpmInput" min="60" max="240" value="120">
</div>
</div>
<div id="drumMachine">
<!-- Instrument rows will go here -->
</div>
Then in JS, create 4 instrument rows: Kick, Snare, Hi-Hat, Clap. Each row has a label, then 16 step cells.
Track the grid state: let's have a grid = Array(4).fill().map(() => Array(16).fill(false));
Each step cell has a data-instrument index (0-3) and data-step index (0-15). When clicked, toggle grid[instrument][step] and add/remove the active class.
Current step: let currentStep = 0; let intervalId = null;
Play function: when playBtn is clicked, if isPlaying, clear intervalId, set playBtn text to Play, isPlaying = false. Else, start interval: intervalId = setInterval(step, (60 / bpm) / 16 * 1000); because setInterval uses ms. Wait, bpm is beats per minute, so each beat is 60/bpm seconds, each step is 1/16 of a beat, so per step time is (60/bpm)/16 seconds, times 1000 for ms.
The step function: trigger all active instruments for currentStep, then update currentStep to (currentStep +1) %16, update the step indicator.
Wait, the step indicator: each step cell has a class .current-step, so we can remove that class from all cells first, then add it to the current step's cells for each instrument? Or wait, the current step is a vertical line? No, better to highlight each cell in the current step across all instruments, so for each row, the cell at currentStep has a different background, like #dc3545 or something.
Wait, let's adjust: when stepping, first remove the .current class from all step cells, then add .current to the cells in column currentStep.
Wait, yes, that's easier. So document.querySelectorAll('.step-cell').forEach(cell => cell.classList.remove('current')); then document.querySelectorAll(`.step-cell[data-step="${currentStep}"]`).forEach(cell => cell.classList.add('current'));
Then for each instrument index, if grid[instrumentIndex][currentStep] is true, call the corresponding trigger function. Like instruments = [triggerKick, triggerSnare, triggerHiHat, triggerClap]; so instruments[instrumentIndex]();
Wait, that's a good array. So instruments[0] is kick, 1 snare, 2 hi-hat, 3 clap.
Also, need to handle the AudioContext initialization: browsers require a user gesture to start audio, so we can't create it until the user clicks the play button, or clicks a cell. So let's add a check: if audioContext is null, create it when first interacting.
Wait, let's initialize audioContext as let audioContext = null; then in the step function or playBtn click, if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
That's important, otherwise the audio won't play on most browsers.
Now, CSS details:
- .controls: display: flex; gap: 1rem; align-items: center; margin-bottom: 2rem;
- .bpm-control: display: flex; gap: 0.5rem; align-items: center;
- #drumMachine: display: flex; flex-direction: column; gap: 4px;
- .instrument-row: display: flex; gap: 4px; align-items: center;
- .instrument-label: width: 100px; text-align: right; padding-right: 1rem; font-weight: bold;
- .step-cell: flex: 1; aspect-ratio: 1 / 1; background: #343a40; border: none; border-radius: 4px; cursor: pointer; transition: background 0.2s;
- .step-cell.active: background: #0d6efd;
- .step-cell.current: background: #fd7e14; /* different color for current step */
- .step-cell.active.current: background: #dc3545; /* active and current step */
- body: background: #212529; color: #f8f9fa; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 2rem; max-width: 1400px; margin: 0 auto;
- button: cursor: pointer; padding: 0.5rem 1rem; border: none; border-radius: 4px; background: #0d6efd; color: white; font-size: 1rem;
- button:hover: background: #0a58ca;
- input[type="number"]: padding: 0.5rem; border-radius: 4px; border: 1px solid #495057; background: #343a40; color: white; width: 80px;
Wait, that's good. Also, the active and current classes: if a cell is both active and current, it should be a different color, like red, so you can see that it's active and playing right now.
Now, let's put all this together into a single index.html file.
Wait, let's write the full code:
First, the HTML doctype, then <html lang="en">, <head> with meta tags, title, and the style tag. Then <body> with the controls, then the drum machine div, then the script tag with all the JS.
Wait, let's test for possible issues:
- The noise buffer: createNoiseBuffer is called each time? Maybe we can cache it, but for simplicity, it's okay to create it each time, since it's a small buffer.
- The AudioContext: need to resume it if it's suspended? Because some browsers suspend audio contexts when the tab is inactive. So in the step function, check if audioContext.state === 'suspended' then resume it. Oh right, that's a good addition: if (audioContext.state === 'suspended') await audioContext.resume(); but wait, resume returns a promise, so maybe in the play button click, we can handle that. Wait, but if using setInterval, we need to make sure the audio context is resumed each time. Alternatively, in the step function, add:
if (audioContext.state !== 'running') {
audioContext.resume();
}
But since resume is async, maybe wrap the step function in an async function? Wait, but setInterval can't be async. Alternatively, just call audioContext.resume() in the playBtn click handler, before starting the interval.
Wait, let's adjust the playBtn click handler:
playBtn.addEventListener('click', () => {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
if (isPlaying) {
clearInterval(intervalId);
playBtn.textContent = 'Play';
isPlaying = false;
} else {
// Start stepping
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
playBtn.textContent = 'Stop';
isPlaying = true;
}
});
That's better.
Also, the BPM input: when the user changes it, we need to update the interval if playing. So add an event listener to bpmInput:
bpmInput.addEventListener('input', () => {
bpm = parseInt(bpmInput.value);
if (isPlaying) {
clearInterval(intervalId);
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
}
});
That way, changing BPM while playing updates the tempo.
Now, the step function:
function step() {
// Update step indicator
document.querySelectorAll('.step-cell').forEach(cell => cell.classList.remove('current'));
document.querySelectorAll(`.step-cell[data-step="${currentStep}"]`).forEach(cell => cell.classList.add('current'));
// Trigger active instruments
for (let instrument = 0; instrument < 4; instrument++) {
if (grid[instrument][currentStep]) {
instruments[instrument]();
}
}
// Advance step
currentStep = (currentStep + 1) % 16;
}
Perfect.
Now, the grid initialization: when the page loads, create the instrument rows. Let's do that:
const instrumentLabels = ['Kick', 'Snare', 'Hi-Hat', 'Clap'];
const drumMachine = document.getElementById('drumMachine');
const grid = Array(4).fill().map(() => Array(16).fill(false));
let currentStep = 0;
let isPlaying = false;
let intervalId = null;
let bpm = 120;
let audioContext = null;
const instruments = [triggerKick, triggerSnare, triggerHiHat, triggerClap];
// Create instrument rows
instrumentLabels.forEach((label, instrumentIndex) => {
const row = document.createElement('div');
row.className = 'instrument-row';
const labelEl = document.createElement('div');
labelEl.className = 'instrument-label';
labelEl.textContent = label;
row.appendChild(labelEl);
// Create 16 steps
for (let step = 0; step < 16; step++) {
const cell = document.createElement('button');
cell.className = 'step-cell';
cell.dataset.instrument = instrumentIndex;
cell.dataset.step = step;
cell.addEventListener('click', () => {
grid[instrumentIndex][step] = !grid[instrumentIndex][step];
cell.classList.toggle('active');
});
row.appendChild(cell);
}
drumMachine.appendChild(row);
});
That will create all the rows and cells.
Now, putting all the functions together: triggerKick, triggerSnare, triggerHiHat, triggerClap, createNoiseBuffer.
Wait, createNoiseBuffer is a helper function, so define that first.
Wait, let's order the script:
1. Declare all the variables first? No, better to define functions before using them. So first createNoiseBuffer, then the trigger functions, then the variables, then create the grid, then add event listeners.
Wait, let's adjust the script order:
<script>
// Helper to create white noise buffer
function createNoiseBuffer() {
const buffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.05, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
// Trigger sounds
function triggerKick() {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(60, audioContext.currentTime);
gain.gain.setValueAtTime(0.8, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.1);
}
function triggerHiHat() {
const noiseSource = audioContext.createBufferSource();
const filter = audioContext.createBiquadFilter();
const gain = audioContext.createGain();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(filter);
filter.connect(gain);
gain.connect(audioContext.destination);
filter.type = 'highpass';
filter.frequency.setValueAtTime(7000, audioContext.currentTime);
gain.gain.setValueAtTime(0.5, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
noiseSource.start(audioContext.currentTime);
noiseSource.stop(audioContext.currentTime + 0.1);
}
function triggerSnare() {
// Noise component
const noiseSource = audioContext.createBufferSource();
const noiseFilter = audioContext.createBiquadFilter();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(noiseFilter);
noiseFilter.type = 'bandpass';
noiseFilter.frequency.setValueAtTime(2000, audioContext.currentTime);
// Oscillator component
const osc = audioContext.createOscillator();
const oscGain = audioContext.createGain();
osc.connect(oscGain);
oscGain.connect(audioContext.destination);
osc.frequency.setValueAtTime(150, audioContext.currentTime);
oscGain.gain.setValueAtTime(0.3, audioContext.currentTime);
oscGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
// Noise gain
const noiseGain = audioContext.createGain();
noiseFilter.connect(noiseGain);
noiseGain.connect(audioContext.destination);
noiseGain.gain.setValueAtTime(0.5, audioContext.currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
osc.start(audioContext.currentTime);
noiseSource.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.15);
noiseSource.stop(audioContext.currentTime + 0.15);
}
function triggerClap() {
const mainGain = audioContext.createGain();
mainGain.gain.setValueAtTime(0, audioContext.currentTime);
mainGain.gain.linearRampToValueAtTime(0.6, audioContext.currentTime + 0.01);
mainGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
mainGain.connect(audioContext.destination);
// Multiple noise bursts for clap texture
for (let i = 0; i < 3; i++) {
const noise = audioContext.createBufferSource();
noise.buffer = createNoiseBuffer();
const filter = audioContext.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(3000 + Math.random() * 1000, audioContext.currentTime + i * 0.02);
noise.connect(filter);
filter.connect(mainGain);
noise.start(audioContext.currentTime + i * 0.02);
noise.stop(audioContext.currentTime + i * 0.02 + 0.05);
}
}
// Drum machine state
const instrumentLabels = ['Kick', 'Snare', 'Hi-Hat', 'Clap'];
const drumMachine = document.getElementById('drumMachine');
const grid = Array(4).fill().map(() => Array(16).fill(false));
let currentStep = 0;
let isPlaying = false;
let intervalId = null;
let bpm = 120;
let audioContext = null;
const instruments = [triggerKick, triggerSnare, triggerHiHat, triggerClap];
// Step function
function step() {
// Update current step indicator
document.querySelectorAll('.step-cell').forEach(cell => cell.classList.remove('current'));
document.querySelectorAll(`.step-cell[data-step="${currentStep}"]`).forEach(cell => cell.classList.add('current'));
// Trigger active instruments
for (let instr = 0; instr < 4; instr++) {
if (grid[instr][currentStep]) {
instruments[instr]();
}
}
// Advance to next step
currentStep = (currentStep + 1) % 16;
}
// Initialize UI
document.addEventListener('DOMContentLoaded', () => {
// Create instrument rows
instrumentLabels.forEach((label, instrIndex) => {
const row = document.createElement('div');
row.className = 'instrument-row';
const labelEl = document.createElement('div');
labelEl.className = 'instrument-label';
labelEl.textContent = label;
row.appendChild(labelEl);
// Add 16 steps
for (let step = 0; step < 16; step++) {
const cell = document.createElement('button');
cell.className = 'step-cell';
cell.dataset.instrument = instrIndex;
cell.dataset.step = step;
cell.addEventListener('click', () => {
grid[instrIndex][step] = !grid[instrIndex][step];
cell.classList.toggle('active');
});
row.appendChild(cell);
}
drumMachine.appendChild(row);
});
// Setup controls
const playBtn = document.getElementById('playBtn');
const bpmInput = document.getElementById('bpmInput');
playBtn.addEventListener('click', () => {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
if (isPlaying) {
clearInterval(intervalId);
playBtn.textContent = 'Play';
isPlaying = false;
} else {
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
playBtn.textContent = 'Stop';
isPlaying = true;
}
});
bpmInput.addEventListener('input', () => {
const newBpm = parseInt(bpmInput.value);
if (!isNaN(newBpm) && newBpm >= 60 && newBpm <= 240) {
bpm = newBpm;
if (isPlaying) {
clearInterval(intervalId);
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
}
}
});
});
</script>
That's the script part. Now the CSS:
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #212529;
color: #f8f9fa;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.controls {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
}
#playBtn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 6px;
background-color: #0d6efd;
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
#playBtn:hover {
background-color: #0a58ca;
}
.bpm-control {
display: flex;
gap: 0.5rem;
align-items: center;
}
.bpm-control label {
font-size: 1rem;
}
#bpmInput {
padding: 0.5rem;
border-radius: 6px;
border: 1px solid #495057;
background-color: #343a40;
color: white;
width: 90px;
font-size: 1rem;
}
#drumMachine {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.instrument-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.instrument-label {
width: 100px;
text-align: right;
padding-right: 1rem;
font-weight: 600;
font-size: 1rem;
}
.step-cell {
flex: 1;
aspect-ratio: 1 / 1;
border: none;
border-radius: 4px;
background-color: #343a40;
cursor: pointer;
transition: background-color 0.2s ease;
}
.step-cell:hover {
background-color: #495057;
}
.step-cell.active {
background-color: #0d6efd;
}
.step-cell.active.current {
background-color: #dc3545;
}
.step-cell.current {
background-color: #fd7e14;
}
</style>
Wait, that's better, added box-sizing: border-box to * so padding doesn't break widths. Also, wrap the controls so they don't overflow on small screens.
Now, putting all together into index.html. Let's check:
- All CSS is in a <style> tag in the head.
- All JS is in a <script> tag at the end of the body, or in the head? Wait, no, the DOMContentLoaded listener will wait for the DOM to load, so it's okay to put the script in the head, but better to put it at the end of the body, so that the DOM is already loaded. Wait, no, the DOMContentLoaded listener will handle that regardless, but putting it at the end is okay.
Wait, let's write the full index.html file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>16-Step Drum Machine</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #212529;
color: #f8f9fa;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.controls {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
}
#playBtn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 6px;
background-color: #0d6efd;
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
#playBtn:hover {
background-color: #0a58ca;
}
.bpm-control {
display: flex;
gap: 0.5rem;
align-items: center;
}
.bpm-control label {
font-size: 1rem;
}
#bpmInput {
padding: 0.5rem;
border-radius: 6px;
border: 1px solid #495057;
background-color: #343a40;
color: white;
width: 90px;
font-size: 1rem;
}
#drumMachine {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.instrument-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.instrument-label {
width: 100px;
text-align: right;
padding-right: 1rem;
font-weight: 600;
font-size: 1rem;
}
.step-cell {
flex: 1;
aspect-ratio: 1 / 1;
border: none;
border-radius: 4px;
background-color: #343a40;
cursor: pointer;
transition: background-color 0.2s ease;
}
.step-cell:hover {
background-color: #495057;
}
.step-cell.active {
background-color: #0d6efd;
}
.step-cell.active.current {
background-color: #dc3545;
}
.step-cell.current {
background-color: #fd7e14;
}
</style>
</head>
<body>
<div class="controls">
<button id="playBtn">Play</button>
<div class="bpm-control">
<label for="bpmInput">BPM:</label>
<input type="number" id="bpmInput" min="60" max="240" value="120">
</div>
</div>
<div id="drumMachine"></div>
<script>
// Helper to create white noise buffer
function createNoiseBuffer() {
const buffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.05, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
// Trigger sounds
function triggerKick() {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(60, audioContext.currentTime);
gain.gain.setValueAtTime(0.8, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.1);
}
function triggerHiHat() {
const noiseSource = audioContext.createBufferSource();
const filter = audioContext.createBiquadFilter();
const gain = audioContext.createGain();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(filter);
filter.connect(gain);
gain.connect(audioContext.destination);
filter.type = 'highpass';
filter.frequency.setValueAtTime(7000, audioContext.currentTime);
gain.gain.setValueAtTime(0.5, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
noiseSource.start(audioContext.currentTime);
noiseSource.stop(audioContext.currentTime + 0.1);
}
function triggerSnare() {
// Noise component
const noiseSource = audioContext.createBufferSource();
const noiseFilter = audioContext.createBiquadFilter();
noiseSource.buffer = createNoiseBuffer();
noiseSource.connect(noiseFilter);
noiseFilter.type = 'bandpass';
noiseFilter.frequency.setValueAtTime(2000, audioContext.currentTime);
// Oscillator component
const osc = audioContext.createOscillator();
const oscGain = audioContext.createGain();
osc.connect(oscGain);
oscGain.connect(audioContext.destination);
osc.frequency.setValueAtTime(150, audioContext.currentTime);
oscGain.gain.setValueAtTime(0.3, audioContext.currentTime);
oscGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
// Noise gain
const noiseGain = audioContext.createGain();
noiseFilter.connect(noiseGain);
noiseGain.connect(audioContext.destination);
noiseGain.gain.setValueAtTime(0.5, audioContext.currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
osc.start(audioContext.currentTime);
noiseSource.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.15);
noiseSource.stop(audioContext.currentTime + 0.15);
}
function triggerClap() {
const mainGain = audioContext.createGain();
mainGain.gain.setValueAtTime(0, audioContext.currentTime);
mainGain.gain.linearRampToValueAtTime(0.6, audioContext.currentTime + 0.01);
mainGain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
mainGain.connect(audioContext.destination);
// Multiple noise bursts for clap texture
for (let i = 0; i < 3; i++) {
const noise = audioContext.createBufferSource();
noise.buffer = createNoiseBuffer();
const filter = audioContext.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(3000 + Math.random() * 1000, audioContext.currentTime + i * 0.02);
noise.connect(filter);
filter.connect(mainGain);
noise.start(audioContext.currentTime + i * 0.02);
noise.stop(audioContext.currentTime + i * 0.02 + 0.05);
}
}
// Drum machine state
const instrumentLabels = ['Kick', 'Snare', 'Hi-Hat', 'Clap'];
const drumMachine = document.getElementById('drumMachine');
const grid = Array(4).fill().map(() => Array(16).fill(false));
let currentStep = 0;
let isPlaying = false;
let intervalId = null;
let bpm = 120;
let audioContext = null;
const instruments = [triggerKick, triggerSnare, triggerHiHat, triggerClap];
// Step function
function step() {
// Update current step indicator
document.querySelectorAll('.step-cell').forEach(cell => cell.classList.remove('current'));
document.querySelectorAll(`.step-cell[data-step="${currentStep}"]`).forEach(cell => cell.classList.add('current'));
// Trigger active instruments
for (let instr = 0; instr < 4; instr++) {
if (grid[instr][currentStep]) {
instruments[instr]();
}
}
// Advance to next step
currentStep = (currentStep + 1) % 16;
}
// Initialize UI
document.addEventListener('DOMContentLoaded', () => {
// Create instrument rows
instrumentLabels.forEach((label, instrIndex) => {
const row = document.createElement('div');
row.className = 'instrument-row';
const labelEl = document.createElement('div');
labelEl.className = 'instrument-label';
labelEl.textContent = label;
row.appendChild(labelEl);
// Add 16 steps
for (let step = 0; step < 16; step++) {
const cell = document.createElement('button');
cell.className = 'step-cell';
cell.dataset.instrument = instrIndex;
cell.dataset.step = step;
cell.addEventListener('click', () => {
grid[instrIndex][step] = !grid[instrIndex][step];
cell.classList.toggle('active');
});
row.appendChild(cell);
}
drumMachine.appendChild(row);
});
// Setup controls
const playBtn = document.getElementById('playBtn');
const bpmInput = document.getElementById('bpmInput');
playBtn.addEventListener('click', () => {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
if (isPlaying) {
clearInterval(intervalId);
playBtn.textContent = 'Play';
isPlaying = false;
} else {
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
playBtn.textContent = 'Stop';
isPlaying = true;
}
});
bpmInput.addEventListener('input', () => {
const newBpm = parseInt(bpmInput.value);
if (!isNaN(newBpm) && newBpm >= 60 && newBpm <= 240) {
bpm = newBpm;
if (isPlaying) {
clearInterval(intervalId);
intervalId = setInterval(step, (60 / bpm) / 16 * 1000);
}
}
});
});
</script>
</body>
</html>
That's the full file. Now, let's check if everything works:
- 4 instruments, 16 steps each.
- Click cells to toggle active (blue), current step is orange, active current step is red.
- Play/stop button, BPM adjuster between 60-240.
- All sounds are synthesized with Web Audio API, no external samples.
- Responsive grid, works on mobile? Well, the aspect ratio keeps the cells square, and flex wrap on controls.
Wait, one thing: when you first click play, it creates the AudioContext, which is correct. Also, if the tab is backgrounded, the audio context might suspend, but the play button click resumes it.
Another thing: the noise buffer is created each time a sound is triggered, which is okay for a demo, but could be cached, but it's not a big deal.
Also, the step interval: using setInterval might drift a bit, but for a simple drum machine, it's perfectly fine.
Now, the user asked to write this all into one index.html file, which this does. So now, I need to write this file to /tmp/ws/index.html.
So use the write function, path is "/tmp/ws/index.html", content is the full HTML string above./tmp/ws/index.html (9414 bytes)Successfully wrote 9414 bytes to /tmp/ws/index.html
thinking
Great, the file was written successfully. Now, let's confirm that the file exists and is correct. Wait, the user just asked to build the app and write it to index.html, which we did. Let's just check with ls to make sure:
{"command":"ls -la /tmp/ws/index.html"}-rw-r--r--. 1 node node 9414 Aug 2 03:19 /tmp/ws/index.html
thinking
Perfect, the file is there. Now, I can let the user know that the drum machine is ready. The app has: - 16-step grid for 4 instruments: Kick, Snare, Hi-Hat, Clap - Click any cell to toggle the pattern - Play/Stop button to start/stop the sequencer - Adjustable BPM between 60-240 - All sounds are synthesized with Web Audio API, no external resources - Responsive styling that works on most screen sizes The file is fully self-contained, no external dependencies, runs sandboxed with no network requests.
Your 16-step drum machine is now ready! The single-file `index.html` app includes: - 4 synthesized drum sounds (kick, snare, hi-hat, clap) using the Web Audio API - 16-step clickable grid for each instrument - Play/stop toggle control - Adjustable BPM (60-240) - Visual step indicator that highlights the currently playing step The file is fully self-contained with no external resources, and runs completely sandboxed without network requests. You can open it directly in any modern web browser to use the drum machine.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Grid renders with 4 tracks and 16 steps but only drag/wheel toggled one cell; no play/stop or BPM controls visible.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.