WebAudio Deep Note, part 2.1: Boots and Cats

October 3rd, 2019. Tagged: JavaScript, WebAudio

In the previous installment we came across the idea of creating noise via an oscillator and via a buffer filled with your own values (as opposed to values being read from a pre-recorded file). I thought a bit of elaboration is in order, even though we're not going to use these ideas for the Deep Note. So... a little diversion. But it's all in the name of WebAudio exploration!

Boots and Cats

You know these electronic, EDM-type stuff, 80's electronica, etc. When you have "four on the floor" (kick drum hit on every beat) and some sort of snare drum on every other beat. It sounds a bit as if you're saying Boots & Cats & Boots & Cats & Boots & Cats & so on.

Let's see how we can generate similar sounds using a sine wave for the kick drum (Boots!) and a random buffer of white noise for the snare drum (Cats!).

Generating realistic-sounding instruments is a wide topic, here we're just exploring the WebAudio API so let's keep it intentionally simple. If you want to dig deeper though, here's a good start.

Here's a demo of the end result.

UI

Just two buttons:

<button onclick="kick()">
  🥾🥾🥾<br>
  <abbr title="shortcut key: B">B</abbr>oots
</button>
<button onclick="snare()" id="cats">
  🐈🐈🐈<br>
  <abbr title="shortcut key: C, also X">C</abbr>ats
</button>
<p>Tip: Press B for Boots and C (or X) for Cats</p>

Boots and Cats UI

Setup

Setting up the audio context and the keydown hooks:

if (!window.AudioContext && window.webkitAudioContext) {
  window.AudioContext = window.webkitAudioContext;
}
const audioContext = new AudioContext();

function kick() {
  // implement me!
}

function snare() {
  // me too!
}

onkeydown = (e) => {
  if (e.keyCode === 66) return kick();
  if (e.keyCode === 67 || e.keyCode === 88) return snare();
};

Now all we need is to implement the kick() and snare() functions.

Cats

The "Cats!" snare drum is white noise. White noise is random vibrations evenly distributed across all frequencies. (Compare this to e.g. pink noise which is also random but adjusted to human hearing - less low frequencies and more highs.)

The snare() function can be really simple. Just like before, create a buffer source, give it an audio buffer (stuff to play), connect to the audio destination (speakers) and start playing.

function snare() {
  const source = audioContext.createBufferSource();
  source.buffer = buffer;
  source.connect(audioContext.destination);
  source.start();
}

This snare function will play the exact same buffer every time, so we only need to generate the buffer once and then replay it. If you think this is boring... well the buffer is random so no one will ever know that the buffer is the same every time. But you can always generate a new buffer every time (expensive maybe) or create a longer buffer than you need and play different sections of it.

And what's in that buffer? As you saw in the previous post it's an array with a (lot of!) values between -1 and 1, describing samples of a wave of some sort. When these values are random, the wave is not that pretty. And the result we, humans, perceive as noise. But turns out that, strangely enough, short bursts of random noise sound like a snare drum of some sort.

OK, enough talking, let's generate the bugger, I mean the buffer.

const buffer = audioContext.createBuffer(1, length, audioContext.sampleRate);

What length? As you know, if the length is the same as the sample rate you get 1 second of sound. That's a looong snare hit. Experiment a bit and you'll see you need a lot less:

const length = 0.05 * audioContext.sampleRate;

Now you have an empty buffer, 0.05 seconds long. You can access its content with:

let data = buffer.getChannelData(0);

0 gives you access to the first channel. Since we created a mono buffer, it only has that one channel. If you create a stereo buffer, you can populate the two channels with different random samples, if you feel so inclined.

Finally, the randomness to fill the channel data:

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

The whole * 2 - 1 is because Math.random() generates numbers from 0 to 1 and we need -1 to 1. So if the random number is 0 it becomes 0 * 2 - 1 = -1. And then if the random number is 1, it becomes 1 * 2 - 1 = 1. Cool.

In this case the white noise is louder than the kick sine wave, so making the amplitude of the noise between -0.5 and +0.5 gives us a better balance. So data[i] = Math.random() - 1; it is.

All together:

const length = 0.05 * audioContext.sampleRate;
const buffer = audioContext.createBuffer(1, length, audioContext.sampleRate);
let data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
  data[i] = Math.random() - 1;
}

function snare() {
  const source = audioContext.createBufferSource();
  source.buffer = buffer;
  source.connect(audioContext.destination);
  source.start();
}

The buffer is created once and reused for every new buffer source. The buffer sources needs to be created for every hit though.

Moving on, the boots!

Boots

The kick (Boots!) is a low-frequency sine wave. We create the wave using createOscillator():

const oscillator = audioContext.createOscillator();

There are a few types of oscillators. Sine is one of them:

oscillator.type = 'sine';

60Hz is fairly low, yet still fairly audible, frequency:

oscillator.frequency.value = 60;

Finally, same old, same old - connect and play:

oscillator.connect(audioContext.destination);
oscillator.start();

This creates a low-frequency wave and plays it indefinitely. To stop it we call stop() and schedule it 0.1 seconds later.

oscillator.stop(audioContext.currentTime + 0.1);

Here currentTime is the audio context's internal timer: the number of seconds since the context was created.

This is cool and all, but we can do a little better without adding too much complexity. Most instruments sound different when the sound is initiated (attack!) vs later on (sustain). So the sine wave can be our sustain and another, shorter and triangle wave can be the attack.

(BTW, the types of oscillators are sine, triangle, square and sawtooth. Play with them all!)

Here's what I settled on for the triangle wave:

const oscillator2 = audioContext.createOscillator();
oscillator2.type = 'triangle';
oscillator2.frequency.value = 10;
oscillator2.connect(audioContext.destination);
oscillator2.start();
oscillator2.stop(audioContext.currentTime + 0.05);

10Hz is way too low for the human hearing, but the triangle wave has overtones at higher frequencies, and these are audible.

So the final kick is:

function kick() {
  const oscillator = audioContext.createOscillator();
  oscillator.type = 'sine';
  oscillator.frequency.value = 60;
  oscillator.connect(audioContext.destination);
  oscillator.start();
  oscillator.stop(audioContext.currentTime + 0.1);

  const oscillator2 = audioContext.createOscillator();
  oscillator2.type = 'triangle';
  oscillator2.frequency.value = 10;
  oscillator2.connect(audioContext.destination);
  oscillator2.start();
  oscillator2.stop(audioContext.currentTime + 0.05);
}

Next...

Alrighty, diversion over, next time we pick up with Deep Note. Meanwhile you can go play Boots & Cats.

Oh, you may hear clicks in Firefox and Safari (Chrome is ok) when the sine wave stops. That's annoying but you'll see later on how to deal with it. Spoiler: turn down the volume and then stop. But in order to turn down the volume you need a volume knob (a gain node) and you'll see these in action soon enough.

Tell your friends about this post on Facebook and Twitter

Sorry, comments disabled and hidden due to excessive spam.

Meanwhile, hit me up on twitter @stoyanstefanov