Fast Synthesis of Basic Oscillators
Fast Synthesis of Basic Oscillators | artemis.sh
I’ve been working on synthesizers for the GBA and rp2040 the past couple years,<br>and I’ve made some fast oscillators.
So first of all, this all applies to doing synthesis with integers. I think for<br>oscillators, even if you do have an FPU, ints are much easier to deal with<br>anyway because of the wrapping.
We’ll be doing math entirely with 32-bit values in this post, for two reasons.<br>One is, it’s the native register size on the GBA, and what I’ve been doing with<br>for the past couple years. The other: it’s a very nice sweet spot for pitch<br>resolution. You can represent all pitches within the audible range quite<br>accurately when dealing with a 32-bit phase. As you reduce the bit-depth of<br>your phase, you start losing pitch accuracy in the bass. You can work with<br>that, and many demosceners do, but it becomes something you have to actively<br>think about when composing your music. With 32-bit, its a non-issue. Anything<br>beyond 32-bit doesn’t really provide much benefit.
Most of the waveforms I’m showing here will have aliasing problems. The sharp<br>transitions create audible artifacts that don’t sound super great. One way to<br>fix this is with PolyBLEP.
I’ve never implemented that, and anyway the point of this code is to be fast at<br>all cost. This is for super constrained environments, where you have to accept<br>the aliasing is there and move on. For alias-free sounds on a budget, you can<br>use phase modulation between sine waves and you’ll get super clean sounds for<br>very cheap, though not as cheap as these waves. I’ll do another post about that.<br>If I ever do a fast PolyBLEP I’ll post about that too, but it’s not on my to-do<br>right now.
TOC
Saw
Wave from Memory
Square
Triangle
Pulse Waves
Parabola approximation of a sine wave
Saw
All oscillators start life as a saw wave, and a saw wave has two components: the<br>current phase, and a value added to the phase each audio sample.
phase: u32 = 0<br>step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32<br>outbuf: [i32; num_samples]
Each audio sample, we add the step to our phase, and the output of our<br>oscillator is the phase. This gives us a ramp-up saw.
let mut phase: u32 = 0;<br>let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;<br>let mut outbuf: [i32; num_samples];
for i in 0..num_samples {<br>phase = phase.wrapping_add(step);<br>outbuf[i] = phase as i32;
// Arm assembly<br>// loop:<br>// add phase, step<br>// str phase, [outbuf], 4<br>// subs count, 1<br>// bne loop
1 / /<br>/| /|<br>/ | / |<br>/ | / |<br>0 / | / |<br>| / | /<br>| / | /<br>|/ |/<br>-1 / /
Each time phase overflows, we’ve completed one oscillation.
If you’re familiar with the concept of the nyquist<br>frequency, it’s quite obvious<br>here: when our phase step is 0x80000000, our wave oscillates once every<br>two-samples, at exactly 1/2 our sample rate. If our phase step is higher than<br>0x80000000, we start overflowing more than once every two samples, but our<br>output actually starts slowly rolling backwards. This also flips our saw wave<br>to a ramp-down saw instead of a ramp-up. Taken as a signed integer, phases >=<br>0x80000000 are negative. Naturally, as our phase step grows beyond<br>0x80000000 we start oscillating slower and slower.
Wave from memory
If you shift down your saw, you can use it to index a wave stored in memory,<br>and do all the wave table synthesis your heart desires. You’ll get the best<br>performance with 8-bit waves, because it takes an extra instruction to mask off<br>the low address bits when accessing 16 or 32-bit waves. So I usually use 8-bit<br>waves, as I don’t find waveforms benefit from the dynamic range of 16-bits the<br>way samples do.
This example uses 1024-sample waves, adjust the bit shifts for other sizes. Make<br>sure to store your wave somewhere with fast access times.
let mut phase: u32 = 0;<br>let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;<br>let mut outbuf: [i32; num_samples];
let wavedata: [i8; 1024] = /* ... */
for i in 0..num_samples {<br>phase = phase.wrapping_add(step);<br>let out = wavedata[(phase >> 22) as usize];
// Scale up the output if you need to, or<br>// leave it 8-bit to save some cycles cycles<br>// going into a fixed-point volume control :)<br>outbuf[i] = out;
// Arm assembly<br>// loop:<br>// add phase, step<br>// lsr out, phase, 22<br>// ldrsb out, [wavedata, out]<br>// str out, [outbuf], 4<br>// subs count, 1<br>// bne loop
Square
There’s two approaches you can take depending on personal taste, and what’s fast for<br>your hardware.
Mathematic calculation
Calculating mathematically is best if you don’t have branchless conditionals.<br>This is true of thumbv1, like you find on common Cortex-M0+ processors. It’s<br>fine everywhere else too, so reach for this first.
let mut phase: u32 = 0;<br>let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;<br>let mut outbuf: [i32; num_samples];
for i in 0..num_samples {<br>phase = phase.wrapping_add(step);
// out = 0x00000000 when phase = 0x80000000<br>let mut out = (phase as...