Tutorial 08. A subtractive synthesis patch

In which we dip our toes into a classic synthesis method.

Most synthesizers before the 1980s used a fairly similar approach to creating sounds.

  1. Start with a raw, harmonically complex oscillator (often square, saw or triangle waves)
  2. Shape the tone of that wave with a filter (usually a lowpass filter)
  3. Control the volume of wave with an envelope

fig 1. typical signal path of an analog subtractive synthesizer. vco stands for voltage controlled oscillator, vcf for voltage controlled filter and vca for voltage controlled amplifier, which would all be separate circuits

Typically, the pitch of the oscillator may be controlled by a keyboard, an envelope (ADSR typically) would give a shape to the volume of the note, and another envelope would change the cutoff of the lowpass filter over the duration of the note, giving a motion to its timbre.

This approach to synthesis is known as subtractive synthesis, because we are primarily shaping the sound by removing harmonics using the filter. It's also a popular approach for a reason; it works really well for creating useful and dynamic sounds. Of course it has its limits, but it's a great place to start with synthesis.

Our goal is to recreate the above signal path so we will need the following objects:

Luckily, in the world of digital synthesis, we don't need an entire module to control volume like the VCA used in analog synths.


global variables:

First let's start with MEAP_BASIC_TEMPLATE and create all the objects we need:

#include <tables/sq8192_int16.h>   // loads square wave
#include <tables/saw8192_int16.h>  // loads square wave
mOscil<saw8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> osc(saw8192_int16_DATA);

MultiResonantFilter filter;

ADSR<CONTROL_RATE, AUDIO_RATE> amplitude_envelope;
ADSR<CONTROL_RATE, CONTROL_RATE> filter_envelope;

There are a few things to note here:

  1. We included two tables for our oscillator. Though we initialize the oscillator with the saw wave, we'll add an option later on to switch it to a square wave. These two waves will give you different timbres; sawtooth waves have dense harmonic content which can result in string-like tones, while square waves have more sparse harmonic content resulting in a slightly more hollow sound (and when unfiltered they will sound like a classic 8-bit video game console, where square waves were extensively used.) If you want to explore other wavetables, both the Mozzi and Meap libraries contain some which can be found within the tables subdirectory of their respective folders in the Arduino libraries folder. Also note that this is a 16-bit wavetable! When we create our oscillator, we need to tell it to expect a 16-bit value so we give it the optional third argument of int16_t.
  2. The filter envelope's update and interpolation rates are both set to CONTROL_RATE. This is because we are using it as a control rate signal rather than an audio rate signal so we can just leave all of its functionality in the updateControl() function.

Now let's create a few more variables for later on before moving into setup():

int notes[8] = { 48, 50, 52, 53, 55, 57, 59, 60 }; // choose MIDI pitches of keyboard notes
int transpose = 0; // num of semitones to transpose, used for octave switching

Rather than calculating the pitch of each note manually, we'll use MIDI note names to simplify this process. The notes array contains the MIDI notes numbers of a C major scale starting on a C2. A list of what pitches MIDI notes represent can be found here. We'll be using the transpose variable as a way of easily switching between octaves later on.


setup():

Now let's set up our envelopes in setup():

amplitude_envelope.setADLevels(255, 200); // set attack level to maximum, and sustain level slightly lower
amplitude_envelope.setTimes(1, 100, 1000000, 1000);
filter_envelope.setADLevels(255, 127); // set attack level to maximum, and sustain level to half

We're setting our amplitude envelope to quickly attack, decay down to a slightly lower value after 100ms and then fade out over one second when released. As usual, the sustain time is set to an arbitrarily large number. We'll be using one of the pots to control the filter envelope so we don't need to set its times just yet.


updateControl():

In updateControl() we'll need to update our envelopes and use the pots to control a few parameters.

amplitude_envelope.update(); // update filter envelope
filter_envelope.update(); // update filter envelope
filter_envelope.setTimes(map(meap.pot_vals[0], 0, 4095, 1, 2000), 400, 1000000, 500); // use pot 0 to control filter envelope attack time
filter.setCutoffFreqAndResonance(filter_envelope.next(), map(meap.pot_vals[1], 0, 4095, 0, 255)); // use filter envelope to control cutoff, and pot 1 to control resonance

First we're updating both envelopes. The we use pot 1 to control the attack time of the filter envelope, mapping it to a range of 1ms when fully counterclockwise to 2000 milliseconds when fully clockwise. Next we use the output of the filter envelope to control the cutoff frequency of the filter. In setup, we set the envelope to sweep up to 255 so this will sweep the filter over its entire range from 0Hz up to 16kHz. Additionally, we are using pot 1 to control the resonance of the filter.


