Let's apply a window to a sound file. To review, a window is an example of function that tracks amplitude (aka volume) over time. Typically, a window will fade in from complete silence, reach a desired volume, and then fade out. It's a bit easier to understand visually: here are some examples of common windowing functions.
Since windowing is an amplitude-based operation, we should look at the
GainNode
API.
GainNode
has a .gain
property which is implemented as an
AudioParam
,
meaning we can use AudioParam
methods like
.setValueCurveAtTime()
.
This method takes an array of gain values, and spreads them out equally over a
period of time. So, if the array is [0, 0.3, 1, 0.3, 0]
, the window will
look like this. If the array is [0, 1, 0.4, 0.4, 0.4, 0]
, the window will
look like this. Any array that starts and ends with a 0
should work well
as a window.
const audioCtx = new AudioContext();
const dac = audioCtx.destination;
let buffer = null;
const request = new XMLHttpRequest();
request.open("GET", "freejazz.wav", true);
request.responseType = "arraybuffer";
request.onload = () => audioCtx.decodeAudioData(request.response,
(data) => buffer = data);
request.send();
Let's start with this code here. As you can see, we have set up our
AudioContext
and dac, and created a variable called buffer
to hold our
sound file. Then, we have loaded our sound file asynchronously using an
XMLHttpRequest
.
Now, let's set up a buffer source node and a corresponding gain node, and then connect them all together. We use the term "grain" to refer to a very short, windowed excerpt of a sound file.
const grain = audioCtx.createBufferSource();
const grainGain = audioCtx.createGain();
grain.connect(grainGain);
grainGain.connect(audioCtx.destination);
Let's also not forget to tell the buffer source what buffer to look at!
const grain = audioCtx.createBufferSource();
grain.buffer = buffer;
const grainGain = audioCtx.createGain();
grain.connect(grainGain);
grainGain.connect(audioCtx.destination);
Now it's time to set up our window. We start by initializing our gain to 0
.
Then, we build our audio curve. For the .setValueCurveAtTime()
API to work,
the curve needs to be in a special array type called
Float32Array
.
Let's make this a relatively long grain, with a grain duration of
half-a-second.
grainGain.gain.setValueAtTime(0, audioCtx.currentTime);
const curve = new Float32Array([0, 0.3, 1, 0.3, 0]);
grainGain.gain.setValueCurveAtTime(curve, audioCtx.currentTime, 0.5);
Finally, let's play the grain.
grain.start();
Now, let's wrap everything we did in a callback function, making it so that we
play our grain whenever the user clicks a button. I've already set up an HTML
page with a button with the ID play
.
const playGrain = () => {
const grain = audioCtx.createBufferSource();
grain.buffer = buffer;
const grainGain = audioCtx.createGain();
grain.connect(grainGain);
grainGain.connect(dac);
grainGain.gain.setValueAtTime(0, audioCtx.currentTime);
const curve = new Float32Array([0, 0.3, 1, 0.3, 0]);
grainGain.gain.setValueCurveAtTime(curve, audioCtx.currentTime, 0.5);
grain.start();
};
$("#play").on("click", playGrain);
Right now, our granulator is very limited. Each of our grains starts right at the beginning of the file, lasts exactly half a second, and triggers the moment you click the "play" button. Let's try to make the behavior of our granulator more sophisticated.
First, let make the grain duration and start time user-assignable, by adding
them as arguments to playGrain()
. We have to change all instances of
audioCtx.currentTime
to startTime
, and our 0.5
to grainDuration
. We
should also add startTime
as an argument to the grain.start()
method.
const playGrain = (startTime, grainDuration) => {
const grain = audioCtx.createBufferSource();
grain.buffer = buffer;
let grainGain = audioCtx.createGain();
grain.connect(grainGain);
grainGain.connect(dac);
grainGain.gain.setValueAtTime(0, startTime);
const curve = new Float32Array([0, 0.3, 1, 0.3, 0]);
grainGain.gain.setValueCurveAtTime(curve, startTime, grainDuration);
grain.start(startTime);
};
Now, let's have the granulator play a random grain. The second argument of
grain.start()
is the "offset", meaning how many seconds into the sound file
we should play. We can calculate our offset using JavaScript's built-in
Math.random()
function, which selects a random float between 0 and just a hair under 1. If
we multiply this by the duration of our buffer, we will get a random timestamp
in the bounds we want. However, since the grain has to play through in its
entirety, we should subtract the grain duration from the buffer duration, so
that grain.start()
will not run out of file to play.
const offset = Math.random() * (buffer.duration - grainDuration);
Now we can add that offset to grain.start()
. The third argument is how long
the file should play. Even though it is not strictly necessary to add this
argument—since our window will effectively silence each grain after the end of
the amplitude curve—manually stopping the playback in this way will conserve a
bit of memory and CPU.
grain.start(startTime, offset, grainDuration);
Finally, let's modify our playback so that it plays a bunch of random grains. Let's launch a new grain every 10 milliseconds, and have our grains be 50 milliseconds long. This means that at any one time, 5 grains will be overlapping with one another.
for (let i = 0; i < 1000; i += 1) {
playGrain(audioCtx.currentTime + i * 0.01, 0.05);
}
You can download a slightly more enhanced version of the granulator on the page below this video.
Download the files used in the above examples by right-clicking the links, and then selecting "Save Link As...".