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.
- Part 0: What Is the Web Audio API?
- Part 1: Signals and Sine Waves
- Part 2: Controlling Frequency and Pitch
- Part 3: Controlling Amplitude and Loudness
- Part 4: Additive Synthesis And the Harmonic Series
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.
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:
// 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
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.
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
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.
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);
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
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);
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.