Drum machine · laguna-s-2.1
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 · 13 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
The user wants a 16-step drum machine built as a single-page web app in one self-contained index.html file. Let me break down the requirements: 1. 16-step drum machine with a clickable grid 2. Four sounds: kick, snare, hi-hat, and clap 3. All sounds synthesized with the Web Audio API (no samples) 4. Play/stop button 5. Adjustable BPM Let me design this: - A 4-row x 16-column grid (one row per instrument, 16 steps) - Each cell is clickable to toggle on/off - Play/stop button to start/stop the sequence - BPM input/slider to adjust tempo - Web Audio API synthesis for each instrument Let me create the synthesis: - Kick: Low-frequency sine wave with exponential decay, maybe with some noise for attack - Snare: Noise with a bandpass filter, short decay, plus a sine component - Hi-hat: Bandpass-filtered noise, short decay - Clap: Noise burst with reverb-like effect (filtered noise) Let me write this all into one HTML file. I'll make it look nice with CSS - a dark theme with the grid, instrument labels, controls. Let me write the complete file now.
Looking at the requirements, I'll build a complete 16-step drum machine with synthesized sounds in a single self-contained HTML file. Let me first check the working directory, then create the file.
{"command":"ls /tmp/ws && pwd"}/tmp/ws
thinking
Empty directory. Let me create the index.html file with the complete drum machine. Let me carefully design the Web Audio API synthesis: 1. **Kick Drum**: - A low-frequency sine wave (around 60-80 Hz) with exponential frequency decay - Start high (like 200 Hz) and drop to low (60 Hz) quickly - Add some noise for the attack/transient - Short attack, quick decay 2. **Snare Drum**: - White/pink noise burst with bandpass filter - A sine component around 200 Hz for the body - Short decay (50-100ms) - Filter around 1000-2000 Hz 3. **Hi-Hat**: - Bandpass-filtered noise (or highpass filtered noise) - Multiple noise sources at different frequencies for richness - Short decay (20-50ms) - Filter around 8000-10000 Hz 4. **Clap**: - Noise burst with a "snappy" attack - Filtered noise - Short decay with some reverb/delay for the handclap effect Let me write this now. For the grid: - 4 rows (kick, snare, hihat, clap) × 16 columns - Clickable cells with visual feedback - Current step highlighted - Play/stop toggle - BPM slider Let me write the full HTML file.
Now I'll create the complete drum machine as a single self-contained file:
/tmp/ws/index.html (16560 bytes)Successfully wrote 16560 bytes to /tmp/ws/index.html
thinking
Let me review the code I wrote. There's a problem in the `playHiHat` function — I have duplicate/legacy code that creates two filters but doesn't create the buffer sources, and then creates another set of sources. Let me fix that to be clean.
Let me re-read the playHiHat function:
```js
function playHiHat(time) {
const ctx = getAudioCtx();
const masterEnv = ctx.createGain();
masterEnv.connect(ctx.destination);
// Two noise bands for richer hi-hat
for (let i = 0; i < 2; i++) {
const filter = ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.setValueAtTime(7000 + i * 1500, time);
const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let j = 0; j < data.length; j++) {
data[j] = Math.random() * 2 - 1;
}
const noiseSrc = ctx.createBufferSource();
noiseSrc.buffer = noiseBuf;
noiseSrc.connect(filter);
filter.connect(masterEnv);
}
masterEnv.gain.setValueAtTime(0.001, time);
masterEnv.gain.exponentialRampToValueAtTime(0.5, time + 0.002);
masterEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.06);
// Start/stop handled per-source
// We need to track sources to stop them; rebuild with stops
// (Simplified: sources auto-stop via buffer length, but let's explicitly stop)
// Actually the above connects both filters; let's restart cleanly:
const noiseBuf1 = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate);
...
```
The first loop creates filters and buffer sources but never calls `.start()` or `.stop()` on the noise sources. This is dead code. The second part (noiseBuf1, noiseBuf2) creates proper sources that get started and stopped. Let me clean this up so the function is correct and clean — remove the dead first loop.
Also, I should double check: `exponentialRampToValueAtTime` requires the value to be greater than 0 (can't ramp to 0 exactly). I used 0.001, which is fine. Also the start value 0.001 is fine.
For the kick, `env.gain.setValueAtTime(0.001, time)` then `exponentialRampToValueAtTime(0.8, time + 0.005)` — both positive, fine.
For snare body: `bodyEnv.gain.setValueAtTime(0.001, time)` → `exponentialRampToValueAtTime(0.7, ...)` → `exponentialRampToValueAtTime(0.001, ...)`. Fine.
For clap: `env.gain.setValueAtTime(0.001, time)` → `0.001` → `0.2` → `0.001`. Fine.
Also, the scheduling approach: I'm using a `setTimeout` loop at 25ms. Each time I compute which step is "next" and schedule audio ahead. But the `scheduledSteps` set management might have issues. Let me think...
Actually, there's a subtle bug. The `nextStepIndex` is computed as `Math.floor(elapsed / secondsPerStep)`. With 120 BPM, secondsPerStep = 60/120/4 = 0.125s = 125ms. With a 25ms timer, every ~5 ticks a new step is played. That's fine.
But the `scheduledSteps` approach: I add a step when I first encounter it, and remove it when `keyTime < now - secondsPerStep`. This should work but might cause steps to be scheduled multiple times if the timing is off. Let me reconsider.
Actually, the issue is that I want each step to be scheduled exactly once. The `scheduledSteps` set tracks which steps have been scheduled. When a step's time has passed (by more than one step interval), I remove it from the set so it can be rescheduled next loop.
Wait, but there's a problem: if a step passes and gets removed from scheduledSteps, then on the next iteration the nextStepIndex might have moved on, and the loop `for (let i = 0; i < STEPS; i++)` would find that step again and reschedule it. But by that point, the step's time has already passed, so we'd be scheduling it late.
Hmm, let me reconsider the design. The issue is I'm iterating through `stepsToPlay` which covers many steps ahead, but I only want to schedule steps within the lookahead window. Let me simplify: just schedule the current step and the next few steps.
Actually, let me reconsider. A simpler and more robust approach: just schedule the current step and next 2 steps each tick. Or even simpler — just schedule the current step each tick.
Let me think about this more carefully. With a 25ms timer and 125ms per step, I tick 5 times between steps. If I only schedule the current step, the timing precision is limited to 25ms, which is probably fine for a drum machine. But scheduling ahead is better for timing.
Actually, the simplest robust approach: each tick, compute the current step index. For each step from current to current+2 (within lookahead), if not already scheduled, schedule it and mark as scheduled. Clear scheduled flags for steps before current-1.
Let me rewrite the scheduling function more cleanly. Actually, let me also reconsider — maybe I'm overcomplicating this. Let me just rewrite the whole scheduling part to be simpler and correct.
Let me rewrite the scheduleStep function:
```js
function scheduleStep() {
if (!isPlaying) return;
const ctx = audioCtx;
const now = ctx.currentTime;
const secondsPerBeat = 60 / bpm;
const secondsPerStep = secondsPerBeat / 4;
if (stepStart === null) stepStart = now + 0.05; // small offset
const elapsed = now - stepStart;
const currentStepIndex = Math.floor(elapsed / secondsPerStep);
// Clear scheduled flags for past steps
for (const key of Array.from(scheduledSteps)) {
const keyTime = stepStart + key * secondsPerStep;
if (keyTime + secondsPerStep < now) {
scheduledSteps.delete(key);
}
}
// Schedule next few steps within lookahead window
for (let i = 0; i < 4; i++) {
const stepIdx = (currentStepIndex + i) % STEPS;
const stepTime = stepStart + (currentStepIndex + i) * secondsPerStep;
if (stepTime - now <= SCHEDULE_AHEAD_TIME + secondsPerStep && !scheduledSteps.has(stepIdx)) {
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
if (sequence[r][stepIdx]) {
INSTRUMENTS[r].fn(stepTime);
}
}
scheduledSteps.add(stepIdx);
}
}
// Update visuals
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
const all = grids[r].querySelectorAll('.step');
all.forEach(el => el.classList.remove('current'));
}
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
const el = grids[r].querySelectorAll('.step')[currentStepIndex % STEPS];
if (el) el.classList.add('current');
}
stepDisplay.textContent = currentStepIndex % STEPS;
timerId = setTimeout(scheduleStep, 25);
}
```
Wait, but there's still a problem with `scheduledSteps`. When a step is at index 5, say, and it gets scheduled and added to the set. Then the next iteration, currentStepIndex is 6, and we check `keyTime + secondsPerStep < now` → `stepStart + 5*s + s < now` → `stepStart + 6*s < now`. Since now is roughly at step 6, this might or might not be true. It depends on exact timing.
The key is: once a step has fully passed, we need to remove it from scheduledSteps so it can be played again in the next loop cycle. But we also need to make sure we don't schedule it before its time.
Actually, there's a simpler issue: the `scheduledSteps` uses step index as the key (0-15). But since the sequence loops, step 0 is played every 16 steps. So we need to clear the flag after the step is played and enough time has passed. The condition `keyTime + secondsPerStep < now` means "the step's scheduled time plus one step duration has passed", which should be a good indicator that this cycle's step 0 is done and we can reschedule for the next cycle.
Hmm, but actually there's another subtlety. Consider: step 0 is scheduled. currentStepIndex becomes 0, then 1, 2, ..., 15, then wraps to 0. When it wraps to 0, we need step 0 to not be in scheduledSteps. The clearing condition is `keyTime + secondsPerStep < now`. For step 0, keyTime = stepStart. At the next cycle, when currentStepIndex wraps to 0 again, now ≈ stepStart + 16 * secondsPerStep. So `stepStart + secondsPerStep < stepStart + 16*secondsPerStep` → this is true, so it gets cleared. Good.
But what about the visual? When currentStepIndex is 0, the visual shows step 0 as current. Then we need to play step 0. If scheduledSteps has step 0 from the previous cycle, we skip it. But we cleared it because `stepStart + 0 + secondsPerStep < now` is true (since now ≈ stepStart + 16*s). So it gets cleared. Good.
Actually wait, I think the timing works but it's fragile. Let me use a different approach that's more robust: instead of tracking scheduled steps in a set, just track which specific step in which cycle we've scheduled. Or even simpler, just schedule the current step + next 1 step each tick, and use `setTimeout` with precise timing.
Actually, let me reconsider. Maybe the simplest correct approach is:
1. Each tick (25ms), compute the current step index.
2. If we've moved to a new step (currentStepIndex !== prevScheduledStep), schedule audio for that step.
3. Schedule 2-3 steps ahead for better timing.
But to schedule ahead accurately, we need to use the audio context's `currentTime` and schedule at the right `time` values, not use setTimeout for timing.
Let me use a hybrid approach: use setTimeout to poll at a higher frequency, and schedule audio using the Web Audio API's scheduling (which is sample-accurate). Each poll:
- Check which steps need to be scheduled in the next ~100ms window
- Schedule unplayed steps
Let me rewrite more carefully. I'll use a simpler tracking mechanism.
Actually, I realize my original code is mostly right but has the dead code in playHiHat. Let me also reconsider the scheduling. Let me think about whether the set-based approach actually works:
Tick 1 (now = stepStart + 0.05s, assuming stepStart was set to now+0.05... actually let me trace through):
Let's say bpm = 120, so secondsPerStep = 0.125s.
- First call: stepStart = now (≈ 0). elapsed = 0. currentStepIndex = 0.
- scheduledSteps is empty.
- Loop i=0..3: stepIdx 0,1,2,3. stepTime = stepStart + i*s. For i=0: stepTime = stepStart ≈ now. stepTime - now ≈ 0 ≤ 0.1 + 0.125. Not in scheduledSteps → schedule, add to set.
- i=1: stepTime = stepStart + s ≈ now + 0.125. stepTime - now = 0.125 ≤ 0.225. Not scheduled → schedule, add.
- i=2: stepTime = now + 0.25. 0.25 ≤ 0.225? No, 0.25 > 0.225. So this is skipped by the condition... wait, 0.25 > 0.225 so it won't schedule. Hmm, that means only 2 steps ahead. Let me adjust the condition or increase lookahead. Actually 0.1 + 0.125 = 0.225. For i=2, stepTime - now = 0.25. 0.25 > 0.225. So it won't be scheduled. Then on the next tick (25ms later), it would be scheduled.
So effectively, each tick schedules 2 steps ahead (current and next), and the tick after schedules 1 step ahead plus current. This should work fine — every step gets scheduled before its time, as long as the 25ms tick catches it within 125ms before the step's scheduled time. Since 25ms < 125ms, this is fine.
Let me trace further:
- Tick 2 (now = stepStart + 0.025): elapsed = 0.025. currentStepIndex = 0. scheduledSteps has {0,1}.
- Clear: for key 0: keyTime = stepStart. keyTime + s = stepStart + 0.125. Is stepStart + 0.125 < stepStart + 0.025? No. For key 1: keyTime = stepStart + 0.125. keyTime + s = stepStart + 0.25. Is < stepStart + 0.025? No. So nothing cleared.
- Loop i=0..3:
- i=0: stepIdx 0, stepTime = stepStart. 0 ≤ 0.225? yes. But step 0 is in scheduledSteps → skip.
- i=1: stepIdx 1, stepTime = stepStart + 0.125. stepTime - now = 0.1. ≤ 0.225? yes. In set → skip.
- i=2: stepIdx 2, stepTime = stepStart + 0.25. 0.225 ≤ 0.225? yes. Not in set → schedule, add {0,1,2}.
- i=3: stepTime - now = 0.3. > 0.225 → skip.
- Tick 3 (now = stepStart + 0.05): elapsed = 0.05. currentStepIndex = 0. scheduledSteps has {0,1,2}.
- Clear: key 0: stepStart + 0.125 < stepStart + 0.05? No. Key 1: stepStart + 0.25 < stepStart + 0.05? No. Key 2: stepStart + 0.375 < stepStart + 0.05? No.
- i=0: in set → skip. i=1: in set → skip. i=2: in set → skip. i=3: stepTime = stepStart + 0.375. stepTime - now = 0.325 > 0.225 → skip.
- Tick 4 (now = stepStart + 0.075): similar, all in set, nothing new.
- Tick 5 (now = stepStart + 0.1): elapsed = 0.1. currentStepIndex = 0. scheduledSteps has {0,1,2}.
- Same as before, nothing new scheduled.
- Tick 6 (now = stepStart + 0.125): elapsed = 0.125. currentStepIndex = 1.
- Clear: key 0: stepStart + 0.125 < stepStart + 0.125? No (not strictly less). Hmm, edge case. Let me use `<=` instead, or it's okay because on the next tick it'll clear.
Actually, let me change the clear condition to `keyTime + secondsPerStep <= now` or just `keyTime < now - secondsPerStep * 0.5` or something. Actually, the issue is: when do we clear step 0's flag? We want to clear it when we know the next loop's instance of step 0 won't be confused. The step 0 was played at time stepStart. The next instance of step 0 will be at stepStart + 16 * s. We need to clear the flag before that time. So we can clear it anytime after `keyTime + s` (i.e., after the step has finished) but before the next cycle's step 0. The condition `keyTime + secondsPerStep < now` means "the step finished more than 0 seconds ago", which is too early — it would clear immediately after the step finishes, well before the next cycle.
Wait no. `keyTime + secondsPerStep < now` where keyTime = stepStart for step 0. So `stepStart + 0.125 < now`. This becomes true when now > stepStart + 0.125, i.e., when we've moved into step 1's territory. At that point, we clear step 0's flag.
So when we get to the next cycle (stepStart + 16 * 0.125 = stepStart + 2.0), step 0's flag was cleared long ago (when now first exceeded stepStart + 0.125). So step 0 can be rescheduled.
Let me continue the trace:
- Tick 6 (now = stepStart + 0.125): currentStepIndex = 1.
- Clear: key 0: stepStart + 0.125 < stepStart + 0.125? No (not strictly less). key 1: stepStart + 0.25 < stepStart + 0.125? No. key 2: stepStart + 0.375 < stepStart + 0.125? No.
- i=0: stepIdx 1. stepTime = stepStart + 0.125. stepTime - now = 0. ≤ 0.225? yes. In set? No, step 1 was scheduled but we're now looking at stepIdx=1. Wait, scheduledSteps has {0,1,2}. stepIdx for i=0 is currentStepIndex % 16 = 1. Is 1 in {0,1,2}? Yes → skip.
Hmm wait, I have a bug. When currentStepIndex = 1, the loop computes `stepIdx = (currentStepIndex + i) % STEPS`. For i=0, stepIdx = 1. The stepTime = stepStart + (1+0)*s = stepStart + 0.125 = now. So stepTime - now ≈ 0 ≤ 0.225, and stepIdx 1 is in the set → skip. For i=1, stepIdx = 2, stepTime = stepStart + 0.25. stepTime - now = 0.125 ≤ 0.225? yes. In set → skip. For i=2, stepIdx 3, stepTime = stepStart + 0.375. stepTime - now = 0.25 > 0.225 → skip. For i=3, skip.
- Tick 7 (now = stepStart + 0.15): elapsed = 0.15. currentStepIndex = 1 (floor(0.15/0.125) = 1). scheduledSteps = {0,1,2}.
- Clear: key 0: stepStart + 0.125 < stepStart + 0.15? Yes! Clear 0. Now {1,2}.
- i=0: stepIdx 1, in set → skip. i=1: stepIdx 2, in set → skip. i=2: stepIdx 3, stepTime-now = 0.225 ≤ 0.225? yes (0.375 - 0.15 = 0.225). Not in set → schedule, add {1,2,3}.
- i=3: skip.
OK so this works. Every step gets scheduled roughly 1-2 ticks before its time, which is within 25-50ms. That's fine for a drum machine. The scheduling is correct.
But there's still a potential issue: when `isPlaying` is set to false (stop), the `setTimeout` callback will still fire once more, but it checks `if (!isPlaying) return;` at the top. Good.
Now, there's a concern about the `stopSequence` using `clearTimeout(timerId)`. When we stop, the pending setTimeout is cleared, but there might be audio events still scheduled in the audio context. That's fine — they'll just play their natural decay. Actually, for a drum machine, that's acceptable. We could also cancel them, but it's not critical.
Let me also reconsider: when the page loads and the user clicks play, we call `scheduleStep()`. The first call sets `stepStart = now` where now = audioCtx.currentTime. Then we schedule steps from stepStart. The audio is scheduled at absolute times using `ctx.currentTime`, which is correct for Web Audio API.
Now, one more issue: the `getAudioCtx()` might return null if audioCtx hasn't been created yet. In `scheduleStep`, I use `audioCtx ? audioCtx.currentTime : ...`. But since we call `startSequence` which calls `getAudioCtx()` first (and the button handler calls `getAudioCtx` before `startSequence`), audioCtx should be set. Let me make sure.
In the play button handler:
```js
const ctx = getAudioCtx();
if (ctx.state === 'suspended') await ctx.resume();
startSequence();
```
And in `startSequence`, I call `scheduleStep()` which uses `audioCtx.currentTime`. Since `getAudioCtx()` was called and returned a valid context, `audioCtx` is set. Good.
Now let me fix the `playHiHat` function to remove the dead code. Let me also double-check all the synthesis functions for correctness.
For `playHiHat`, the dead first loop creates filters and buffer sources but never starts/stops them. I should remove it. The second part (noiseBuf1, noiseBuf2) is the actual working code. But wait, I never start/stop those either... let me re-read:
```js
src1.start(time); src1.stop(time + 0.06);
src2.start(time); src2.stop(time + 0.06);
```
Yes, the second part does start and stop them. So the first loop is purely dead code. Let me remove it.
Actually, I also notice the masterEnv gain is set but the sources are connected to it via filters. Let me verify: noiseBuf1's src1 → f1 → masterEnv → ctx.destination. Yes. And masterEnv.gain is set. Good.
Let me also reconsider: I should use `ctx.currentTime` for scheduling, but in `scheduleStep`, I wrote `const now = audioCtx ? audioCtx.currentTime : performance.now() / 1000;`. Since audioCtx is always set by this point, I can just use `audioCtx.currentTime`. But the fallback doesn't hurt. Let me keep it simple though.
Let me also reconsider the scheduling time values. When I call `INSTRUMENTS[r].fn(stepTime)`, the `stepTime` is an absolute time in the audio context's timeline. The synth functions use this as the `time` parameter for all scheduling calls. Good.
One more thing: the `SCHEDULE_AHEAD_TIME = 0.1`. With 25ms tick, this means we look ~100ms ahead. With steps being 125ms apart at 120 BPM, that's about 1 step ahead. Combined with the loop going up to i=3, we schedule up to 4 steps ahead but only play those within 100ms + 125ms = 225ms of the current time. So at most 2 steps ahead are scheduled per tick. This is fine.
Actually, I realize I should also handle the case where the browser tab loses focus or the audio context state changes. The visibilitychange handler stops the sequence, which is good.
Let me now fix the playHiHat function. Let me rewrite the whole file cleanly to avoid the dead code.
Actually, instead of rewriting the whole file, let me just fix the playHiHat function with an edit.
Let me also reconsider the scheduling algorithm. I want to make sure it's robust. Let me simplify it a bit. Instead of the set-based approach with clearing logic, let me just track the last scheduled step and schedule ahead from there.
Actually, the set-based approach works as I traced through. Let me just fix playHiHat and make the scheduleStep function cleaner.
Let me re-read my scheduleStep implementation more carefully...
```js
function scheduleStep() {
if (!isPlaying) return;
const now = audioCtx ? audioCtx.currentTime : performance.now() / 1000;
const secondsPerBeat = 60 / bpm;
const secondsPerStep = secondsPerBeat / 4;
if (stepStart === null) stepStart = now;
const elapsed = now - stepStart;
const nextStepIndex = Math.floor(elapsed / secondsPerStep) % STEPS;
const stepsToPlay = [];
for (let i = 0; i < STEPS; i++) {
const stepIdx = (nextStepIndex + i) % STEPS;
const t = stepStart + (nextStepIndex + i) * secondsPerStep;
if (!scheduledSteps.has(stepIdx)) {
stepsToPlay.push({ idx: stepIdx, time: t });
scheduledSteps.add(stepIdx);
}
}
...
```
Wait, this adds ALL 16 steps to scheduledSteps on the first tick! Because none of them are in the set initially. That's wrong — I don't want to schedule all 16 steps at once. I only want to schedule steps within the lookahead window.
Oh wait, I see — in my original code, I only push to `stepsToPlay` for all non-scheduled steps, but then I only actually call the synth functions for those within the lookahead window:
```js
for (const sp of stepsToPlay) {
if (sp.time - now <= SCHEDULE_AHEAD_TIME + secondsPerStep) {
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
if (sequence[r][sp.idx]) {
INSTRUMENTS[r].fn(sp.time);
}
}
}
}
```
And I add them all to scheduledSteps. This is inefficient — I'm adding all 16 steps to the set, and then they won't be rescheduled until they're cleared. The clearing happens when `keyTime < now - secondsPerStep`. So step 0 gets cleared when now > stepStart + s (after the step passes). But steps 15 would only get cleared 15 steps later. And since I add all 16 to the set, step 0 won't be rescheduled until it's cleared.
The problem: on the first tick, I add steps 0-15 to scheduledSteps. Step 0 and 1 get audio scheduled. Steps 2-15 are added to the set but not yet played. On the next tick, steps 2 might get scheduled (if within lookahead), but 0 and 1 are still in the set. After step 0 finishes (now > stepStart + s), step 0 gets cleared, and can be scheduled again in the next cycle.
Actually, this does work correctly — it just adds all steps to the set upfront, and they get cleared one by one as they pass. The audio is only scheduled for steps within the lookahead window. So the set just tracks "have I already scheduled this step in this cycle?" And it gets cleared after the step passes. This is actually correct!
But there's a performance issue: I'm creating a `stepsToPlay` array of 16 elements every tick, even though I only use 2 of them. And I'm adding all 16 to the set. Let me optimize this slightly, but it's not a correctness issue.
Hmm, actually wait. There's a subtle bug. If I add all 16 steps to the set on the first tick, then on the second tick, `nextStepIndex` might be 0 (if 25ms passed, which is less than 125ms). So nextStepIndex is still 0. Then step 0 is in the set → skip. Step 1 is in the set → skip. Step 2 is in the set → skip (but it was added on the first tick but not yet played, and its time hasn't arrived). When step 2's time arrives, it should be played. But it's in the set, so it won't be played!
This is a bug! The step gets added to scheduledSteps before its time, but the audio is never scheduled for it (because it was outside the lookahead window). Then when its time comes, it's already in the set, so it's skipped.
I need to fix this. The set should only contain steps that have been audio-scheduled, not just "seen". Or I should only add to the set when I actually schedule the audio.
Let me rewrite the scheduling function to be correct. Here's my revised approach:
```js
function scheduleStep() {
if (!isPlaying) return;
const ctx = audioCtx;
const now = ctx.currentTime;
const secondsPerBeat = 60 / bpm;
const secondsPerStep = secondsPerBeat / 4;
if (stepStart === null) stepStart = now;
const elapsed = now - stepStart;
const currentStepIdx = Math.floor(elapsed / secondsPerStep) % STEPS;
// Update visuals
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
const all = grids[r].querySelectorAll('.step');
all.forEach(el => el.classList.remove('current'));
}
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
const el = grids[r].querySelectorAll('.step')[currentStepIdx];
if (el) el.classList.add('current');
}
stepDisplay.textContent = currentStepIdx;
// Schedule unplayed steps within the lookahead window
for (let i = 0; i < STEPS; i++) {
const stepIdx = (currentStepIdx + i) % STEPS;
const stepTime = stepStart + (currentStepIdx + i) * secondsPerStep;
const timeDiff = stepTime - now;
if (timeDiff < 0) {
// Step has already passed this cycle — clear and continue
scheduledSteps.delete(stepIdx);
continue;
}
if (timeDiff <= SCHEDULE_AHEAD_TIME + secondsPerStep && !scheduledSteps.has(stepIdx)) {
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
if (sequence[r][stepIdx]) {
INSTRUMENTS[r].fn(stepTime);
}
}
scheduledSteps.add(stepIdx);
}
}
timerId = setTimeout(scheduleStep, 25);
}
```
This is cleaner. The key difference: I only add to `scheduledSteps` when I actually schedule the audio. And I clear steps that have already passed (timeDiff < 0).
Let me trace through this:
- Tick 1 (now = stepStart): elapsed = 0. currentStepIdx = 0.
- i=0: stepIdx 0, stepTime = stepStart. timeDiff = 0. 0 ≤ 0.1 + 0.125 = 0.225. Not in set → schedule step 0's instruments. Add 0 to set.
- i=1: stepIdx 1, stepTime = stepStart + 0.125. timeDiff = 0.125. ≤ 0.225. Not in set → schedule. Add 1.
- i=2: stepIdx 2, stepTime = stepStart + 0.25. timeDiff = 0.25. > 0.225 → skip (don't add to set).
- i=3+: even larger timeDiffs → skip.
- Set: {0, 1}
- Tick 2 (now = stepStart + 0.025): currentStepIdx = 0.
- i=0: stepIdx 0, timeDiff = -0.025 < 0. Delete 0 from set. Set: {}. Wait, that's wrong! The step hasn't finished yet (it finishes at stepStart + 0.125). timeDiff = stepTime - now = stepStart - (stepStart + 0.025) = -0.025. So the step's scheduled time is 25ms in the past. But the step is still playing (it has a 125ms duration).
Hmm, the issue is: `timeDiff < 0` means the step's scheduled start time has passed, not that the step has finished. I should only clear the set entry when the step has fully completed. A step scheduled at `stepTime` takes about `secondsPerStep` to complete (the next step starts `secondsPerStep` later). So I should clear when `stepTime + secondsPerStep < now`, i.e., `now - stepTime > secondsPerStep`, i.e., `stepTime - now < -secondsPerStep`.
But actually, for the scheduling purpose, I don't need to clear the set entry for a step that has already been played in this cycle. I only need to clear it so it can be rescheduled in the NEXT cycle. The next cycle starts 16 steps later. So I can clear any step whose time is in the past:
Actually, the problem is more subtle. The `scheduledSteps` set has entries 0-15. When the sequence loops, step 0 needs to be playable again. So I need to clear step 0's entry before the next loop's step 0. The next loop's step 0 is at `stepStart + 16 * secondsPerStep`. So I need to clear step 0 before that time, which happens naturally since step 0's timeDiff becomes negative at `stepStart + epsilon`.
But if I clear it when `timeDiff < 0` (i.e., `stepTime < now`), that means I clear it as soon as the step's scheduled time passes. Then on the next tick (25ms later), the step is not in the set, and I check `timeDiff <= SCHEDULE_AHEAD_TIME + secondsPerStep`. Since the step's time has passed, `timeDiff` is still negative, and I'd push it to the `timeDiff < 0` branch and delete it again. So the step never gets rescheduled in the same cycle, which is correct (we don't want to play it twice). And when the next cycle comes (16 steps later), `timeDiff` will be large negative, and... hmm, this doesn't work for the next cycle.
Wait, let me reconsider. When the next cycle comes:
- currentStepIdx = 0 (wrapped around). stepStart is still the original stepStart (it doesn't change).
- stepTime = stepStart + (0 + 0) * secondsPerStep = stepStart.
- timeDiff = stepStart - now = stepStart - (stepStart + 16*secondsPerStep) = -16*secondsPerStep. This is very negative.
- So it goes to the `timeDiff < 0` branch: delete from set, continue.
- It never gets rescheduled!
This is a bug. The step's scheduled time is always in the past once the cycle is done. I need a different approach.
The fundamental issue: I'm using absolute step indices (0-15) in the set, but the sequence loops. I need to track either (cycle, step) or use a different clearing strategy.
Let me think differently. Instead of tracking which steps have been scheduled, let me track the step index of the last scheduled step. Then I schedule from the last scheduled step to the lookahead window.
Or even simpler: I can use a "scheduled up to" approach. Track `lastScheduledStep` (a monotonically increasing counter, not modulo). On each tick, schedule all steps from `lastScheduledStep + 1` up to the lookahead window.
```js
let lastScheduledStep = -1; // absolute step counter
function scheduleStep() {
if (!isPlaying) return;
const ctx = audioCtx;
const now = ctx.currentTime;
const secondsPerBeat = 60 / bpm;
const secondsPerStep = secondsPerBeat / 4;
if (stepStart === null) stepStart = now;
const elapsed = now - stepStart;
const currentStep = Math.floor(elapsed / secondsPerStep);
const currentStepIdx = ((currentStep % STEPS) + STEPS) % STEPS;
// Update visuals
...
// Schedule steps from lastScheduledStep+1 up to lookahead
const lookaheadSteps = Math.ceil((SCHEDULE_AHEAD_TIME + secondsPerStep) / secondsPerStep);
for (let step = lastScheduledStep + 1; step <= currentStep + lookaheadSteps; step++) {
const stepIdx = ((step % STEPS) + STEPS) % STEPS;
const stepTime = stepStart + step * secondsPerStep;
for (let r = 0; r < NUM_INSTRUMENTS; r++) {
if (sequence[r][stepIdx]) {
INSTRUMENTS[r].fn(stepTime);
}
}
}
lastScheduledStep = currentStep + lookaheadSteps;
timerId = setTimeout(scheduleStep, 25);
}
```
This is cleaner! `lastScheduledStep` is an absolute counter (e.g., 0, 1, 2, ..., 15, 16, 17, ...). Each tick, we schedule all steps from the last scheduled one up to the current step + lookahead. Since `step` is an absolute counter, the modulo operation gives us the grid index. And we never skip a step because `lastScheduledStep` keeps increasing.
When we stop, we reset `lastScheduledStep = -1`.
The `lookaheadSteps` ensures we always schedule at least one step ahead of the current one. Let me compute: at 120 BPM, secondsPerStep = 0.125. SCHEDULE_AHEAD_TIME = 0.1. lookaheadSteps = ceil((0.1 + 0.125) / 0.125) = ceil(1.8) = 2. So we schedule 2 steps ahead. With a 25ms tick, that's plenty.
At 200 BPM, secondsPerStep = 60/200/4 = 0.075. lookaheadSteps = ceil((0.1 + 0.075) / 0.075) = ceil(2.33) = 3. Fine.
At 60 BPM, secondsPerStep = 60/60/4 = 0.25. lookaheadSteps = ceil((0.1 + 0.25) / 0.25) = ceil(1.4) = 2. Fine.
This approach is much cleaner. Let me rewrite the whole file with this fixed scheduling approach and the fixed playHiHat.
Actually, let me also make sure the `lastScheduledStep` reset happens on stop and on start. In `startSequence`, I set `stepStart = null` and `lastScheduledStep = -1`. In `stopSequence`, I also reset.
Let me rewrite the file completely to be clean and correct.
Also, I want to make sure the visual feedback for the current step is smooth. I'll highlight the current step column across all rows.
Let me also consider: should I highlight only the current step in each row, or highlight the same column across all rows? Currently, my code highlights the step with `currentStepIdx` class in each grid. Let me update the CSS/JS to highlight by column.
Actually, let me restructure the grid to be a single table-like grid where each row has the instrument label and 16 steps. Let me keep it as separate grids (one per instrument) and highlight the same column index in all of them. That's what I'm already doing — I iterate over all grids and add 'current' to the step at `currentStepIdx`.
Let me rewrite the file now, incorporating all fixes.The file is created, but let me review the scheduling logic and hi-hat function for correctness before finalizing:
{"path":"/tmp/ws/index.html","offset":1}<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>16-Step Web Audio Drum Machine</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; overflow: hidden; } body { font-family: 'Courier New', Courier, monospace; background: #0a0a12; color: #e0e0e0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; padding: 20px; } .container { max-width: 980px; width: 100%; } h1 { text-align: center; font-size: 2.2em; letter-spacing: 3px; margin-bottom: 10px; color: #4fc3f7; text-shadow: 0 0 10px rgba(79,195,247,0.5); } .subtitle { text-align: center; color: #888; font-size: 0.9em; margin-bottom: 30px; } .instrument-row { display: flex; align-items: center; margin-bottom: 12px; } .instrument-label { width: 90px; font-size: 0.85em; color: #4fc3f7; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; padding: 8px 12px; border-radius: 4px; background: rgba(255,255,255,0.03); text-align: right; margin-right: 10px; } .grid { display: flex; gap: 2px; background: #1a1a22; border: 2px solid #222; border-radius: 6px; padding: 2px; overflow: hidden; } .step { width: 42px; height: 42px; background: #1a1a2e; border: 1px solid #0f0f17; cursor: pointer; transition: all 0.1s ease; display: flex; align-items: center; justify-content: center; position: relative; } .step::before { content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 3px; background: transparent; } .step.on { background: #4fc3f7; box-shadow: 0 0 12px rgba(79,195,247,0.6); } .step.on::before { background: rgba(255,255,255,0.3); } .step.current { background: #66bb6a; box-shadow: 0 0 14px rgba(102,187,106,0.7); animation: pulse 0.3s ease; } .step.current.on { background: #7ddb77; box-shadow: 0 0 16px rgba(102,187,106,0.8), 0 0 8px rgba(79,195,247,0.5); } .step.current::before { background: rgba(255,255,255,0.3); } @keyframes pulse { 0% { transform: scale(1); } 30% { transform: scale(1.04); } 100% { transform: scale(1); } } .controls { display: flex; align-items: center; justify-content: center; gap: 30px; margin-top: 30px; padding: 20px; background: #1a1a22; border-radius: 8px; border: 1px solid #222; } .bpm-control { display: flex; align-items: center; gap: 12px; } .bpm-label { font-size: 0.9em; color: #aaa; } .bpm-value { font-size: 1.3em; font-weight: bold; color: #4fc3f7; min-width: 40px; text-align: center; } #bpm { width: 150px; height: 24px; accent-color: #4fc3f7; cursor: pointer; } #play-btn { padding: 14px 40px; font-size: 1.1em; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; color: #fff; } #play-btn.play { background: #66bb6a; box-shadow: 0 0 15px rgba(102,187,106,0.5); } #play-btn.play:hover { background: #7ddb77; box-shadow: 0 0 20px rgba(102,187,106,0.7); } #play-btn.stop { background: #ef5350; box-shadow: 0 0 15px rgba(239,83,80,0.5); } #play-btn.stop:hover { background: #ff6b68; box-shadow: 0 0 20px rgba(239,83,80,0.7); } .step-indicator { margin-top: 15px; font-size: 0.85em; color: #888; text-align: center; } .kick-swatch { color: #ff8a65; } .snare-swatch { color: #ffd54f; } .hihat-swatch { color: #9575cd; } .clap-swatch { color: #ef5350; } </style> </head> <body> <div class="container"> <h1>DRUM MACHINE</h1> <p class="subtitle">16-Step Sequencer with Web Audio API Synthesis</p> <div class="instrument-row" id="row-0"> <div class="instrument-label kick-swatch">Kick</div> <div class="grid" id="grid-0"></div> </div> <div class="instrument-row" id="row-1"> <div class="instrument-label snare-swatch">Snare</div> <div class="grid" id="grid-1"></div> </div> <div class="instrument-row" id="row-2"> <div class="instrument-label hihat-swatch">Hi-Hat</div> <div class="grid" id="grid-2"></div> </div> <div class="instrument-row" id="row-3"> <div class="instrument-label clap-swatch">Clap</div> <div class="grid" id="grid-3"></div> </div> <div class="controls"> <div class="bpm-control"> <span class="bpm-label">BPM</span> <input type="range" id="bpm" min="60" max="200" value="120"> <span class="bpm-value" id="bpm-display">120</span> </div> <button id="play-btn" class="play">PLAY</button> </div> <div class="step-indicator"> Step <span id="step-display">0</span> / 16 </div> </div> <script> // ─── Audio Context ────────────────────────────────────────────── let audioCtx = null; function getAudioCtx() { if (audioCtx === null || audioCtx.state === 'closed') { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } return audioCtx; } // ─── Sound Synth Functions ────────────────────────────────────── function playKick(time) { const ctx = getAudioCtx(); // Envelope-controlled oscillator for the kick body const env = ctx.createGain(); env.connect(ctx.destination); // Main pitch sweep oscillator const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(180, time); // start frequency osc.frequency.exponentialRampToValueAtTime(40, time + 0.45); // decay osc.connect(env); // Quick gain envelope env.gain.setValueAtTime(0.001, time); env.gain.exponentialRampToValueAtTime(0.8, time + 0.005); env.gain.exponentialRampToValueAtTime(0.001, time + 0.45); osc.start(time); osc.stop(time + 0.5); // Sub-frequency noise for click/attack const noiseEnv = ctx.createGain(); noiseEnv.connect(ctx.destination); const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate); const data = noiseBuf.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2); } const noiseSrc = ctx.createBufferSource(); noiseSrc.buffer = noiseBuf; noiseSrc.connect(noiseEnv); noiseEnv.gain.setValueAtTime(0.4, time); noiseEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.05); noiseSrc.start(time); noiseSrc.stop(time + 0.05); } function playSnare(time) { const ctx = getAudioCtx(); // Body: short sine at ~220Hz with quick decay const bodyEnv = ctx.createGain(); bodyEnv.connect(ctx.destination); const bodyOsc = ctx.createOscillator(); bodyOsc.type = 'sine'; bodyOsc.frequency.setValueAtTime(220, time); bodyOsc.connect(bodyEnv); bodyEnv.gain.setValueAtTime(0.001, time); bodyEnv.gain.exponentialRampToValueAtTime(0.7, time + 0.003); bodyEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.15); bodyOsc.start(time); bodyOsc.stop(time + 0.15); // Noise: bandpass-filtered white noise const noiseEnv = ctx.createGain(); noiseEnv.connect(ctx.destination); const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(1000, time); filter.Q = 1; const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.15, ctx.sampleRate); const data = noiseBuf.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = Math.random() * 2 - 1; } const noiseSrc = ctx.createBufferSource(); noiseSrc.buffer = noiseBuf; noiseSrc.connect(filter); filter.connect(noiseEnv); noiseEnv.gain.setValueAtTime(0.001, time); noiseEnv.gain.exponentialRampToValueAtTime(0.9, time + 0.005); noiseEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.15); noiseSrc.start(time); noiseSrc.stop(time + 0.15); } function playHiHat(time) { const ctx = getAudioCtx(); const masterEnv = ctx.createGain(); masterEnv.connect(ctx.destination); // Two noise bands for richer hi-hat for (let i = 0; i < 2; i++) { const filter = ctx.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.setValueAtTime(7000 + i * 1500, time); const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate); const data = noiseBuf.getChannelData(0); for (let j = 0; j < data.length; j++) { data[j] = Math.random() * 2 - 1; } const noiseSrc = ctx.createBufferSource(); noiseSrc.buffer = noiseBuf; noiseSrc.connect(filter); filter.connect(masterEnv); } masterEnv.gain.setValueAtTime(0.001, time); masterEnv.gain.exponentialRampToValueAtTime(0.5, time + 0.002); masterEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.06); // Start/stop handled per-source // We need to track sources to stop them; rebuild with stops // (Simplified: sources auto-stop via buffer length, but let's explicitly stop) // Actually the above connects both filters; let's restart cleanly: const noiseBuf1 = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate); const d1 = noiseBuf1.getChannelData(0); for (let j = 0; j < d1.length; j++) d1[j] = Math.random() * 2 - 1; const src1 = ctx.createBufferSource(); src1.buffer = noiseBuf1; const f1 = ctx.createBiquadFilter(); f1.type = 'bandpass'; f1.frequency.setValueAtTime(9000, time); src1.connect(f1); f1.connect(masterEnv); const noiseBuf2 = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate); const d2 = noiseBuf2.getChannelData(0); for (let j = 0; j < d2.length; j++) d2[j] = Math.random() * 2 - 1; const src2 = ctx.createBufferSource(); src2.buffer = noiseBuf2; const f2 = ctx.createBiquadFilter(); f2.type = 'bandpass'; f2.frequency.setValueAtTime(11000, time); src2.connect(f2); f2.connect(masterEnv); src1.start(time); src1.stop(time + 0.06); src2.start(time); src2.stop(time + 0.06); } function playClap(time) { const ctx = getAudioCtx(); // Main noise burst const env = ctx.createGain(); env.connect(ctx.destination); const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(800, time); filter.Q = 2; const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate); const data = noiseBuf.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = Math.random() * 2 - 1; } const noiseSrc = ctx.createBufferSource(); noiseSrc.buffer = noiseBuf; noiseSrc.connect(filter); filter.connect(env); env.gain.setValueAtTime(0.001, time); env.gain.exponentialRampToValueAtTime(1.0, time + 0.003); env.gain.exponentialRampToValueAtTime(0.2, time + 0.05); env.gain.exponentialRampToValueAtTime(0.001, time + 0.2); noiseSrc.start(time); noiseSrc.stop(time + 0.2); // Second delayed noise burst for clap "slapback" const env2 = ctx.createGain(); env2.connect(ctx.destination); const filter2 = ctx.createBiquadFilter(); filter2.type = 'bandpass'; filter2.frequency.setValueAtTime(800, time); filter2.Q = 2; const noiseBuf2 = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate); const data2 = noiseBuf2.getChannelData(0); for (let i = 0; i < data2.length; i++) { data2[i] = Math.random() * 2 - 1; } const noiseSrc2 = ctx.createBufferSource(); noiseSrc2.buffer = noiseBuf2; noiseSrc2.connect(filter2); filter2.connect(env2); env2.gain.setValueAtTime(0.001, time + 0.05); env2.gain.exponentialRampToValueAtTime(0.7, time + 0.053); env2.gain.exponentialRampToValueAtTime(0.001, time + 0.25); noiseSrc2.start(time + 0.05); noiseSrc2.stop(time + 0.25); } const INSTRUMENTS = [ { name: 'kick', fn: playKick }, { name: 'snare', fn: playSnare }, { name: 'hihat', fn: playHiHat }, { name: 'clap', fn: playClap } ]; // ─── Sequence State ───────────────────────────────────────────── const STEPS = 16; const NUM_INSTRUMENTS = INSTRUMENTS.length; let sequence = []; for (let i = 0; i < NUM_INSTRUMENTS; i++) { sequence.push(new Array(STEPS).fill(false)); } let currentStep = -1; let isPlaying = false; let bpm = 120; let timerId = null; let stepStart = null; let scheduledSteps = new Set(); // ─── Grid Build ────────────────────────────────────────────────── const grids = []; for (let r = 0; r < NUM_INSTRUMENTS; r++) { const gridEl = document.getElementById('grid-' + r); grids.push(gridEl); for (let s = 0; s < STEPS; s++) { const stepEl = document.createElement('div'); stepEl.classList.add('step'); stepEl.dataset.row = r; stepEl.dataset.step = s; stepEl.addEventListener('click', () => { sequence[r][s] = !sequence[r][s]; stepEl.classList.toggle('on'); }); gridEl.appendChild(stepEl); } } // ─── Play / Stop ───────────────────────────────────────────────── const playBtn = document.getElementById('play-btn'); const bpmDisplay = document.getElementById('bpm-display'); const stepDisplay = document.getElementById('step-display'); document.getElementById('bpm').addEventListener('input', (e) => { bpm = parseInt(e.target.value); bpmDisplay.textContent = bpm; }); bpmDisplay.textContent = bpm; playBtn.addEventListener('click', async () => { if (!isPlaying) { // Unlock audio context on first user gesture const ctx = getAudioCtx(); if (ctx.state === 'suspended') await ctx.resume(); startSequence(); } else { stopSequence(); } }); function startSequence() { isPlaying = true; playBtn.textContent = 'STOP'; playBtn.classList.remove('play'); playBtn.classList.add('stop'); currentStep = -1; scheduledSteps.clear(); stepStart = null; scheduleStep(); } function stopSequence() { isPlaying = false; playBtn.textContent = 'PLAY'; playBtn.classList.remove('stop'); playBtn.classList.add('play'); // Clear any pending timer if (timerId) { clearTimeout(timerId); timerId = null; } // Reset step visuals for (let r = 0; r < NUM_INSTRUMENTS; r++) { const all = grids[r].querySelectorAll('.step'); all.forEach(el => el.classList.remove('current')); } currentStep = -1; stepDisplay.textContent = '0'; } const SCHEDULE_AHEAD_TIME = 0.1; function scheduleStep() { if (!isPlaying) return; const now = audioCtx ? audioCtx.currentTime : performance.now() / 1000; const secondsPerBeat = 60 / bpm; // Each step = 1/4 beat (semiquaver) → 4 steps per beat const secondsPerStep = secondsPerBeat / 4; if (stepStart === null) stepStart = now; // Determine next step index based on elapsed time const elapsed = now - stepStart; const nextStepIndex = Math.floor(elapsed / secondsPerStep) % STEPS; const stepsToPlay = []; for (let i = 0; i < STEPS; i++) { const stepIdx = (nextStepIndex + i) % STEPS; const t = stepStart + (nextStepIndex + i) * secondsPerStep; if (!scheduledSteps.has(stepIdx)) { stepsToPlay.push({ idx: stepIdx, time: t }); scheduledSteps.add(stepIdx); } } // Update visual: clear previous current, set new for (let r = 0; r < NUM_INSTRUMENTS; r++) { const all = grids[r].querySelectorAll('.step'); all.forEach(el => el.classList.remove('current')); } for (let r = 0; r < NUM_INSTRUMENTS; r++) { const el = grids[r].querySelectorAll('.step')[nextStepIndex]; if (el) el.classList.add('current'); } stepDisplay.textContent = nextStepIndex; // Schedule audio for each step in the lookahead window for (const sp of stepsToPlay) { if (sp.time - now <= SCHEDULE_AHEAD_TIME + secondsPerStep) { for (let r = 0; r < NUM_INSTRUMENTS; r++) { if (sequence[r][sp.idx]) { INSTRUMENTS[r].fn(sp.time); } } } } // Clear scheduled flags for steps that have passed for (const key of scheduledSteps) { const keyTime = stepStart + parseInt(key) * secondsPerStep; if (keyTime < now - secondsPerStep) { scheduledSteps.delete(key); } } // Schedule next tick timerId = setTimeout(scheduleStep, 25); } // ─── Initialize ────────────────────────────────────────────────── window.addEventListener('load', () => { stepDisplay.textContent = '0'; }); // Handle page visibility — stop audio if hidden document.addEventListener('visibilitychange', () => { if (document.hidden && isPlaying) { stopSequence(); } }); </script> </body> </html>
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
Drum machine grid UI renders correctly but no visible reaction to click, drag, wheel, or keyboard inputs in screenshots.
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.