cofx logo

blog by cofx

Browser beats I: synthesizing a kick drum

This post first appeared on Kabisa's Tech Blog.

Because I wanted to gain some experience in working with Angular and NgRx, I started building a sample-based step sequencer that runs in the browser. To do that, I had to dive into the Web Audio API. I’ll write something about that step sequencer later. First, I want to take a look at the basics of the Web Audio API and try to synthesize a kick drum.

The basis

At the basis of most syntesized kick drums, there’s a sine wave, or something that’s close to a sine wave. The function below produces a sine wave with a frequency of 55Hz that plays for the duration of ten seconds.

const play = () => {
    const audioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioContext = new audioContextClass();

    const sine = audioContext.createOscillator();
    sine.type = 'sine';
    sine.frequency.value = 55;

    sine.start(audioContext.currentTime);
    sine.stop(audioContext.currentTime + 10);
}

It sounds like this: (You might not hear it over your laptop’s speakers. You’ll need decent speakers or headphones that are able to reproduce low frequencies.)

When you visualize that sound, as shown below, you’ll see why it’s called a sine wave. The left-hand side of the figure shows the waveform, and the right-hand side shows the sound spectrum.

Sine at 55Hz

The sound spectrum is almost completely empty, except for a narrow spike at the rightmost end. This explains why you might not hear the sound over your laptop speakers, for example. Not all speakers are capable of reproducing sounds at low frequencies. You can emulate the frequency response of such speakers by applying a high-pass filter. If you filter out all frequencies below 120Hz, this is what’s left of our sine wave:

The graphs below further illustrate that not much is left of the original sound.

Sine at 55Hz through a high-pass filter at 120Hz

What does that mean for our synthesized kick drum? We’ll apply a trick to make your ears believe that there’s still some bass to be heard, even when listening to speakers that can’t reproduce low frequencies very well. Instead of a sine wave, we’ll start out with a triangle wave.

const play = () => {
    const audioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioContext = new audioContextClass();

    const triangle = audioContext.createOscillator();
    triangle.type = 'triangle';
    triangle.frequency.value = 55;

    triangle.connect(audioContext.destination);

    triangle.start(audioContext.currentTime);
    triangle.stop(audioContext.currentTime + 10);
}

Without further processing, it will look like this:

Triangle at 55Hz

It’s again clear where the name comes from. It’s also clear that there’s much more going on in the spectrum graph.

Unfortunately, it sounds a little abrasive, like this:

Ideally, we’d like to process this triangle wave in such a way that it sounds more like the sine wave, without cutting off too much of the high-frequency sounds. We can do that using a wave shaper.

const distortionCurve = (amount) => {
    const numberOfSamples = 44100;
    const curve = new Float32Array(numberOfSamples);
    const deg = Math.PI / 180;
    for (let i = 0; i < numberOfSamples; ++i) {
        const x = i * 2 / numberOfSamples - 1;
        curve[i] = (3 + amount) * x * 20 * deg / ( Math.PI + amount * Math.abs(x) );
    }
    return curve;
};

const play = () => {
    const audioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioContext = new audioContextClass();

    const triangle = audioContext.createOscillator();
    triangle.type = 'triangle';
    triangle.frequency.value = 55;

    const waveShaper = audioContext.createWaveShaper();
    waveShaper.curve = distortionCurve(5);

    triangle.connect(waveShaper);
    waveShaper.connect(audioContext.destination);

    triangle.start(audioContext.currentTime);
    triangle.stop(audioContext.currentTime + 10);
}

The curve I’m using above comes from a Stack Overflow answer by Kevin Ennis. In theory, there are multiple Sigmoid functions that you could use. I only tried this one and stuck with it because I liked the result.

Speaking of results, here are the graphs for this sound:

Triangle at 55Hz through a wave shaper

The triangles look a lot more like sines, and there is still something going on at the higher end of the frequency spectrum. The resulting sound sounds like this:

The W3C spec gives a good explanation of what’s actually going on when you apply a wave shaper with a certain curve. I won’t go into the details here.

What did we achieve with this detour? If we filter out the low frequencies again to simulate cheaper speakers, we end up with the following sound:

The graphs for this filtered sound are shown below. When you compare these to the ones for the filtered sine wave shown above, you’ll notice that there’s still something to hear after removing the low end. This is enough for you ears to trick you into believing that there’s actually some low end left, even when there isn’t.

Triangle at 55Hz through a wave shaper and a high-pass filter at 120Hz

Make it boom

