Making music with the Web Audio API

Nick Morgan / @skilldrick

Cissy Strut

The Meters, 1969

Cissy, So What, Superstition, Papa, Chameleon

Skilldrick, 2012

Cissy in JS

Skilldrick, 2015

The Web Audio API

Creating the AudioContext


// webkit prefix needed for Safari and iOS Safari
var ctx = new (window.AudioContext || window.webkitAudioContext)();
          

Loading Audio


function getAudioBuffer(ctx, filename, cb) {
  getData(filename, function (audioData) {
    ctx.decodeAudioData(audioData,
      function (buffer) {
        cb(buffer);
      }
    );
  });
}

function getData(filename, cb) {
  var request = new XMLHttpRequest();
  request.open('GET', filename, true);
  request.responseType = 'arraybuffer';
  request.onload = function() {
    cb(request.response);
  }
  request.send();
}
          

Source code

Creating and playing an AudioBufferSourceNode


var ctx = new (window.AudioContext || window.webkitAudioContext)();

getAudioBuffer(ctx, 'shared/cissy-strut.mp3', function (buffer) {
  // Create a new BufferSourceNode
  function createSource() {
    var source = ctx.createBufferSource();
    source.buffer = buffer;
    source.connect(ctx.destination);
    return source;
  }

  window.playSource = function () {
    var source = createSource();
    source.start(0, 35.52, 1.4);
  };
});
          

Source code

playBufferFrom


function playBufferFrom(offset, length) {
  var source = createSource(); // From previous slide
  source.start(0, offset, length);
}

window.playMain = function () {
  playBufferFrom(35.52, 1.4);
};

window.playShort1 = function () {
  playBufferFrom(51.61, 1/6);
};

window.playShort2 = function () {
  playBufferFrom(50.24, 1/6);
};
          

Source code

Creating a GainNode


function createSource(output) {
  var source = ctx.createBufferSource();
  source.buffer = buffer;
  source.connect(output);
  return source;
}

function playBufferFrom(offset, length, output) {
  var source = createSource(output);
  source.start(0, offset, length);
}

window.playWithGain = function (gain) {
  var gainNode = ctx.createGain();
  gainNode.connect(ctx.destination);
  gainNode.gain.value = gain;

  playBufferFrom(35.52, 1.4, gainNode);
};
          

Source code

Playing in the Future


function playBufferFrom(when, offset, length, output) {
  var source = createSource(output);
  source.start(when, offset, length);
}

window.playFourTimes = function () {
  playBufferFrom(ctx.currentTime + 0 * 1.33, 35.52, 1.33, ctx.destination);
  playBufferFrom(ctx.currentTime + 1 * 1.33, 35.52, 1.33, ctx.destination);
  playBufferFrom(ctx.currentTime + 2 * 1.33, 35.52, 1.33, ctx.destination);
  playBufferFrom(ctx.currentTime + 3 * 1.33, 35.52, 1.33, ctx.destination);
};
          

Source code

Declaratively Defining Patterns


var bpm = 90;
var beatLength = 60 / bpm;
var quarterBeatLength = beatLength / 4;

// Locations of each sample within cissy-strut.mp3
var labels = {
  short1: 51.61,
  short2: 50.24
};

var fastPattern = [
//  label   offset  length
  ['short1',  0,      1],
  ['short1',  2,      1],
  ['short1',  5,      1],
  ['short1',  7,      1],
  ['short1',  8,      1],
  ['short1',  9,      1],
  ['short2', 10,      1],
  ['short2', 11,      1],
  ['short1', 12,      1],
  ['short1', 13,      1],
  ['short2', 14,      1],
  ['short1', 15,    .33],
  ['short1', 15.33, .33],
  ['short1', 15.66, .33],
  ['short1', 16,      1],
  ['short1', 18,      1],
  ['short1', 21,      1],
  ['short1', 23,    .33],
  ['short1', 23.33, .33],
  ['short1', 23.66, .33],
  ['short1', 24,      1],
  ['short1', 25,      1],
  ['short2', 26,      1],
  ['short2', 27,      1],
  ['short1', 28,      1],
  ['short1', 29,      1],
  ['short2', 30,    .33],
  ['short2', 30.33, .33],
  ['short2', 30.66,   1]
];

// start is measured in quarter-beats from start of playing
// length is measured in quarter-beats
function playSample(label, start, length, output, startTime) {
  playBufferFrom(
    startTime + start * quarterBeatLength,
    labels[label],
    length * quarterBeatLength,
    output
  );
}

function playPattern(pattern, output, startTime) {
  pattern.forEach(function (sample) {
    playSample(
      sample[0],
      sample[1],
      sample[2],
      output,
      startTime
    );
  });
}

window.playFastPattern = function () {
  playPattern(fastPattern, ctx.destination, ctx.currentTime);
};
          

Source code

Playing the Same Pattern Multiple Times


var quarterBeatsPerPattern = 32;

function playPattern(pattern, patternNumber, output, startTime) {
  pattern.forEach(function (sample) {
    playSample(
      sample[0],
      patternNumber * quarterBeatsPerPattern + sample[1],
      sample[2],
      output,
      startTime
    );
  });
}

window.playFastPatternTwice = function () {
  playPattern(fastPattern, 0, ctx.destination, ctx.currentTime);
  playPattern(fastPattern, 1, ctx.destination, ctx.currentTime);
};
          

