How to Make a Kick Drum with the Web Audio API

14 minutes

Originally published on Web Audio Studio blog.

Most kicks you hear are samples. But under the hood, a kick can be surprisingly simple: a falling sine wave for the body, and a tiny burst of noise for the click.

That is what makes kick synthesis so satisfying. Instead of scrolling through sample packs, you can build the sound from first principles, understand exactly why it hits the way it does, and shape every part of it to fit your track.

This matters even more if you work with the Web Audio API, because these sounds are not magic. They come from a few small building blocks you can wire together, automate, and tweak in real time.

Open the live graph in Web Audio Studio → and hear it before reading the code.

How a kick drum works

The body starts as a simple sine oscillator. Right after the hit, its pitch falls fast from about 150 Hz to 40 Hz in roughly 50 ms. That quick drop is what gives the kick its heavy “boom.” At the same time, the volume fades down, so the body feels like a short drum hit instead of a constant tone.

The click sits on top of that body. We make it from white noise, then shape it with a band-pass filter around 2 kHz and a very short gain envelope. The result is a small bright transient at the start of the sound, which helps the kick cut through the mix. After that, both layers go through their own gain nodes and are mixed into one output.

The audio graph

Here’s the full node layout we’ll build:

NodeRole
OscillatorNodeSine wave — the body of the kick
AudioWorkletNodeWhite noise generator — the click transient
BiquadFilterNodeBand-pass filter shaping the noise
GainNode (sine)Amplitude envelope for the body
GainNode (noise)Amplitude envelope for the click
GainNode (mix)Merges both layers
GainNode (master)Final output level
AudioDestinationNodeSpeakers

This is the mental shift that makes the Web Audio API click: we are not creating a brand new sound every time the kick plays. We build the graph once, keep the oscillator and noise source running, and then shape that live signal over time with gain and frequency automation. In other words, we are not triggering a clip, we are sculpting a sound.

Here is what this audio graph looks like visually:

Visual layout of the kick drum audio graph in Web Audio Studio
Visual layout of the kick drum audio graph in Web Audio Studio

Building it step by step

1. Start with one sine oscillator

The simplest kick starts with one sine oscillator. We connect it straight to the output and start it.

An oscillator is just a sound source that generates a repeating waveform. Here it gives us a steady sine tone, which will become the low body of the kick.

This gives us a plain low sine tone. It is not a real kick yet, but it gives us the low body we need.

What you should hear: a flat low tone that keeps going. It has weight, but it does not feel like a drum hit yet.

The code examples in this article use plain Web Audio API code to keep the signal flow easy to see. In a real app, you usually create or resume the AudioContext from a user action like a button click, because many browsers block audio until the user interacts with the page.

const ctx = new AudioContext();

const sineOsc = ctx.createOscillator();
sineOsc.type = "sine";
sineOsc.frequency.value = 150;

sineOsc.connect(ctx.destination);

sineOsc.start();

Open this step in Web Audio Studio and hear the raw oscillator →

2. Schedule the pitch drop

Now we make the sound change over time.

A kick sounds like a kick because the pitch moves very fast right after the hit. It starts higher, then drops down in a few milliseconds. That fast pitch drop gives the sound its kick shape, and once the oscillator falls low enough, the tail becomes much less audible on many speakers.

This already sounds much closer to a kick, even though we are still using only one oscillator.

What you should hear: the front of the sound drops fast, and the tail fades into a low rumble. It still feels unfinished, but now it starts to resemble a kick.

const ctx = new AudioContext();

const sineOsc = ctx.createOscillator();
sineOsc.type = "sine";
sineOsc.frequency.value = 150;

sineOsc.connect(ctx.destination);

sineOsc.start();

const when = ctx.currentTime;

sineOsc.frequency.setValueAtTime(150, when);
sineOsc.frequency.exponentialRampToValueAtTime(40, when + 0.05);

Open this step in Web Audio Studio and hear the pitch drop →

3. Add a gain envelope to the sine body

The pitch drop gets us closer to a kick, but the oscillator is still running all the time. Now we add sineGain so the body can fade in and fade out like a real drum hit.

This is the amplitude envelope of the body. In synth terms, this is the part that behaves like a very simple ADSR (Attack, Decay, Sustain, Release).