updateAudio():

In updateAudio() we just need to finish connecting these objects together.

int64_t out_sample = osc.next();                             // grab sample from oscillator
filter.next(out_sample);                                     // send oscillator
out_sample = filter.low();                                   // grab lowpass output from filter
out_sample = (out_sample * amplitude_envelope.next()) >> 8;  // apply amplitude envelope
return StereoOutput::fromNBit(11, (out_sample * meap.volume_val) >> 12, (out_sample * meap.volume_val) >> 12);

We grab a sample from our oscillator, put it into the filter and then grab a sample from its lowpass output. Next we apply our amplitude envelope to the signal by multiplying by the envelope and then dividing by 256 (remember that >> 8 is the same as dividing by 256). And finally we send the sample to the audio output as an 19-bit signal. Our oscillator is 16-bits so we'll leave a generous 3 bits (18dB) of headroom to avoid clipping the output at high resonance.


updateDip():

Now, lets use a couple dip switches to do the following:

Modify the case 0 and case 1 statements as follows:

case 0:
    if (up) {  // DIP 0 up
        transpose = 12;
      } else {  // DIP 0 down
        transpose = 0;
      }
      break;
case 1:
      if (up) {                               // DIP 1 up
        osc.setTable(sq8192_int16_DATA); // set oscillator's table to a square wave
      } else {                                // DIP 1 down
        osc.setTable(saw8192_int16_DATA); // set oscillator's table to a saw wave
      }
      break;

updateTouch():

Finally, let's use the touch pads to trigger some notes! We'll need to tell the oscillator what pitch to play and start/stop both of our envelopes. Modify the "Any pad pressed" if statement at the top of the function as follows:

if (pressed) {  // Any pad pressed
    amplitude_envelope.noteOn();
    filter_envelope.noteOn();
    osc.setFreq(mtof(notes[number] + transpose));
} else {  // Any pad released
    amplitude_envelope.noteOff();
    filter_envelope.noteOff();
}

The only tricky thing here is that osc.setFreq() line. We are using the Mozzi mtof() function which converts the MIDI note number you give to it into a frequency. We need to do this because, the mOscil object only understands frequencies in Hz, not MIDI note numbers. So first we are grabbing a the note we want from the notes[] array we defined up top. By indexing it like this: notes[number] we are grabbing the note in the array that corresponds to the pad we pressed. For example if we press pad 0, it will grab the 0th member of the notes array which is 48 (C2), if we press pad 3 it will grab the 3th member of the notes array which is 53 (F2) etc. Before sending that number to mtof() to be converted into a frequency we add transpose on to it, which will be 12 if dip 0 is up (increasing our octave by 1) or 0 if dip 2 is down (keeping our octave at C2)


We're all done now! Upload your code and press some keys. If you turn up pot 0, you should hear the filter slowly open, brightening your note over time. If you turn up pot 1, you will hear a resonant sweep as the filter opens.


FULL CODE BELOW


/*
 Demonstrates basic subtractive synthesis patch

 Pot 0 controls filter attack time
 Pot 1 controls resonance

 DIP 0 toggles octave
 DIP 1 toggles waveform: down = saw, up = square
 */

#define CONTROL_RATE 128  // Hz, powers of 2 are most reliable
#include <Meap.h>  // MEAP library, includes all dependent libraries, including all Mozzi modules

Meap meap;                                            // creates MEAP object to handle inputs and other MEAP library functions
MIDI_CREATE_INSTANCE(HardwareSerial, Serial1, MIDI);  // defines MIDI in/out ports

// ---------- YOUR GLOBAL VARIABLES BELOW ----------
#include <tables/sq8192_int16.h>  // loads square wave
#include <tables/saw8192_int16.h>           // loads square wave

mOscil<saw8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> osc(saw8192_int16_DATA);

MultiResonantFilter filter;

ADSR<CONTROL_RATE, AUDIO_RATE> amplitude_envelope;
ADSR<CONTROL_RATE, CONTROL_RATE> filter_envelope;

int notes[8] = { 48, 50, 52, 53, 55, 57, 59, 60 }; // choose MIDI pitches of keyboard notes
int transpose = 0; // num of semitones to transpose, used for octave switching

