Learn Web Audio from the Ground Up, Part 2:
Controlling Frequency and Pitch

Posted on by Tero Parviainen (@teropa)

In the first article we constructed sine wave oscillators that had one particular frequency: 440Hz, or the A4 standard note. In this article we'll see how we can vary the frequency and how this results in different audible pitches.

This post is part of a series I'm writing about making sounds and music with the Web Audio API. It is written for JavaScript developers who don't necessarily have any background in music or audio engineering.

What's The Relationship between Frequency and Pitch?

As we discussed last time, we can measure the frequency of sound waves in hertz, based on how many times they repeat per second. When we have a sine wave, we can control the frequency by changing the speed by which its oscillator goes around the circle.

The way this relates to pitch is that our brains interpret higher frequencies as higher pitches, and lower frequencies as lower pitches. In the previous article we were working with one particular frequency, 440Hz, but if we adjust that frequency we'll start to hear different pitches:

As you play with the sound here, you're likely to notice that you are unable to hear anything on frequencies lower than 20Hz or higher than 20kHz. 20Hz-20kHz is the typical range audible to humans. It's not exactly the same for everyone though, and also tends to decrease in the higher end with age.

It's still an impressively wide range though. The highest audible frequency has a ratio of 1000:1 compared to the lowest! Those waves on the higher end are getting pretty damn fast, oscillating up to 20,000 times every second. That's much faster than anything we can visualize or intuit.

With 20kHz we're also actually bumping into the limits of what can be captured with the 44,1khZ sampling frequency that is the default in Web Audio. On the very high end, the difference between the wave frequency and the sampling frequency is so small that we're only capturing slightly over two samples per each period of the wave!

There's a rule that governs the limits of what frequencies can be sampled without losing too much information, which goes by the fancy name of the Nyquist-Shannon sampling theorem.

Changing pitch by adjusting frequency works the same way with all sound waves, not just sine oscillators. Though something like a human voice produces vastly more complicated sound waves than just sinusoids, it still follows the same rule: When you sing a higher note, you emit sound waves with higher frequencies.

This is the cause of the chipmunk effect you get when you play a sound faster than the rate at which it was recorded. When you increase the playback rate, you're effectively squeezing the sound wave into a smaller time window, which increases its frequency:

And How About Musical Notes?

Though there's an infinite number of possible frequencies and pitches we can produce, certain frequencies are special. We label them as musical notes and then we make music using them. Most of the time, those special frequencies are the ones that form the notes of the chromatic scale.

When you play 12 successive keys (also counting black ones) from C to B on a piano keyboard, you're playing a chromatic scale. Image: Tobias R.

As we discussed, when you increase the frequency, you get a higher pitch. You might then assume that there's some kind of constant value by which we need to increase the frequency when we move from one key to the next on a piano keyboard. But this is not the case. That's because the relationship between frequencies and notes is in fact exponential: You don't add a constant number to go from one note to the next. You multiply by a constant number instead.

To me this is somewhat counterintuitive, but that's just the way it is. And there's no committee to blame for it either. No one decided that this should be the case. It's a built-in feature of our auditory system to perceive things this way.

The way this works is that whenever you double the frequency of a note, you go up an octave. And when you halve the frequency, you go down an octave. So if you take a "C" note and double the frequency, you're still playing a "C" note, but just one octave higher (e.g. "C4" instead of "C3").

Because of this exponential relationship, we can span a vast frequency domain from the lowest to the highest reaches of the audible spectrum by just playing the same "A" note in a few octaves:

Since the chromatic scale is divided into twelve notes, going up a single note means going up one 12th of an octave. You multiply the frequency by a fractional power of two: 21/12. When you go down, you're dividing, so you multiply by a negative fractional power of two: 2-1/12. When we play the whole chromatic scale within a single octave, the differences between notes aren't as pronounced, but they're still exponential. Each note's frequency is 21/12 times the previous one:

Expressed in JavaScript, if we have the frequency for one note, we can go to any other note by doing some simple multiplication or division:

// Reference note:

const A4 = 440;

// Octave jumps:

const A5 = A4 * Math.pow(2, 1); // Same as 440 * 2
const A6 = A4 * Math.pow(2, 2); // Same as 440 * 2 * 2

const A3 = A4 * Math.pow(2, -1); // Same as 440 / 2
const A2 = A4 * Math.pow(2, -2); // Same as 440 / 2 / 2

// Single note jumps:

const B4 = A4 * Math.pow(2, 1/12);
const C5 = A4 * Math.pow(2, 2/12);

const G4 = A4 * Math.pow(2, -1/12);
const F4 = A4 * Math.pow(2, -2/12);

How can I Change The Pitch of an OscillatorNode?

We've already used the frequency parameter of OscillatorNode to set the frequency (and thus pitch) for new OscillatorNodes. But we can also change the frequencies in oscillators that have already been started.

For example, we can play three successive notes by increasing the frequency at specific times using JavaScript setTimeouts:

let audioCtx = new AudioContext(); Run / Edit

let oscillator = audioCtx.createOscillator();

oscillator.frequency.value = 440;
setTimeout(() => oscillator.frequency.value *= Math.pow(2, 1/12), 1000);
setTimeout(() => oscillator.frequency.value *= Math.pow(2, 1/12), 2000);

oscillator.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 3);

The main problem with using setTimeouts is the imprecision of JavaScripts timers. JavaScript is a single-threaded language, and thus there are no guarantees that when you schedule a function with setTimeout it's run after exactly that amount of time. It might easily be several milliseconds later. Sometimes even more.

