WebAudio Deep Note, part 4: multiple sounds

October 20th, 2019. Tagged: JavaScript, WebAudio

Previously in this series:

  1. intro
  2. play a sound
  3. loop and change pitch

We need to play 30 sounds at the time, that is 30 instances of the same cello sample, all pitched all over the place and over time. (If that sounds odd, please revisit the intro post.) Let's ignore the "over time" changing of pitches for now and focus on the final chord. It's a D major chord (meaning D, A and F#) notes, where each note is played in several octaves and each note in each octave is played by several voices. Meaning for example the same A3 note is played twice. The full list of notes to play is:

const notes = {
  D1: {rate: 1/4, voices: 4},
  D2: {rate: 1/2, voices: 4},
  A2: {rate: 3/4, voices: 2},
  D3: {rate: 1,   voices: 2},
  A3: {rate: 3/2, voices: 2},
  D4: {rate: 2,   voices: 2},
  A4: {rate: 3,   voices: 2},
  D5: {rate: 4,   voices: 2},
  A5: {rate: 6,   voices: 2},
  D6: {rate: 8,   voices: 2},
  Fs: {rate: 10,  voices: 6},
};

As you see each note has a number of voices. The rate is how we're going to pitch things (see the previous post re: pitching). Because we already know how to pitch D3 based on our C3 sample, we'll use this as a starting point and call it rate 1, meaning no slow downs or speed ups. All other notes in the final chord are multiples of this D3.

As discussed already, a note (say D4) that is an octave up from the same note (D3) has twice the frequency. This means we play it twice as fast to get the correct frequency. Hence D4 is a rate of 2 compared to D3 "base" rate of 1. D5 then is twice the D4 or the rate of 4. D6 is twice D5, or rate of 8. In the other direction D2 is half of D3's frequency. So rate of 1/2. D1 is half of D2 or a quarter of D3. So rate of 1/4. That takes case of all the Ds.

Then A3 has the "perfect" ratio of 3:2 to D3. (Recall that string length illustration). And so the rate is 3/2. (In music theory parlance A is the interval of the "perfect fifth" of D.) A4 is 2 * A3 or a simple 3. A5 is 3 * 2 or 6. On the other side A2 is half of A3, so (3/2)/2 or 3/4.

Finally the top note F# (music theory: the major third above D) has the ratio of 5:4 in our perfect just tuning. We only have one F# and that is F#6. So it's 5/4 of D6. 8 * 5/4 = 10.

(Why 5:4? What happened to 4:3? We have 2:1 (octave), 3:2 (perfect fifth) and 4:3 is called perfect fourth. These, and 1:1, which is the same note (unison), are all the "perfect" intervals. After that things are not so perfect. They didn't sound like they go together that well to the people who came up with these names. So there. 5:4 is a major third. 6:5 is a minor third. But we only worry about octaves and fifths and a single major third in our Deep Note case.)

Alright let's see some code. First I've decided to finally divorce loading a sample from playing it. So here it comes now, the load() function:

function load(files) {
  return new Promise((resolve, reject) => {
    const buffers = new Map;
    files.forEach(f => {
      fetch(f)
        .then(response => response.arrayBuffer())
        .then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
        .then(audioBuffer => {
          buffers.set(f, audioBuffer);
          if (buffers.size === files.length) {
            resolve(buffers);
          }
        })
        .catch(e => console.log('uff'));
    });    
  });
}

The function takes an array of samples to load. Convenient when you want to be done with all samples you need to load (or preload when the user hovers a button maybe). The result of the function is a map of buffers, each keyed with the file name.

Next, some constants:

const C3 = 130.81;
const c3d150 = 150 / C3; // 1.1467013225;

const SAMPLE = 'Roland-SC-88-Cello-C3-glued-01.wav';
const sources = [];

You know what the first three are about. The last one is where we'll keep an array of buffer sources, ready to play (or stop). We'll have 30 buffer sources, one for each voice.

So when you want to stop all these sounds you loop through all sources and stop them. You can also delete them, since they can not be reused. If we need to play the same thing again, the 30 buffer sources will have to be recreated.

function stop() {
  for (let i = 0; i < sources.length; i++) {
    sources[i] && sources[i].stop();
    delete sources[i];
  }
}

Now, time to play:

function play() {
  load([SAMPLE]).then(buffers => {
    for (let note in notes) {    
      for (let i = 0; i < notes[note].voices; i++) {
         // todo
      }
    };
  });
}

This function loads the samples and loops through all the notes we need to play (the notes object from the top of this post) and then loops again for each repeating voice that plays the same note.

In the body of the loop you'll find the same thing you already know. The new bits are setting the rate (to control the pitch) and pushing to the array of sources.

function play() {
  load([SAMPLE]).then(buffers => {
    for (let note in notes) {    
      for (let i = 0; i < notes[note].voices; i++) {
        const source = audioContext.createBufferSource();
        source.buffer = buffers.get(SAMPLE);
        source.loop = true;
        source.playbackRate.value = c3d150 * notes[note].rate;    
        source.connect(audioContext.destination);
        source.start();
        sources.push(source);
      }
    };
  });
}

And this is it - this is how we play multiple sounds. The demo is here.

Just make sure your volume is way down when you hit play. Because it might get loud. In the next installment we'll learn how to manage the volume, a.k.a. gain.

Tell your friends about this post: Facebook, Twitter, Google+

Sorry, comments disabled and hidden due to excessive spam.

Meanwhile, hit me up on twitter @stoyanstefanov