Warning: This thing can be quite loud. Turn down your speakers or headphones before use!
In this round of experimentation with wavetable synthesis I began exploring the possibilities of pitch bending and modulation. At first this seemed to be a difficult task because of the general nature of wavetables, wherein waveforms are written to memory as lists of numbers. Those numbers are later read from memory and used to generate audible sound. This saves processing time, as the numbers do not have to be calculated repeatedly.
So, how can frequency modulation be achieved on a set of static numbers? First we need to understand the problem in it’s entirety. Suppose we have a list of 44,100 samples that represent one sine cycle or a 1Hz tone. Clearly this is out of the range of human hearing but if we were to read this data out of memory and use it to fill an audio buffer it might look something like this:
var pointer:int = 0; var samples:List; // -- a list of audio data for( var i:int = 0; i < BUFFER_SIZE; i++ ) { buffer.writeData( samples[pointer] ) if( pointer > samples.length ) { pointer = 0; } else { pointer++; } }
From the previous example, consider what would happen if we incremented the pointer by two in instead of one… We would read every other sample, shortening the wave by a factor of .5 and effectively increasing the frequency of the waveform to 2Hz. Now imagine if we increased the pointer by 100. We would take every hundredth sample and a tone of 441Hz would be audible as 44100/100 = 441
.
Typically we are interested in using frequency values to determine increment values. So, the question is: “For a specific frequency, what value do we increment the pointer by?”. I’ve heard this increment value referred to as the phase accumulator and the pointer is typically called the phase, so that is how I will reference them from this point forward. Fortunately, there is a well known equation that can determine the phaseAccumulator based on the. In the example below we assume that we are working with a much shorter wavetable, one with only 8192 samples in it.
var freq:Number = 440; var wavelet:List; // -- list of audio data, has a length of 8192 samples const SAMPLE_RATE = 44100; var phaseAccumulator:Number = ( freq / SAMPLE_RATE ) * wavelet.length;
The above example results in a phaseAccumulator of 81.7342407. If you recall from the first example we are adding the phaseAccumulator to the phase variable on every iteration and using the phase as a pointer in the wavelet. Now, we have a slight problem. We can not use a floating point number as a pointer. Instead we have to round the pointer before addressing a sample in the wavelet. Our example now looks something like this.
var freq:Number = 440; var wavelet:List; // -- list of audio data, has a length of 8192 samples const SAMPLE_RATE = 44100; var phaseAccumulator:Number = ( freq / SAMPLE_RATE ) * wavelet.length; for( var i:int = 0; i < BUFFER_SIZE; i++ ) { buffer.writeData( samples[ Math.round( phase ) ] ) // -- only use the round value of phase, do not actually round it if( phase + phaseAccumulator > samples.length ) { phase = 0; } else { phase += phaseAccumulator; } }
So now we have a way to calculate all frequencies from a static list of audio data. Pretty nice… this also opens up the possibility of introducing modulation and pitch-bending. To achieve the portamento effect in the example above, I use something of an old tweening trick. A second frequency variable is added, which represents a “target frequency”. Then using a simple easing equation to modulate the frequency variable we can calculate a new phaseAccumulator value. The big difference between this approach and the above example is that we now move two equations into the loop and continually calculate new values for frequency and phaseAccumulator.
for( var i:int = 0; i < BUFFER_SIZE; i++ ) { buffer.writeData( samples[ Math.round( phase ) ] ) // -- only use the round value of phase, do not actually round it freq += ( targetFreq - _freq ) * portTime; // -- small number between .009 and .0001, lower the number / longer the effect phaseAccumulator = ( freq / SAMPLE_RATE ) * length; if( phase + phaseAccumulator > samples.length ) { phase = 0; } else { phase += phaseAccumulator; } }
By easing the frequency variable we are effectively modulating the phaseAccumulator which speeds up or slows down the advancement of the phase variable across the wavelet. This adds a nice portamento or pitch bending affect. Additionally we can introduce a more regular LFO type effect by further modulating the phaseAccumulator at a regular rate. In the source code you will see that another wave table is being used as an input value for the modulation effect.
One thing to mention is that by rounding the phase value, aliasing occurs. This causes tiny artifacts to be attached to the tones and is most noticeable in pure sine tones. It can be avoided through various methods, each of which would make great topics for future articles.