Drum Synthesis in JavaScript
8 min read

Drum Synthesis in JavaScript

For a long time now, the idea of writing a small drum synth has been one of those "maybe it would be cool if..." tasks floating in the periphery of my Creative Intent plugin prototypes or in my small experiments with generative music. Typically I've put off the idea thinking that it would take more time than it would be worth. But, now that I have Elementary at my disposal, I've found that small experiments or goals like these can be half-day projects, some even shorter than that. So I recently set out to build a small, free drum synth library for Elementary to finally scratch that itch, and in this article I want to share a brief tutorial on how I approached the synthesis of an electronic drum, clap, and hi hat sound.

If you want to hear the examples as you follow along, you can simply clone the repository, npm install, and npm start. Lastly, before we dive in, I want to caveat that the approaches I've taken here for each sound are quite simple. I'm sure that there are more complicated, perhaps more accurate, approaches to synthesizing these sounds, but the approaches I took led me to a set of sounds that I'm happy with for my own musical experiments.

Kick Drums

An electronic kick drum is composed of a few really simple elements. First, we have the sub bass which is tuned to a desired frequency and lends the real “meat” of the kick. Then we have the click at beginning of the sound which loosely simulates the sound of the contact made when the mallet hits the membrane of an acoustic kick drum. Often this is achieved in kick drum synthesis using a very fast pitch envelope sweeping the sub tone from higher frequencies to lower. These components alone form the complete building blocks for many electronic kicks, but in mine I wanted a little grunge as well, thinking about distorted 808 style kick drum hits.

To translate these components concretely into our synthesis approach, we start with the sub bass, for which I’ve used a simple sine tone at a user-specified frequency pitch, typically tuned in the 30-120hz range. Then we can apply a contour using an amplitude envelope which ramps up to its maximum very quickly (say, 5ms – 400ms), and decays back down to 0 shortly thereafter (5ms – 4s).

function kick(pitch, attack, decay, gate) {
  let env = el.adsr(attack, decay, 0.0, 0.1, gate);
  return el.mul(env, el.cycle(pitch));
}

Here, our kick drum function will be used such that the gate argument passed in is a pulse train which alternates between a value of 0 and 1 at some user-defined rate. On the rising edge (a transition from 0 to 1), our envelope will engage, triggering the kick drum sound.

core.on('load', function() {
  core.render(kick(40, 0.005, 0.1, el.train(1)));
});

Now to give our kick a nice click-like transient we apply another envelope, this time to the pitch of the sub tone to sweep the frequency of the sub down from a higher value. The tuning here is done to taste, where I run pitch from 5 times the user given value down to pitch itself over a short period of time.