The sound we ended up with sounds a little like “WOOOOOOOOOOH”. Let’s turn that into a “WOOOOM”.

const play = () => {
    const audioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioContext = new audioContextClass();

    const triangle = audioContext.createOscillator();
    triangle.type = 'triangle';
    triangle.frequency.value = 55;

    const waveShaper = audioContext.createWaveShaper();
    waveShaper.curve = distortionCurve(5);

    const triangleGainNode = audioContext.createGain();
    triangleGainNode.gain.value = 1;
    triangleGainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.6)

    triangle.connect(waveShaper);
    waveShaper.connect(triangleGainNode);
    triangleGainNode.connect(audioContext.destination);

    triangle.start(audioContext.currentTime);
    triangle.stop(audioContext.currentTime + 1);
}

In the snippet above, you’ll see that we’re using a gain node to gradually fade out over the course of 600 milliseconds. The end result sounds like this.

Now that we have something that sounds like “WOOOOM”, let’s make it sound like “BOOOOM”.

const play = () => {
    const audioContextClass = window.AudioContext || window.webkitAudioContext;
    const audioContext = new audioContextClass();

    const triangle = audioContext.createOscillator();
    triangle.type = 'triangle';
    triangle.frequency.value = 220;
    triangle.frequency.exponentialRampToValueAtTime(55, audioContext.currentTime + 0.1);

    const waveShaper = audioContext.createWaveShaper();
    waveShaper.curve = distortionCurve(5);

    const triangleGainNode = audioContext.createGain();
    triangleGainNode.gain.value = 1;
    triangleGainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.6)

    triangle.connect(waveShaper);
    waveShaper.connect(triangleGainNode);
    triangleGainNode.connect(audioContext.destination);

    triangle.start(audioContext.currentTime);
    triangle.stop(audioContext.currentTime + 1);
}

As shown above, we do that be quickly lowering the frequency of the triangle wave from 220Hz to 55Hz over the course of 100 milliseconds. The end result sounds like this:

If you want to achieve more of a 90s Euro house vibe, you can drop down from a higher frequency.

White noise

If you look at how classic synthesizers emulate kick drums, you’ll see that they’ll often use a little white noise to give the kicks a little more body. The Web Audio API doesn’t provide (white) noise out of the box, but you can use an audio buffer to create your own.

const generateWhiteNoiseBuffer = (numberOfSamples) => {
    const buffer = audioContext.createBuffer(1, numberOfSamples, audioContext.sampleRate);

    const data = buffer.getChannelData(0);
    for (let i = 0; i < numberOfSamples; ++i) {
        data[i] = Math.random() * 2 - 1;
    }

    return buffer;
}

const whiteNoiseBuffer = generateWhiteNoiseBuffer(audioContext.sampleRate);

const whiteNoiseBufferSource = () => {
    const bufferSource = audioContext.createBufferSource();
    bufferSource.buffer = whiteNoiseBuffer;
    bufferSource.loop = true;
    bufferSource.loopEnd = audioContext.sampleRate
    return bufferSource;
}

Each buffer source returned by the function whiteNoiseBufferSource can only be started once. The same holds for the oscillator nodes that we’ve been creating above. The buffer returned by generateWhiteNoiseBuffer, however, can be reused. The result sounds like this:

The next step is to apply a fade to this sound, just like we did before.

After that, we cut of most of the higher frequencies using a low pass filter.

const play = () => {

    ...

    const noise = whiteNoiseBufferSource();

    const noiseGainNode = audioContext.createGain();
    noiseGainNode.gain.value = 1;
    noiseGainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.2);

    const noiseFilter = audioContext.createBiquadFilter();
    noiseFilter.type = 'lowpass';
    noiseFilter.frequency.value = 120;

    noise.connect(noiseGainNode);
    noiseGainNode.connect(noiseFilter);
    noiseFilter.connect(audioContext.destination);

    noise.start(audioContext.currentTime);
    noise.stop(audioContext.currentTime + 1);
};

The end result sounds like this:

End result

Combining the sine-like wave and the filtered white noise leads to the following result:

All you need is a handful of other instruments and you’re halfway making the next big dance hit, in your browser.

Conclusion

Let’s put the results of all this hard work into action. First, open Youtube, Spotify or whatever streaming service you like to play your favorite song. Then, visit https://ljpengelen.github.io/groovid19/kick-snare-hihat.html and press q, w, and e to drum along. Enjoy!

Published: 2020-05-25

Tagged: Browser beats