Source code

Putting it All Together


var ctx = new (window.AudioContext || window.webkitAudioContext)();

getAudioBuffer(ctx, 'shared/cissy-strut.mp3', function (buffer) {
  var bpm = 90;
  var beatLength = 60 / bpm;
  var quarterBeatLength = beatLength / 4;
  var quarterBeatsPerPattern = 32;
  var buffer;
  var channels = [];

  // Locations of each sample within cissy-strut.mp3
  var labels = {
    main: 35.52,
    short1: 51.61,
    short2: 50.24,
    cymbal: 42.73,
    drum: 38.45,
  };

  var fastPattern = [
  //  label   offset  length
    ['short1',  0,      1],
    ['short1',  2,      1],
    ['short1',  5,      1],
    ['short1',  7,      1],
    ['short1',  8,      1],
    ['short1',  9,      1],
    ['short2', 10,      1],
    ['short2', 11,      1],
    ['short1', 12,      1],
    ['short1', 13,      1],
    ['short2', 14,      1],
    ['short1', 15,    .33],
    ['short1', 15.33, .33],
    ['short1', 15.66, .33],
    ['short1', 16,      1],
    ['short1', 18,      1],
    ['short1', 21,      1],
    ['short1', 23,    .33],
    ['short1', 23.33, .33],
    ['short1', 23.66, .33],
    ['short1', 24,      1],
    ['short1', 25,      1],
    ['short2', 26,      1],
    ['short2', 27,      1],
    ['short1', 28,      1],
    ['short1', 29,      1],
    ['short2', 30,    .33],
    ['short2', 30.33, .33],
    ['short2', 30.66,   1]
  ];

  var slowPattern = [
    ['main',  0, 8],
    ['main',  8, 8],
    ['main', 16, 8],
    ['main', 24, 8]
  ];

  var cymbalPattern = [
    ['cymbal',  0, 3],
    ['cymbal',  4, 3],
    ['cymbal',  8, 3],
    ['cymbal', 12, 3],
    ['cymbal', 16, 3],
    ['cymbal', 20, 3],
    ['cymbal', 24, 3],
    ['cymbal', 28, 3]
  ];

  var drumPattern = [
    ['drum',  0, 1],
    ['drum',  2, 1],
    ['drum',  4, 1],
    ['drum',  6, 1],
    ['drum',  8, 2],
    ['drum', 10, 1],
    ['drum', 12, 2],
    ['drum', 14, 1],
    ['drum', 15, 1],
    ['drum', 16, 1],
    ['drum', 18, 1],
    ['drum', 20, 2],
    ['drum', 22, 1],
    ['drum', 23, 1],
    ['drum', 24, 2],
    ['drum', 26, 1],
    ['drum', 28, 1],
    ['drum', 29, 1],
    ['drum', 30, 2]
  ];

  function createSource(output) {
    var source = ctx.createBufferSource();
    source.buffer = buffer;
    source.connect(output);
    return source;
  }

  function playBufferFrom(when, offset, length, output) {
    createSource(output).start(when, offset, length);
  }

  // start is measured in quarter-beats from start of playing
  // length is measured in quarter-beats
  function playSample(label, start, length, output, startTime) {
    playBufferFrom(
      startTime + start * quarterBeatLength,
      labels[label],
      length * quarterBeatLength,
      output
    );
  }

  // pattern is one of the four patterns above
  function playPattern(pattern, patternNumber, output, startTime) {
    pattern.forEach(function (sample) {
      playSample(
        sample[0],
        patternNumber * quarterBeatsPerPattern + sample[1],
        sample[2],
        output,
        startTime
      );
    });
  }

  // Always queue the next pattern one pattern-length before it's due
  function keepTriggering(cb) {
    var startTime = ctx.currentTime;
    var patternIndex = 0;

    cb(patternIndex, startTime);

    setInterval(function () {
      var elapsedTime = ctx.currentTime - startTime;
      var elapsedBeats = elapsedTime / quarterBeatLength;
      var nextIndex = Math.floor(elapsedBeats / quarterBeatsPerPattern) + 1;
      if (nextIndex > patternIndex) {
        patternIndex = nextIndex;
        cb(patternIndex, startTime);
      }
    }, 1000);
  }

  $('.cissy-demo .sliders input').on("input", function () {
    var $el = $(this);
    var channel = $el.data('channel');
    var value = $el.val();
    channels[channel].gain.value = value;
  });


  function start() {
    // Create four channels, each of which is a gain node connected to the
    // destination node
    for (var i = 0; i < 4; i++) {
      channels[i] = ctx.createGain();
      channels[i].gain.value = 0;
      channels[i].connect(ctx.destination);
    }

    keepTriggering(function (i, startTime) {
      playPattern(slowPattern, i, channels[0], startTime);
      playPattern(fastPattern, i, channels[1], startTime);
      playPattern(cymbalPattern, i, channels[2], startTime);
      playPattern(drumPattern, i, channels[3], startTime);
    });

    channels.forEach(function (channel, index) {
      $('input[data-channel=' + index + ']').val(channel.gain.value);
    });
  }

  $(".cissy-demo .loading").hide();

  start();
});
          

Source code

Demo (again)

THE END

Some Stuff I Made

Other links