const ctx = new AudioContext();

const sineOsc = ctx.createOscillator();
sineOsc.type = "sine";
sineOsc.frequency.value = 150;

const sineGain = ctx.createGain();
sineGain.gain.value = 0;

sineOsc.connect(sineGain);
sineGain.connect(ctx.destination);

sineOsc.start();

const when = ctx.currentTime;

sineOsc.frequency.setValueAtTime(150, when);
sineOsc.frequency.exponentialRampToValueAtTime(40, when + 0.05);

sineGain.gain.setValueAtTime(0, when);
sineGain.gain.linearRampToValueAtTime(1, when + 0.005);
sineGain.gain.exponentialRampToValueAtTime(0.001, when + 0.4);
sineGain.gain.setValueAtTime(0, when + 0.401);

Open this step in Web Audio Studio and hear the body take shape →

What you should hear: this is the point where the sound stops feeling like a synth test and starts feeling like a real low drum hit.

If you only change a few things here, change these:

  • 150 is a balanced starting pitch. Try 120 for a rounder, softer front, or 180 for a more aggressive hit.
  • 40 is where the body settles. Try 30 for more sub weight, or 50 if you want the tail to stay more audible on small speakers.
  • 0.05 gives a natural pitch fall. Try 0.03 for a tighter, more techno-like kick, or 0.08 for a longer drop that starts leaning toward an 808-style feel.
  • 0.4 gives a medium-length body. Try 0.2 for a shorter, drier kick, or 0.7 for a longer low-end tail.

The other values matter too, but these four will teach you the sound of the patch fastest. Once those make sense, you can explore the smaller details in Web Audio Studio.

4. The body is ready, but it still needs a click

At this point the low body works, but the kick is still too soft and too smooth. Real kicks usually have a short click at the start. That click helps the ear find the transient, and it helps the kick cut through the mix, especially on small speakers.

We will make that click with a very short burst of filtered white noise.

White noise is a random signal that contains lots of frequencies at once. On its own it sounds like hiss. If we shape it with a filter and a very fast gain envelope, it becomes a useful click.

To generate white noise cleanly in Web Audio, we will use an AudioWorkletProcessor.

What you should hear: on its own, white noise sounds like plain hiss. It is not musical yet, but it gives us the raw material for the click.

class WhiteNoiseProcessor extends AudioWorkletProcessor {
  process(inputs, outputs) {
    const output = outputs[0];
    for (let channel = 0; channel < output.length; channel++) {
      const buf = output[channel];
      for (let i = 0; i < buf.length; i++) {
        buf[i] = Math.random() * 2 - 1;
      }
    }
    return true;
  }
}

registerProcessor("white-noise-processor", WhiteNoiseProcessor);

5. Load the worklet in the main file

Now we load that processor and create a node from it. This gives us a live white-noise source inside our main graph.

In a real project, white-noise-processor.js needs to be served as a real file URL. A simple option is to put it in /public and load it as /white-noise-processor.js.

await ctx.audioWorklet.addModule("/white-noise-processor.js");

const noise = new AudioWorkletNode(ctx, "white-noise-processor", {
  numberOfInputs: 0,
  numberOfOutputs: 1,
});

6. Route both sources into the output

Before we shape the click further, it helps to put both sound sources into the same output path. We will use a mix bus and a master gain, then connect both the sine body and the raw noise into that chain.

This gives us one place to combine layers and one final output before ctx.destination.

const mix = ctx.createGain();
const master = ctx.createGain();

sineOsc.connect(sineGain);
sineGain.connect(mix);

noise.connect(mix);
mix.connect(master);
master.connect(ctx.destination);

Open this step in Web Audio Studio and hear both layers together →

Right now this will sound like the sine body plus raw white noise. The routing is in place, but the noise is still too broad. In the next step we will focus its tone, and after that we will shape its timing.

What you should hear: a kick-like low body, but with a noisy layer on top that is still too wide and too messy.

7. Filter the noise

Raw white noise contains low, mid, and high frequencies. For a kick click, we do not want all of that. We only want a small band that feels sharp and bright.

That is why we use a band-pass filter. It keeps a focused slice of the noise and removes the rest.

const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = "bandpass";
noiseFilter.frequency.value = 2000;
noiseFilter.Q.value = 1;