function kick(pitch, click, attack, decay, gate) {
  // First up we have our amp envelope
  let env = el.adsr(attack, decay, 0.0, 0.1, gate);

  // Then we have a pitch envelope with 0 attack and decay in [5ms, 4s].
  // The `el.adsr` node uses exponential segments which is great for our purposes
  // here, but you could also weight the curve more or less aggressively by squaring
  // or taking the square root of the pitchenv signal.
  let pitchenv = el.adsr(0.005, click, 0.0, 0.1, gate);

  // Then our synthesis: a sine tone at our base pitch, whose frequency is quickly
  // modulated by the pitchenv to sweep from 5*pitch to 1*pitch over `click` seconds.
  // The resulting sound is multiplied straight through our amp envelope.
  return el.mul(
    env,
    el.cycle(
      el.mul(
        el.add(1, el.mul(4, pitchenv)),
        pitch,
      )
    )
  );

By now, our kick sound is largely complete, but I wanted to add some extra harmonics to the sub tone and drive the kick into more grungy territory with a waveshaper. For that, we simply take our previous sound and push it through an el.tanh saturator with a user specified drive. The complete kick synth, then, looks as follows.

function kick(pitch, click, attack, decay, drive, gate) {
  // First up we have our amp envelope
  let env = el.adsr(attack, decay, 0.0, 0.1, gate);

  // Then we have a pitch envelope with 0 attack and decay in [5ms, 4s].
  // The `el.adsr` node uses exponential segments which is great for our purposes
  // here, but you could also weight the curve more or less aggressively by squaring
  // or taking the square root of the pitchenv signal.
  let pitchenv = el.adsr(0.005, click, 0.0, 0.1, gate);

  // Then our synthesis: a sine tone at our base pitch, whose frequency is quickly
  // modulated by the pitchenv to sweep from 5*pitch to 1*pitch over `click` seconds.
  // The resulting sound is multiplied straight through our amp envelope.
  let clean = el.mul(
    env,
    el.cycle(
      el.mul(
        // The pitch envelope runs from a multiplier of 5 to
        // a multiplier of 1 on the original pitch
        el.add(1, el.mul(4, pitchenv)),
        pitch,
      )
    )
  );

  // Then you can drive it into a soft clipper with a gain multiplier in [1, 10]
  return el.tanh(el.mul(clean, drive));
}

You can find the complete kick synth on GitHub.

Claps

I'm sure that synthesizing clap sounds can be an extremely detailed endeavor if you want to dig in far enough. But the approach that I took is perhaps the simplest thing I could think of, and I'm quite happy with the result. First, we'll note that an individual clap sound is really little more than a transient with the bulk of its resonating decay within, say, the 400Hz – 3500Hz frequency range. We can approach something like this simply using filtered white noise and an amplitude envelope for contour.

function clap(tone, attack, decay, gate) {
  let no = el.noise();
  let e1 = el.adsr(attack, decay, 0.0, 0.1, gate);
  
  return el.bandpass(
    tone,
    1.214,
    el.mul(no, e1),
  );
}

Like the kick drum above, our clap function will be triggered on the rising edge of a pulse train signal.

core.on('load', function() {
  core.render(clap(800, 0.005, 0.01, el.train(1)));
});

Now, this is all fine so far, but the interesting character of a clap comes from the layering of several such transients, as if several people try to clap once at the same time. It's the slight variations in timing that really make the difference. So, here we simply take the above approach, duplicate it four times, and offset the attacks and decays just slightly. Finally, for one last touch of additional character, I've run the result through another el.tanh saturator.

function clap(tone, attack, decay, gate) {
  // Layered white noise synthesis
  let no = el.noise();

  let e1 = el.adsr(el.add(0.035, attack), el.add(0.06, decay), 0.0, 0.1, gate);
  let e2 = el.adsr(el.add(0.025, attack), el.add(0.05, decay), 0.0, 0.1, gate);
  let e3 = el.adsr(el.add(0.015, attack), el.add(0.04, decay), 0.0, 0.1, gate);
  let e4 = el.adsr(el.add(0.005, attack), el.add(0.02, decay), 0.0, 0.1, gate);

  // Then we run the result through a bandpass filter set according to tone
  // between 400Hz and 3500Hz, and slightly saturate.
  return el.tanh(
    el.bandpass(
      tone,
      1.214,
      el.add(
        el.mul(no, e1),
        el.mul(no, e2),
        el.mul(no, e3),
        el.mul(no, e4),
      ),
    )
  );
}

Like the kick drum, you can find the complete clap synth on GitHub.

Hi Hats

Synthesizing the hi hat sound ended up being where I spent most of my time with this little project. Like the clap, it seemed obvious to start with the idea that a hi hat, especially a closed hi hat, is little more than a transient which generally resides in the higher part of the frequency spectrum. But just using filtered noise to get there felt to me lacking in the metallic character of a hat. After studying a few other well known drum synths such as the classic TR-808 or the DR110, I gathered that high pitched square waves can be a good building block for the character that I was seeking.

Then, with some additional exploration, I arrived at an approach which uses FM synthesis (via phase modulation) with a single sine modulator and a single sine carrier. The modulator runs at exactly twice the rate of the carrier to generate the odd harmonics of a square wave. Then, to pull in some of the non-harmonic noise character, I used a second modulator, this time a white noise signal modulating the frequency of the first modulator. The FM synthesis looks as follows.

/** A quick helper for a sine wave oscillator with a phase offset. */
function cycle(freq, phaseOffset) {
  let t = el.add(el.phasor(freq), phaseOffset);
  let p = el.sub(t, el.floor(t));

  return el.sin(el.mul(2 * Math.PI, p));
}

/** Our hat synth */
function hat(pitch) {
  // Synthesis
  let m2 = el.noise();
  let m1 = cycle(el.mul(2, pitch), el.mul(2, m2));
  let m0 = cycle(pitch, el.mul(2, m1));

  return m0;
}

Here, the pitch given to the hat function determines the base frequency of the carrier sine tone, which can be adjusted by the user to tune the metallic character of the hat. Now, as written, we're missing the amplitude envelope and therefore don't have a way of triggering discrete instances of our hat. Let's add that in:

function hat(pitch, attack, decay, gate) {
  // Synthesis
  let m2 = el.noise();
  let m1 = cycle(el.mul(2, pitch), el.mul(2, m2));
  let m0 = cycle(pitch, el.mul(2, m1));

  // Amp envelope with an attack in [5ms, 200ms] and a
  // decay in [5ms, 4000ms]
  let env = el.adsr(attack, decay, 0.0, 0.1, gate);

  return el.mul(m0, env);
}

Again, we'll use this with a pulse train signal for triggering the hat:

core.on('load', function() {
  core.render(hat(8800, 0.005, 0.01, el.train(1)));
});

Lastly, especially because of the white noise modulator, we get a lot of frequency content in the mid and low range that we don't need and really don't want with our hat sound, so we can run the above synth through a bandpass filter like with the clap synth. Here we introduce the tone parameter to set the cutoff frequency of the filter, tuned by the user.

function hat(pitch, tone, attack, decay, gate) {
  // Synthesis
  let m2 = el.noise();
  let m1 = cycle(el.mul(2, pitch), el.mul(2, m2));
  let m0 = cycle(pitch, el.mul(2, m1));

  // Then we run the result through a bandpass filter set according to tone
  // between 800Hz and 18kHz.
  let f = el.bandpass(tone, 1.214, m0);

  // Finally we have the amp envelope with an attack in [5ms, 200ms] and a
  // decay in [5ms, 4000ms]
  let env = el.adsr(attack, decay, 0.0, 0.1, gate);

  return el.mul(f, env);
}

There we have it! And of course, you can find the hat synth on GitHub as well.

Free to Use

To wrap up, I'm happy to share that all of the sounds presented above are free to use in your own projects and available on npm right now. To get started, you can simply add @nick-thompson/drumsynth to your project:

$ npm install @nick-thompson/drumsynth

Finally, a brief usage example with each of the kick, clap, and hat playing on every step of the pulse train:

const core = require('elementary-core');
const el = require('@nick-thompson/elementary');
const ds = require('@nick-thompson/drumsynth');

// The simplest example: a kick, hat, and clap playing on every step of a
// pulse train running at 4Hz.
core.on('load', function() {
  let gate = el.train(4);

  core.render(
    el.add(
      ds.kick(40, 0.104, 0.005, 0.4, 4, gate),
      ds.clap(800, 0.005, 0.204, gate),
      ds.hat(317, 12000, 0.005, 0.1, gate),
    ),
  );
);