How to implement an oscillator badly, and how to fix it

To play sounds on a computer, programmers create an array of numbers whose values correspond to where they want the voicecoil of the computer’s speaker to be at any given point in time, and then ask the computer to play that sound. This array of numbers corresponds to the “wave form” of the sound.

Simple sine waves

If you’re a programmer and want a bland tone, a sine wave is the shape you want for the waveform. A sine wave oscillating at 440 times per second (Hz) sounds like this:

In this example, the wave form is sampled 48,000 times per second and has one second of duration. The resulting array of numbers contains the values of Math.sin(x) computed at a frequency that oscillates 440 total times from the beginning to the end of the array:

const waveform = [];
// Math.sin has period 2 * pi. We want period 1/440, and
// are sampling at 48000/s. So we multiply by 440 * 2 * pi / 48000
const baseFreq = 440;
const freq = baseFreq * 2 * Math.PI / 48000;
for (let i = 0; i < 48000; ++i) {
    waveform.push(Math.sin(i * freq));
}

If you want a tone that sounds one octave higher, then you need to double the frequency of the waveform. That’s easy enough, just double the frequency of the sine wave itself, by changing the value of baseFreq to 880.

…slide

Now let’s make this a little more interesting. What if we want our tone to start at 440Hz, but end at 880Hz?

A simple attempt

Naively inspecting the code above and trying the obvious change should get you close to something like this:

const waveform = [];
const baseFreq = 440;
for (let i = 0; i < 48000; ++i) {
    const u = i / 48000;
    // currentFreq changes smoothly from 440 to 880
    // as we move along the loop
    const currentFreq = baseFreq * (1 + u); 
    const freq = currentFreq * 2 * Math.PI / 48000;
    waveform.push(Math.sin(i * freq));
}

That produces the following sound:

That’s great, we got a sliding tone with a progressively higher pitch. But pay attention to what happens if we join the three tones together. First, the 440 Hz tone, then this slide up, then the 880 Hz tone:

Somehow, at the end of the slide, we’re at a frequency higher than 880Hz (you can tell because the tone goes down in pitch when it stabilizes). What happened?

Phase

What that code above is doing is cutting tiny pieces of 48000 different sine waves and pasting them together, one from each frequency.

Unfortunately, sine waves oscillate, and sine waves of different frequencies oscillate at different rates. As a result, we have no guarantee that the sine waves “line up” at the time we splice them. And if they don’t, then we might end up with a completely different tone.

In this specific case, each splicing we do is rushing the tone a little, because its frequency is just a little higher than before. This repeated rushing of the tone makes the sine wave oscillate faster than the frequency we wanted, and so we end up with a higher pitch than we expected.

There are at least two ways to fix this. The mathy way is to bust out your diff-eq and numerical integration knowledge, and write the waveform directly.

It’s not that complicated, because sine waves have simple derivatives and Taylor series give you a great approximation. You’d write something like \(f(t + k) = f(t) + k f'(t) + 1/2k^2 f''(t) + \cdots\), remember that \(\sin'(x) = \cos(x), \cos'(x) = -\sin(x)\), apply a chain rule here and there, etc.

But there’s a much cleaner way.

When doing this computation numerically, we’ve already resigned ourselves to computing values of this function sequentially – we can only get the next term if we have the previous one.

The mathy way asks us to keep the previous value of the function we’ve evaluated. But if we know that we are generating values for an oscillator, that tells us that we’re always sampling from some base wave form (here, a sine wave). So we can instead maintain the phase of the last value we’ve evaluated. Then, when the next evaluation comes, we know what is the instant frequency we want, use that to update the phase, and reevalute the base waveform at the new phase:

const waveform = [];
const baseFreq = 440;
let phase = 0;
for (let i = 0; i < 48000; ++i) {
    const u = i / 48000;
    const currentFreq = baseFreq * (1 + u);
    phase += currentFreq * 2 * Math.PI / 48000;
    waveform.push(Math.sin(phase));
}

This small change guarantees that from one sample to another, we won’t cause big jumps in the resulting waveform.

That sounds like this:

And the three tones, concatenated, sound like this:

Upshot

This implementation is nice for one big reason. If you want to sample a different periodic base wave that isn’t a sine wave, you can just plug and play, and it all works.

And that turns out to be how FM synthesis is done; if you try to splice complicated base forms, you get completely wrong results. But integrating the phase explicitly works out great.

In addition, FM synthesis was invented in the 70s, and sines were too expensive to compute in real time. But one could use lookup tables that were sine-like for the base wave, and that worked perfectly well. A decade later we got the DX7, and the rest is electronic music history.