2000 Hz puts the click in the upper mids, where the attack is easy to hear. Q controls how narrow or wide the band is. Q = 1 keeps the band fairly wide, so it still sounds natural.

What you should hear: the noise should stop sounding like broad hiss and start sounding more focused, brighter, and more useful as an attack layer.

Now the click has the right color, but not the right shape. The next step is to make it short enough to behave like a real transient.

8. Shape the click with its own gain envelope

Now we give the noise path its own gain node, just like we did for the sine body. This lets us open the click for a tiny moment and then shut it off again.

const noiseGain = ctx.createGain();
noiseGain.gain.value = 0;

noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);

const when = ctx.currentTime;

noiseGain.gain.setValueAtTime(0, when);
noiseGain.gain.linearRampToValueAtTime(0.9, when + 0.001);
noiseGain.gain.exponentialRampToValueAtTime(0.001, when + 0.07);
noiseGain.gain.setValueAtTime(0, when + 0.071);

These values are doing the same kind of work as the body envelope, just much faster:

  • 0.9 at when + 0.001 → the click opens almost instantly, in 1 ms.
  • 0.001 at when + 0.07 → the click fades over 70 ms.
  • 0 at when + 0.071 → one millisecond later it is forced to full silence.

The result is a short bright transient on top of the low sine body.

What you should hear: the kick should suddenly become easier to spot. Even if the body stays the same, the front edge now feels sharper and more defined.

At this point the click itself is ready. The last step is just to replace the old raw-noise route with this finished click path.

9. Route the click into the main output

The click is now shaped, so the last step is to update the routing code.

The sine body is already going through mix, master, and ctx.destination. At this point, you can remove the old noise.connect(mix) line from step 6 and replace it with the final click route below.

noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(mix);

Open this step in Web Audio Studio and hear the finished kick →

At this point, the kick is fully built.

What you should hear: a full kick shape with a low body and a clear front click, instead of a sine tone with some noise on top.

Full code

Here is the whole example split into the two files you need.

Main file

const ctx = new AudioContext();

await ctx.audioWorklet.addModule("/white-noise-processor.js");

const sineOsc = ctx.createOscillator();
sineOsc.type = "sine";
sineOsc.frequency.value = 150;

const sineGain = ctx.createGain();
sineGain.gain.value = 0;

const noise = new AudioWorkletNode(ctx, "white-noise-processor", {
  numberOfInputs: 0,
  numberOfOutputs: 1,
});

const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = "bandpass";
noiseFilter.frequency.value = 2000;
noiseFilter.Q.value = 1;

const noiseGain = ctx.createGain();
noiseGain.gain.value = 0;

const mix = ctx.createGain();
const master = ctx.createGain();

sineOsc.connect(sineGain);
sineGain.connect(mix);

noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(mix);

mix.connect(master);
master.connect(ctx.destination);

sineOsc.start();

const when = ctx.currentTime;

// Sine body
sineOsc.frequency.setValueAtTime(150, when);
sineOsc.frequency.exponentialRampToValueAtTime(40, when + 0.05);

sineGain.gain.setValueAtTime(0, when);
sineGain.gain.linearRampToValueAtTime(1, when + 0.005);
sineGain.gain.exponentialRampToValueAtTime(0.001, when + 0.4);
sineGain.gain.setValueAtTime(0, when + 0.401);

// Noise click
noiseGain.gain.setValueAtTime(0, when);
noiseGain.gain.linearRampToValueAtTime(0.9, when + 0.001);
noiseGain.gain.exponentialRampToValueAtTime(0.001, when + 0.07);
noiseGain.gain.setValueAtTime(0, when + 0.071);

white-noise-processor.js

class WhiteNoiseProcessor extends AudioWorkletProcessor {
  process(inputs, outputs) {
    const output = outputs[0];
    for (let channel = 0; channel < output.length; channel++) {
      const buf = output[channel];
      for (let i = 0; i < buf.length; i++) {
        buf[i] = Math.random() * 2 - 1;
      }
    }
    return true;
  }
}

registerProcessor("white-noise-processor", WhiteNoiseProcessor);

Open the full patch in Web Audio Studio → and tweak every parameter in real time.

Tweaking the sound