In most musical applications and games, such imprecision is not acceptable. That's why the Web Audio API comes with a more precise clock for scheduling these kinds of things. The AudioContext has a currentTime parameter that tells you, in seconds, what the current time counter of the AudioContext is. It's a floating point number that represents the number of seconds since the context was initialized.

audioCtx.currentTime

Based on this time, we can tell the frequency parameter to change at an exact point in time in the future using its setValueAtTime method. Here's the same combination of notes as in the previous example:

let audioCtx = new AudioContext(); Run / Edit

let osc = audioCtx.createOscillator();

osc.frequency.value = 440;
osc.frequency.setValueAtTime(440 * Math.pow(2, 1/12), audioCtx.currentTime + 1);
osc.frequency.setValueAtTime(440 * Math.pow(2, 2/12), audioCtx.currentTime + 2);

osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 3);

This time we schedule a couple of value changes that will take place on the audio thread where the audio processing itself happens. No JavaScript code needs to be executed at that point. The audio thread also usually runs on a higher than normal operating system priority, so it's likely to get to do its work at the right time.

How about Sliding from One Pitch to Another?

So we can switch the frequency at a point in time, but that can be pretty abrupt. Often you want to slide from one frequency to the next gradually over time. In music this is called portamento or glissando.

We could always schedule setValueAt many times at short intervals to come up with a change that sounds gradual. But the Web Audio API gives us something better, which is to simply schedule a linear ramp to occur between two frequencies over a time period. For this we have the linearRampToValueAtTime method:

let audioCtx = new AudioContext(); Run / Edit

let osc = audioCtx.createOscillator();

osc.frequency.setValueAtTime(440, audioCtx.currentTime);
osc.frequency.linearRampToValueAtTime(
  440 * Math.pow(2, 1/12),
  audioCtx.currentTime + 1
);

osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 3);

Notice that we're using setValueAtTime to set the base frequency before we schedule the ramp. This is in fact required, because there's no starting time argument in the linearRampToValueAtTime method. There's just the end time, and the ramp always starts from the previous scheduled frequency setting. If there is none, it won't work (and you can test that if you try removing the preceding setValueAtTime).

By the same token, if you have several ramps but also want to hold a single frequency for a time period between them, you need to use setValueAt to mark the point between where you hold the note and where it starts ramping again:

let audioCtx = new AudioContext(); Run / Edit

let osc = audioCtx.createOscillator();

let f1 = 440;
let f2 = 440 * Math.pow(2, 1/12);
let f3 = 440 * Math.pow(2, 2/12);

osc.frequency.setValueAtTime(f1, audioCtx.currentTime + 1);
osc.frequency.linearRampToValueAtTime(f2, audioCtx.currentTime + 2);
osc.frequency.setValueAtTime(f2, audioCtx.currentTime + 3);
osc.frequency.linearRampToValueAtTime(f3, audioCtx.currentTime + 4);

osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 5);

Here, the setValueAtTime between the two ramps allows the second note to be sustained for one second. If you were to remove it, the two ramps would just be joined and you'd get a continous slide:

You can also have non-linear ramps. An exponential ramp is one that starts off slowly and then gradually becomes steeper toward the end. In many cases, this ends up sounding smoother than a linear ramp.

You can make one of these ramps using exponentialRampToValueAtTime, which has the exact same function signature as the linear ramp:

let audioCtx = new AudioContext();  Run / Edit

let osc = audioCtx.createOscillator();

let f1 = 440;
let f2 = 440 * 2;
let f3 = 440 * 2 * 2;

osc.frequency.setValueAtTime(f1, audioCtx.currentTime + 1);
osc.frequency.exponentialRampToValueAtTime(f2, audioCtx.currentTime + 2);
osc.frequency.setValueAtTime(f2, audioCtx.currentTime + 3);
osc.frequency.exponentialRampToValueAtTime(f3, audioCtx.currentTime + 4);

osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 5);

You could also come up with a completely custom-calculated ramp by specifying the intermediate values as a Float32Array and scheduling it using setValueCurveAtTime().

With this suite of methods, we have a pretty flexible toolset of controlling frequency over time. We can already produce simple, recognizable melodies with single sine waves.

const G4 = 440 * Math.pow(2, -2/12); Run / Edit
const A4 = 440;
const F4 = 440 * Math.pow(2, -4/12);
const F3 = 440 * Math.pow(2, -16/12);
const C4 = 440 * Math.pow(2, -9/12);

let audioCtx = new AudioContext();
let osc = audioCtx.createOscillator();

let t = audioCtx.currentTime;
osc.frequency.setValueAtTime(G4, t);
osc.frequency.setValueAtTime(G4, t + 0.95);
osc.frequency.exponentialRampToValueAtTime(A4, t + 1);
osc.frequency.setValueAtTime(A4, t + 1.95);
osc.frequency.exponentialRampToValueAtTime(F4, t + 2);
osc.frequency.setValueAtTime(F4, t + 2.95);
osc.frequency.exponentialRampToValueAtTime(F3, t + 3);
osc.frequency.setValueAtTime(F3, t + 3.95);
osc.frequency.exponentialRampToValueAtTime(C4, t + 4);

osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 6);

But we haven't exhausted all the possibilities with frequency control yet. For example, we'll later look into periodically oscillating the frequency around a base pitch.

Tero Parviainen is an independent software developer and writer.

Tero is the author of two books: Build Your Own AngularJS and Real Time Web Application development using Vert.x 2.0. He also likes to write in-depth articles on his blog, some examples of this being The Full-Stack Redux Tutorial and JavaScript Systems Music.

comments powered by Disqus