Synthio Tutorial: 4. Oscillators & Wavetables
- About Oscillators
- Change a note’s oscillator waveform
- Mixing between waveforms
- Use a Wavetable
- Wavetable scanning
- Use a WAV as an oscillator
- Fatter sounds with detuned oscillators
- Fun with wavetables: wavetabledrone
- Research links
- Next Steps
About Oscillators
Most synthesizers have a small selection of waveforms as the starting point for a sound, perhaps: square wave, saw wave, and sine wave. Some synths allow you to “morph” between these waveshapes, gradually turning a sine into a square, for instance. Some synths provide a square wave with an modulatable pulse width, giving a characteristic sound. But in almost all cases, the waveforms are these simple shapes. You can do a lot with these simple shapes.
In synthio
, instead of these small selection of waveforms to choose from, we
can instead assign any buffer of numbers as our waveform! This is amazing and
one of synthio’s best features. Also, the waveform can be changed at any time to
alter the sound of the waveform. (Unfortunately, there’s no way to tell synthio
exactly when in the waveform to switch to the new one, so you can get glitches
if you shift to waveform that’s very different)
Change a note’s oscillator waveform
To use a different waveform than the stock square wave,
assign the .waveform
property of the synthio.Note
object.
You can do this when constructing the Note
object or while the Note is sounding.
Creating good waveforms is a whole other topic, but we can use some math and
numpy
commands to help us make some simple ones, like in this example below.
This is similar to the numpy
commands we used to generate custom LFO waveforms,
but unlike LFO waveforms, a Note
’s waveform doesn’t interpolate between values
for us.
There are two main parameters when creating waveforms: how many samples in the wave
(NUM_SAMPLES
) and the maximum amplitude of the those samples (VOLUME
). For
complex waves, having more samples will be a more accurate representation of your
sound at the expense of using up more RAM. The maximum amplitude of a waveform
in synthio
is a +/-32767, since internally the samples are stored as 16-bit
signed integers. In practice, it’s usually good to have your waveform’s max
be a little understand to help prevent clipping.
These waveforms are “single-cycle” waveforms to synthio
. They’re meant to represent
once oscillation and if your waveform contains more than one oscillation, the pitch
of your waveform will not match what synthio
thinks it is.
# 4_oscillators/code_waveform1.py
import time, random
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA, knobB
NUM = 256 # number of samples in a waveform
VOL = 32000 # loudness (volume) of samples, np.int16 ranges from 0-32767
# sine wave, just like in trig class
wave_sine = np.array(np.sin(np.linspace(0, 2*np.pi, NUM, endpoint=False)) * VOL, dtype=np.int16)
# sawtooth wave, looks like a downward ramp
wave_saw = np.linspace(VOL, -VOL, num=NUM, dtype=np.int16)
# square wave, like the default
wave_square = np.concatenate((np.ones(NUM // 2, dtype=np.int16) * VOL,
np.zeros(NUM // 2, dtype=np.int16) * -VOL))
# 'noise' wave made with random numbers (not a very good random noise)
wave_noise = np.array([random.randint(-VOL, VOL) for i in range(NUM)], dtype=np.int16)
my_waves = [wave_sine, wave_saw, wave_square, wave_noise]
my_wave_names = ["sine", "saw", "square", "noise"]
note1 = synthio.Note(0) # start a note playing with default waveform
synth.press(note1)
wi=0 # wave index into my_waves
while True:
note1.frequency = synthio.midi_to_hz(32 + int((knobA.value/65535)*32))
note1.waveform = my_waves[wi] # pick new wave for playing note
print("wave:", wi, my_wave_names[wi])
time.sleep( (knobB.value/65535) ) # knobB controls tempo
wi=(wi+1) % len(my_waves) # pick a new waveform
Mixing between waveforms
Many synths have the ability to “morph” between two different waveform shapes,
like sine wave to saw wave. With synthio
waveforms, we can do that too, by taking
two existing waveforms and mixing them together.
The numpy
library can help here. It efficiently performs operations on arrays
of numbers (our waveform). So we can use numpy
to create a simple lerp()
function that mixes between two numpy arrays, resulting in a mixed waveform.
Note we have to do this mixing “by hand”, we cannot use synthio.MathOperation.CONSTRAINED_LERP
because it only works on single values, not arrays/lists.
(But in a future example, you’ll see how we can use a synthio.LFO
for our mix position.
We just have to copy “by hand” the value from the LFO to our lerp()
function)
In this example, a Note is played playing a wave that’s the mix between a sine wave and a saw wave. The mix speed is controlled by knobA and knobB controls the pitch of the continuously-sounding Note. As the mix goes from sine wave to saw wave, it sounds almost like the sawtooth wave is being filtered down. This is one of the neat aspects of doing this kind of wave mixing: we can fake filter sweeps just by mixing waves! You’ll see much more of this in the wavetable examples.
# 4_oscillators_waveforms/code_wavemix.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA, knobB
NUM = 256 # number of samples in a waveform
VOL = 32000 # loudness (volume) of samples, np.int16 ranges from 0-32767
wave_sine = np.array(np.sin(np.linspace(0, 2*np.pi, NUM, endpoint=False)) * VOL, dtype=np.int16)
wave_saw = np.linspace(VOL, -VOL, num=NUM, dtype=np.int16)
# empty buffer we copy wave mix into
wave_empty = np.zeros(NUM, dtype=np.int16)
note = synthio.Note(frequency=220, waveform=wave_empty)
synth.press(note)
# mix between values a and b, works with numpy arrays too, t ranges 0-1
def lerp(a, b, t): return (1-t)*a + t*b
wave_pos = 0
while True:
print("%.2f" % wave_pos)
mix_speed = 0.001 + (knobA.value/65535) * 0.2 # 0.001 - 0.2
note.waveform[:] = lerp(wave_sine, wave_saw, wave_pos)
note.frequency = synthio.midi_to_hz(48 + (knobB.value/65535)*12)
wave_pos = (wave_pos + mix_speed) % 1.0 # move our mix position
time.sleep(0.01)
Use a Wavetable
In the code_waveform1
example above, the waves were put in an array so we could switch
them out easily. This is how wavetables work.
Wavetables let us store several different (potentially harmonically related) waveforms in a single unit and call each wave up indepedently. There was a site called waveeditonline.com that had a wonderful collection of free waveforms in a wavetable format, each wavetable stored as a standard WAV file. Each wavetable contains 64 waveforms with each waveform having 256 samples. When visualized, one of those wavetables would look like:
While the waveditonline site is gone, the “wav-files.zip] bundle it provided lives on in various places (like the wav-files.zip bundle here from Jul 2023) It’s a shame beacuse it had great visualizations of each wavetable, like this one for the “BRAIDS01.WAV” wavetable above.
Since CircuitPython can read WAV files, we can use those wavetables to easily add new sonic textures to our synths.
In the “4_oscillators_wavetables” directory of this tutorial,
there is a “wavetables” directory containing some good wavetables from waveeditonline.
Copy the entire “wavetables” directory to the CIRCUITPY drive.
The example below will look in there. Also install the adafruit_wave
library.
It’s availble from the CircuitPython bundle
or installable with circup install adafruit_wave
from the commandline.
In the example below, there is a small Wavetable
class that makes loading a
wavetable and selecting a waveform within the wavetable eaiser. That class is
also available as wavetable.py
for your own use.
When the example is running, use knobA to pick a waveform within a wavetable and
use the button to select another wavetable.
It’s amazing how many different sounds you can get so easily by using wavetables!
And remember: this is just one synthio.Note
and you have 11 more.
# 4_oscillators_waveforms/code_wavetable.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, keys, knobA
import adafruit_wave
wave_dir = "/wavetables/" # wavetables from old http://waveeditonline.com/
wavetables = ["BRAIDS01.WAV","DRONE.WAV","SYNTH_VO.WAV","PPG_BES.WAV"]
wavetable_num_samples = 256 # number of samples per wave in wavetable
wti = 0 # index into wavetables list
class Wavetable:
""" A 'waveform' for synthio.Note uses a WAV containing a wavetable
and provides a scannable wave position."""
def __init__(self, filepath, wave_len=256):
self.w = adafruit_wave.open(filepath)
self.wave_len = wave_len # how many samples in each wave
if self.w.getsampwidth() != 2 or self.w.getnchannels() != 1:
raise ValueError("unsupported WAV format")
self.waveform = np.zeros(wave_len, dtype=np.int16) # empty buf to fill
self.num_waves = self.w.getnframes() // self.wave_len
self.set_wave_pos(0)
def set_wave_pos(self, pos):
"""Pick which wave to use in the wavetable"""
pos = min(max(pos, 0), self.num_waves-1) # constrain
samp_pos = int(pos) * self.wave_len # get sample position
self.w.setpos(samp_pos) # seek to wavetable location
waveA = np.frombuffer(self.w.readframes(self.wave_len), dtype=np.int16)
self.waveform[:] = waveA # copy into buf
self._wave_pos = pos
wavetable1 = Wavetable(wave_dir+wavetables[wti]) # load up wavetable
midi_note = 45 # A2
note = synthio.Note(synthio.midi_to_hz(midi_note), waveform=wavetable1.waveform)
synth.press(note) # start note sounding
pos = 0 # last knob position
while True:
if key := keys.events.get(): # button pushed
if key.pressed:
wti = (wti+1) % len(wavetables) # go to next index
wavetable1 = Wavetable(wave_dir+wavetables[wti]) # load new wavet
note.waveform = wavetable1.waveform # attach to note
new_pos = (knobA.value / 65535) * wavetable1.num_waves
pos = int((new_pos*0.5) + pos*0.5) # filter knob input
wavetable1.set_wave_pos(pos) # pick new wavetable
print("%s: wave num:%d" % (wavetables[wti], pos))
time.sleep(0.01)
Wavetable scanning
As you use the knob to scan the wavetable above, you may notice how jarring it
is two switch between two waves that aren’t very related. The Wavetable
class
can be expanded so that it mixes between adjacent waves in the wavetable,
much like the code_wavemix.py
example above. This makes for a much smoother
scanning along the wavetable.
First let’s move the Wavetable class to its own file and add in the wave mixing:
# 4_oscillators_waveforms/wavetable.py
import ulab.numpy as np
import synthio
import adafruit_wave
class Wavetable:
""" A 'waveform' for synthio.Note that uses a wavetable w/ a scannable wave position."""
def __init__(self, filepath, wave_len=256):
self.w = adafruit_wave.open(filepath)
self.wave_len = wave_len # how many samples in each wave
if self.w.getsampwidth() != 2 or self.w.getnchannels() != 1:
raise ValueError("unsupported WAV format")
self.waveform = np.zeros(wave_len, dtype=np.int16) # empty buffer we'll copy into
self.num_waves = self.w.getnframes() // self.wave_len
self._wave_pos = 0
@property
def wave_pos(self): return self._wave_pos
@wave_pos.setter
def wave_pos(self, pos):
"""Pick where in wavetable to be, morphing between waves"""
pos = min(max(pos, 0), self.num_waves-1) # constrain
samp_pos = int(pos) * self.wave_len # get sample position
self.w.setpos(samp_pos)
waveA = np.frombuffer(self.w.readframes(self.wave_len), dtype=np.int16)
self.w.setpos(samp_pos + self.wave_len) # one wave up
waveB = np.frombuffer(self.w.readframes(self.wave_len), dtype=np.int16)
pos_frac = pos - int(pos) # fractional position between wave A & B
self.waveform[:] = Wavetable.lerp(waveA, waveB, pos_frac) # mix waveforms A & B
self._wave_pos = pos
# mix between values a and b, works with numpy arrays too, t ranges 0-1
def lerp(a, b, t): return (1-t)*a + t*b
Save that file to the CIRCUITPY drive and we can use that in an updated wavetable scanning example. In the example below, let’s use the “PLAITS02.WAV” wavetable, which looks like this:
I think it sounds particularly neat. Let’s also use a synthio.LFO
to do
the wavetable scanning for us. The knobA controls the scan rate and knobB
controls the pitch as before, but check out how smooth the fades between waves
are! It can sound really great. Moving through certain wavetables can sound like
a complex modular synth patch when all we’re doing is moving around the wavetable.
This is the feature that really sold me on synthio
.
# 4_oscillators_waveforms/code_wavetable_scan.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA, knobB
from wavetable import Wavetable
wavetable_fname = "wavetables/PLAITS02.WAV" # from http://waveeditonline.com/
wavetable1 = Wavetable(wavetable_fname)
midi_note = 48
note = synthio.Note(synthio.midi_to_hz(midi_note), waveform=wavetable1.waveform)
synth.press(note)
# create a positive ramp-up-down LFO to scan through the waveetable
wave_lfo = synthio.LFO(rate=0.05, waveform=np.array((0,32767), dtype=np.int16))
wave_lfo.scale = wavetable1.num_waves
synth.blocks.append(wave_lfo) # this activates LFO when not attached to Note
while True:
# regularly copy LFO to wave_pos by hand
wavetable1.wave_pos = wave_lfo.value
wave_lfo.rate = (knobA.value/65535) * 0.25
print("wave_pos:%.2f" % wavetable1.wave_pos)
time.sleep(0.01)
Use a WAV as an oscillator
Since synthio
oscillator waveforms live in RAM and WAV files are normally
big compared to available RAM, you won’t be able to just load up any WAV file
and use it as a waveform. You can see what the largest sample you can load
by looking at synthio.waveform_max_length
.
On RP2040, synthio.waveform_max_length=16384
. This means you will need to
curate and downsample a WAV to fit as a synthio
waveform.
In the “4_oscillators_waveforms” directory, there is a shortened and down-sampled version of the famous “Amen break” drumloop, called “amen1_8k_s16.wav”. Copy that file to the CIRCUITPY drive for the example below. It’s been down-sampled to 8kHz sample rate and lasts for 1.75 seconds. This gives 14001 samples, just small enough to fit into the avaiable 16384 maximum.
Beacuse synthio
thinks of its oscillator waveforms as single-cycle waves,
when using it with a standard WAV file, to get the sample to play back at its
original speed, we need to set note.frequency
based on the WAV’s
sample rate and the number of samples in the WAV. The example
shows how to calculate the WAV file duration and use that to set .frequency
.
This works really only for drumloops.
Calculating .frequency
for pitched samples is “left as an experiement for the reader”.
So for the samples we can load, it means we have fine-grained control over their speed and pitch! Check out the example where knobA controls the pitch of the drum loop.
# 4_oscillators_wavetables/code_wavewav.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA
import adafruit_wave
# reads in entire wave into RAM
# return tuple of (memoryview on the WAV, sample_rate, num_samples)
def read_waveform(filename):
with adafruit_wave.open(filename) as w:
if w.getsampwidth() != 2 or w.getnchannels() != 1:
raise ValueError("unsupported format")
return (memoryview(w.readframes(w.getnframes())).cast('h'),
w.getframerate(), w.getnframes())
wave_wav, sample_rate, num_samples = read_waveform("/amen1_8k_s16.wav")
duration = num_samples / sample_rate
print("sample_rate:%d num_samples:%d duration:%.2f" % (sample_rate, num_samples, duration))
note = synthio.Note(frequency=1/duration, waveform=wave_wav)
synth.press(note)
while True:
note.frequency = (1/duration) * (0.25 + (knobA.value / 65535) * 1.5)
print("note freq:%6.3f duration:%5.2f" % (note.frequency, 1/note.frequency))
time.sleep(0.2)
Fatter sounds with detuned oscillators
Typical synthesizer architectures have two or three oscillators
(with potentially different waveforms) that sound together and then fed
through the amplifier (controlled by the amplitude envelope) and filter (controlled
by the filter envelope). While synthio
only has a single oscillator in its
voice architecture, we can double- or triple-up those voices, triggering them
at the same time, to approximate this type of typical synth. Yes, the filters get doubled,
but the modulators (amp & filter envs) can be shared and it does open the possibily
of having different filters on each oscillator.
(And it does mean we could use different filters for each oscillator, something most
synths cannot do!)
One technique to quickly make a synth patch sound better is to detune its oscillators.
We have fine-grained control over a synthio.Note
’s frequency with note.frequency
,
so we can do use that to “detune” oscilators to get a “fatter” sound. We could
even attach a very subtle LFO to note.bend
to emulate the small tuning fluctuations
of an analog synth.
Also good to note that synthio.midi_to_hz()
allows a floating-point
value for the MIDI note number. This allows you to detune more musically
than doing it on frequency.
The example below sets up “knobA” to control the number of oscillators sounding (from 1 to 6) and “knobB” controls how much detune between each oscillator.
Listen to how thick the sound can get! If this is starting to sound to you like the famous “SuperSaw” sound that was common in electronic music in the early 2000s, you’re right.
# 4_oscillators_wavetables/code_detune.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA, knobB
wave_saw = np.linspace(32000, -32000, num=128, dtype=np.int16) # saw osc
midi_note = 45
while True:
num_oscs = int(knobA.value/65535 * 6 + 1) # up to 7 oscillators
detune = (knobB.value/65535) * 0.01 # up to 10% detune
print("num_oscs: %d detune: %.4f" % (num_oscs,detune))
notes = [] # holds note objs being pressed
# simple detune, always detunes up
for i in range(num_oscs):
f = synthio.midi_to_hz(midi_note) * (1 + i*detune) # detune!
notes.append( synthio.Note(f, waveform=wave_saw) )
synth.press(notes)
time.sleep(0.5)
synth.release(notes)
time.sleep(0.1)
Fun with wavetables: wavetabledrone
Now we have some ability to load and play wavetables, let’s use them to make
a slowly evolving dronesynth with some oscillators scanning at different rates
through a harmonically-rich wavetable.
There are three notes, each playing from the same wavetable, but using
different LFOs to move through the wavetables at different rates. One Note
also has a slow LFO on its .bend
property to add some pitch variation.
The knobA lets you control the wavetable position of one of the notes.
This ominous sonic landscape seems to go on forever.
# 4_oscillators_wavetables/code_wavetable_drone.py
# NOTE: on RP2040, this needs SAMPLE_RATE=22050 in synth_setup.py
import time
import ulab.numpy as np
import synthio
from synth_setup import synth, knobA, knobB
from wavetable import Wavetable
wavetable_fname = "wavetables/PLAITS02.WAV" # from http://waveeditonline.com/
wavetable1 = Wavetable(wavetable_fname)
wavetable2 = Wavetable(wavetable_fname)
wavetable3 = Wavetable(wavetable_fname)
midi_note = 48
note = synthio.Note(synthio.midi_to_hz(midi_note-12),
waveform=wavetable1.waveform)
note2 = synthio.Note(synthio.midi_to_hz(midi_note-7),
waveform=wavetable2.waveform)
note3 = synthio.Note(synthio.midi_to_hz(midi_note),
waveform=wavetable3.waveform)
note3.bend = synthio.LFO(rate=0.005, scale=0.25, phase_offset=0.5)
wave_lfo = synthio.LFO(rate=0.005, waveform=np.array((0,32767), dtype=np.int16) )
wave_lfo.scale = wavetable1.num_waves
wave_lfo2 = synthio.LFO(rate=0.01, waveform=np.array((32767,0), dtype=np.int16) )
wave_lfo2.scale = wavetable1.num_waves
wave_lfo2.phase_offset = 0.25
synth.blocks.append(wave_lfo) # add LFOs to blocks so they get run
synth.blocks.append(wave_lfo2)
synth.press( (note,note2,note3) )
while True:
wavetable1.wave_pos = wave_lfo.value # copy LFO pos to wave pos
wavetable2.wave_pos = wave_lfo2.value
wavetable3.wave_pos = 24 + 16.0 * (knobA.value/65535)
print("%.2f %.2f %.2f" % (wave_lfo.value, wave_lfo2.value, wavetable3.wave_pos))
time.sleep(0.05)
Research links
Next Steps
We now have enough control over synthio
to make just about any sound.
So let’s figure out how to control the sounds with MIDI.