void setup() {
  Serial.begin(115200);                      // begins Serial communication with computer
  Serial1.begin(31250, SERIAL_8N1, 43, 44);  // sets up MIDI: baud rate, serial mode, rx pin, tx pin
  startMozzi(CONTROL_RATE);                  // starts Mozzi engine with control rate defined above
  meap.begin();

  osc.setFreq(220);

  amplitude_envelope.setADLevels(255, 200); // set attack level to maximum, and sustain level slightly lower
  amplitude_envelope.setTimes(1, 100, 1000000, 1000);

  filter_envelope.setADLevels(255, 127); // set attack level to maximum, and sustain level to half
  filter_envelope.setTimes(1, 400, 1000000, 500);
}


void loop() {
  audioHook();
}


void updateControl() {
    meap.readInputs();
    // ---------- YOUR updateControl CODE BELOW ----------
  
    amplitude_envelope.update();                                                                       // update filter envelope
    filter_envelope.update();                                                                          // update filter envelope
    filter_envelope.setTimes(map(meap.pot_vals[0], 0, 4095, 1, 2000), 400, 1000000, 500);              // use pot 0 to control filter envelope attack time
    filter.setCutoffFreqAndResonance(filter_envelope.next(), map(meap.pot_vals[1], 0, 4095, 0, 255));  // use filter envelope to control cutoff, and pot 1 to control resonance
  }


AudioOutput_t updateAudio() {
  int64_t out_sample = osc.next();                             // grab sample from oscillator
  filter.next(out_sample);                                     // send oscillator
  out_sample = filter.low();                                   // grab lowpass output from filter
  out_sample = (out_sample * amplitude_envelope.next()) >> 8;  // apply amplitude envelope
  return StereoOutput::fromNBit(19, (out_sample * meap.volume_val) >> 12, (out_sample * meap.volume_val) >> 12);
}


/**
   * Runs whenever a DIP switch is toggled
   *
   * int number: the number (0-7) of the switch that was toggled
   * bool up: true indicated switch was toggled up, false indicates switch was toggled
   */
void updateDip(int number, bool up) {
  if (up) {  // Any DIP toggled up

  } else {  //Any DIP toggled down
  }
  switch (number) {
    case 0:
      if (up) {  // DIP 0 up
        transpose = 12;
      } else {  // DIP 0 down
        transpose = 0;
      }
      break;
    case 1:
      if (up) {  // DIP 1 up
        osc.setTable(sq8192_int16_DATA); // set oscillator's table to a square wave
      } else {  // DIP 1 down
        osc.setTable(saw8192_int16_DATA); // set oscillator's table to a saw wave
      }
      break;
    case 2:
      if (up) {  // DIP 2 up
      } else {   // DIP 2 down
      }
      break;
    case 3:
      if (up) {  // DIP 3 up
      } else {   // DIP 3 down
      }
      break;
    case 4:
      if (up) {  // DIP 4 up
      } else {   // DIP 4 down
      }
      break;
    case 5:
      if (up) {  // DIP 5 up
      } else {   // DIP 5 down
      }
      break;
    case 6:
      if (up) {  // DIP 6 up
      } else {   // DIP 6 down
      }
      break;
    case 7:
      if (up) {  // DIP 7 up
      } else {   // DIP 7 down
      }
      break;
  }
}

/**
   * Runs whenever a touch pad is pressed or released
   *
   * int number: the number (0-7) of the pad that was pressed
   * bool pressed: true indicated pad was pressed, false indicates it was released
   */
void updateTouch(int number, bool pressed) {
  if (pressed) {  // Any pad pressed
    amplitude_envelope.noteOn();
    filter_envelope.noteOn();
    osc.setFreq(mtof(notes[number] + transpose));
  } else {  // Any pad released
    amplitude_envelope.noteOff();
    filter_envelope.noteOff();
  }
  switch (number) {
    case 0:
      if (pressed) {  // Pad 0 pressed
      } else {        // Pad 0 released
      }
      break;
    case 1:
      if (pressed) {  // Pad 1 pressed
      } else {        // Pad 1 released
      }
      break;
    case 2:
      if (pressed) {  // Pad 2 pressed
      } else {        // Pad 2 released
      }
      break;
    case 3:
      if (pressed) {  // Pad 3 pressed
      } else {        // Pad 3 released
      }
      break;
    case 4:
      if (pressed) {  // Pad 4 pressed
      } else {        // Pad 4 released
      }
      break;
    case 5:
      if (pressed) {  // Pad 5 pressed
      } else {        // Pad 5 released
      }
      break;
    case 6:
      if (pressed) {  // Pad 6 pressed
      } else {        // Pad 6 released
      }
      break;
    case 7:
      if (pressed) {  // Pad 7 pressed
      } else {        // Pad 7 released
      }
      break;
  }
}