In Web Audio Studio you can tweak values right on the nodes, or edit the code directly and listen again. The best way to learn this patch is to change one thing at a time and listen for one clear result.

  • Change the body start pitch in sineOsc.frequency from 150 to 120 if you want a rounder, softer kick. Push it to 180 if you want the hit to feel sharper and more aggressive.
  • Change the body end pitch in sineOsc.frequency from 40 to 30 if you want more sub weight. Push it to 50 if you want the tail to stay easier to hear on small speakers.
  • Change the pitch drop time from 0.05 to 0.03 if you want a tighter, punchier kick that starts to feel more techno-like. Push it to 0.08 if you want a longer drop that leans more toward an 808-style shape.
  • Change the body attack in sineGain.gain from 0.005 to 0.001 if you want a harder front edge. Push it to 0.01 if you want the hit to feel softer and less sharp.
  • Change the body decay in sineGain.gain from 0.4 to 0.2 if you want a short, dry kick. Push it to 0.7 if you want a longer low-end tail.
  • Change the click level in noiseGain.gain from 0.9 to 0.3 if you want the attack to sit back. Keep it high if you want the kick to cut through a dense mix.
  • Change the click decay in noiseGain.gain from 0.07 to 0.03 if you want a tiny, tight tick. Push it to 0.12 if you want a noisier, more acoustic-style attack.
  • Change the click tone in noiseFilter.frequency from 2000 to 1200 if you want a darker click. Push it to 3500 if you want a brighter, snappier attack.
  • Change noiseFilter.Q from 1 to 0.7 if you want a wider, smoother click. Push it to 3 if you want a narrower, more focused tone.
  • Change master.gain if the whole patch is too loud, instead of rebalancing every layer by hand.

Optional: Loop it with proper Web Audio scheduling

Calling those automation lines once with const when = ctx.currentTime is fine for testing, but a real beat needs repeated hits. At that point it makes sense to wrap everything in a playKick(when) function, because we no longer want to schedule “right now” every time. We want to schedule each hit at an exact future time like nextTime.

The first idea many people try is setInterval() or repeated calls from the UI thread. That works at first, but it can drift and jitter when the page is busy.

The Web Audio clock is much more stable than the main JavaScript thread. So instead of triggering the sound exactly at “now”, we check the clock often and schedule notes a little bit ahead.

That is what this lookahead scheduler does:

  • it wakes up every 25 ms
  • it looks 100 ms into the future
  • it schedules every kick that should happen in that window

This is why the timing stays tighter, even when the browser is doing other work.

You can read more about this idea in Chris Wilson’s A Tale of Two Clocks .

function playKick(when) {
  sineOsc.frequency.setValueAtTime(150, when);
  sineOsc.frequency.exponentialRampToValueAtTime(40, when + 0.05);

  sineGain.gain.setValueAtTime(0, when);
  sineGain.gain.linearRampToValueAtTime(1, when + 0.005);
  sineGain.gain.exponentialRampToValueAtTime(0.001, when + 0.4);
  sineGain.gain.setValueAtTime(0, when + 0.401);

  noiseGain.gain.setValueAtTime(0, when);
  noiseGain.gain.linearRampToValueAtTime(0.9, when + 0.001);
  noiseGain.gain.exponentialRampToValueAtTime(0.001, when + 0.07);
  noiseGain.gain.setValueAtTime(0, when + 0.071);
}

const bpm = 120;
const interval = 60 / bpm;
const lookahead = 0.1;     // schedule 100 ms ahead
const timerInterval = 25;  // check every 25 ms

let nextTime = ctx.currentTime;

function scheduler() {
  while (nextTime < ctx.currentTime + lookahead) {
    playKick(nextTime);
    nextTime += interval;
  }

  setTimeout(scheduler, timerInterval);
}

scheduler();

Try the full patch in Web Audio Studio → and experiment with the playKick(when) function, the nextTime scheduler, and the timing values in the lookahead loop.


You now have everything you need to build a kick from scratch: a sine wave for weight, a burst of noise for click, and a few fast automation curves to shape both into a real drum sound.

Now open the patch in Web Audio Studio and break it. Push the pitch too far, make the tail too long, brighten the click until it feels wrong, then pull it back until it hits exactly the way you want.