Fast Synthesis of Basic Oscillators

jandeboevrie1 pts0 comments

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...

phase step wave fast outbuf waves

Related Articles