Tutorial 10. Operator FM synthesis

A convenient way of dealing with frequency modulation.

Many commercial FM synthesizers find ways of simplifying and limiting the broad concept of FM synthesis into convenient packages for users to program. A very common abstraction for FM synthesis is the operator. An operator is a module containing a volume-enveloped oscillator (typically a sine wave) that is used as the building block of FM patches. When you connect these building blocks together in different patterns, called algorithms you can build complex sounds out of modulation paths in a relatively intuitive way.

fig 1. an operator

fig 2. an algorithm (#1 on the Yamaha DX7)


MEAP contains an operator class called mOperator, which contains an mOscil, an ADSR and code for connecting these internally and conveniently connecting mOperators to each other.

The program below will implement a basic FM keyboard patch while exploring some of mOperator's features.


mOperators are declared similarly to mOscils. Load a wavetable, usually a sine wave, tell the mOperator how large the wavetable is, and point it to the table. We don't need to tell mOperator that it will run at AUDIO_RATE, this is assumed.

We also create a template of the semitones of a major scale so we can play a scale with the touch pads.

#include <tables/sin8192_int8.h>

// (C + B) -> A -> output
mOperator opA(SIN8192_DATA);
mOperator opB(SIN8192_DATA);
mOperator opC(SIN8192_DATA);

int maj_scale[8] = {0, 2, 4, 5, 7, 9, 11, 12};

In this case, we are creating three operators. Operator A will be out carrier (the operator that gets sent to the audio output) and the other two will both modulate it.

In setup(), we set up the operators' envelopes just like any ADSR, and set a frequency ratio for each operator. Whenever we tell the operator to play a certain note, it will multiply the frequency ratio of that note and play the resulting frequency instead. For example, if we set an operator's frequency ratio to 2 and tell it to play an A3, it will instead play an A4 (an octave up). This is useful because we often want to place operators at different harmonics of the fundamental that the entire FM instrument is playing.

In the sound we are building, operator A will be the body of the sound, playing the fundamental. Operator B has a slow attack and plays an octave and a 5th up. It will gradually bring in some mid-high harmonics as it fades in. Operator C will be in charge of creating an attack transient. It has a very high frequency ratio so it will generate high relatively and somewhat non-tonal frequencies, and it has a short decay and low sustain value so it will get out of the way quickly.

  opA.setFreqRatio(1);
  opA.setTimes(1, 300, 99999999, 1000);
  opA.setADLevels(255, 200);
  opA.setGain(255);

  opB.setFreqRatio(3);
  opB.setTimes(1000, 300, 99999999, 1000);
  opB.setADLevels(255, 255);

  opC.setFreqRatio(11);
  opC.setTimes(1, 100, 1, 500);
  opC.setADLevels(255, 50);

In updateControl we do two things. First, we want to use MEAP's pots to control the index of modulation for operators B and C, which is how much each of the operators will modulate operator A. This is done using the setGain function, which takes an input between 0 and 255. If an operator has a high gain, it will greatly modulate whatever it is connected to. Increasing opB's gain will create more of a bright growth sound, and increasing opC's gain will increase the volume of the note's attack transient.

Next, we need to run the update function for each operator, which will calculate its envelope behind the scenes.

  opB.setGain(map(meap.pot_vals[0], 0, 4095, 0, 255));
  opC.setGain(map(meap.pot_vals[1], 0, 4095, 0, 255));

  opA.update();
  opB.update();
  opC.update();

In updateTouch, we tell the touch pads to trigger notes. The noteOn function is a MIDI compatible noteOn, with the first argument being the MIDI note number you want to play and the second being the note's velocity (or volume) from 0-127. Notice that we send the same note to all operators.

  if (pressed) {  // Any pad pressed
    opA.noteOn(52+maj_scale[number], 127);
    opB.noteOn(52+maj_scale[number], 127);
    opC.noteOn(52+maj_scale[number], 127);
  } else {  // Any pad released
    opA.noteOff();
    opB.noteOff();
    opC.noteOff();
  }

Finally, in updateAudio, we need to send our signal to the output. This is also where we implement our FM algorithm by connecting our operators to each other. Notice that we are using two different versions of the .next() function. opB and opC call it without an argument. This indicates that the operator will not be modulated by anything. opA on the other hand uses mod_val (which contains the combined output of opB and opC) as the input to the .next() function, which indicates that it will be frequency modulated by that signal. mOperators always output 16-bit signals so be sure to set your output bitrate to 16 bits.

    int64_t mod_val = opB.next() + opC.next();
    int64_t out_sample = opA.next(mod_val);

Extra mOperator features


There are a few features of mOperator that are not covered in this example:


FULL CODE BELOW


/*
  Operator FM. 

  Touch pads play notes
  pot 0 controls oscB modulation amount: high frequency growth
  pot 1 controls oscC modulation amount: attack transient
 */

#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/sin8192_int8.h>

// (C + B) -> A -> output
mOperator<SIN8192_NUM_CELLS> opA(SIN8192_DATA);
mOperator<SIN8192_NUM_CELLS> opB(SIN8192_DATA);
mOperator<SIN8192_NUM_CELLS> opC(SIN8192_DATA);

int maj_scale[8] = {0, 2, 4, 5, 7, 9, 11, 12};

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();                              // sets up MEAP object

  // ---------- YOUR SETUP CODE BELOW ----------
  opA.setFreqRatio(1);
  opA.setTimes(1, 300, 99999999, 1000);
  opA.setADLevels(255, 200);
  opA.setGain(255);

  opB.setFreqRatio(3);
  opB.setTimes(1000, 300, 99999999, 1000);
  opB.setADLevels(255, 255);

  opC.setFreqRatio(11);
  opC.setTimes(1, 100, 1, 500);
  opC.setADLevels(255, 50);
}


void loop() {
  audioHook();  // handles Mozzi audio generation behind the scenes
}


/** Called automatically at rate specified by CONTROL_RATE macro, most of your mode should live in here
	*/
void updateControl() {
  meap.readInputs();
  // ---------- YOUR updateControl CODE BELOW ----------
  opB.setGain(map(meap.pot_vals[0], 0, 4095, 0, 255));
  opC.setGain(map(meap.pot_vals[1], 0, 4095, 0, 255));

  opA.update();
  opB.update();
  opC.update();

  // Serial.println(opA.mod_shift_val_);
}

/** Called automatically at rate specified by AUDIO_RATE macro, for calculating samples sent to DAC, too much code in here can disrupt your output
	*/
AudioOutput_t updateAudio() {
  int64_t mod_val = opB.next() + opC.next();
  int64_t out_sample = opA.next(mod_val);

  return StereoOutput::fromNBit(16, (out_sample * meap.volume_val)>>12, (out_sample * meap.volume_val)>>12);
}

/**
   * Runs whenever a touch pad is pressed or released
   *
   * int number: the number (0-7) of the pad that was pressed
   * bool pressed: true indicates pad was pressed, false indicates it was released
   */
void updateTouch(int number, bool pressed) {
  if (pressed) {  // Any pad pressed
    opA.noteOn(52+maj_scale[number], 127);
    opB.noteOn(52+maj_scale[number], 127);
    opC.noteOn(52+maj_scale[number], 127);
  } else {  // Any pad released
    opA.noteOff();
    opB.noteOff();
    opC.noteOff();
  }
  switch (number) {
    case 0:
      if (pressed) {  // Pad 0 pressed
        Serial.println("t0 pressed ");
      } else {  // Pad 0 released
        Serial.println("t0 released");
      }
      break;
    case 1:
      if (pressed) {  // Pad 1 pressed
        Serial.println("t1 pressed");
      } else {  // Pad 1 released
        Serial.println("t1 released");
      }
      break;
    case 2:
      if (pressed) {  // Pad 2 pressed
        Serial.println("t2 pressed");
      } else {  // Pad 2 released
        Serial.println("t2 released");
      }
      break;
    case 3:
      if (pressed) {  // Pad 3 pressed
        Serial.println("t3 pressed");
      } else {  // Pad 3 released
        Serial.println("t3 released");
      }
      break;
    case 4:
      if (pressed) {  // Pad 4 pressed
        Serial.println("t4 pressed");
      } else {  // Pad 4 released
        Serial.println("t4 released");
      }
      break;
    case 5:
      if (pressed) {  // Pad 5 pressed
        Serial.println("t5 pressed");
      } else {  // Pad 5 released
        Serial.println("t5 released");
      }
      break;
    case 6:
      if (pressed) {  // Pad 6 pressed
        Serial.println("t6 pressed");
      } else {  // Pad 6 released
        Serial.println("t6 released");
      }
      break;
    case 7:
      if (pressed) {  // Pad 7 pressed
        Serial.println("t7 pressed");
      } else {  // Pad 7 released
        Serial.println("t7 released");
      }
      break;
  }
}

/**
   * 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
        Serial.println("d0 up");
      } else {  // DIP 0 down
        Serial.println("d0 down");
      }
      break;
    case 1:
      if (up) {  // DIP 1 up
        Serial.println("d1 up");
      } else {  // DIP 1 down
        Serial.println("d1 down");
      }
      break;
    case 2:
      if (up) {  // DIP 2 up
        Serial.println("d2 up");
      } else {  // DIP 2 down
        Serial.println("d2 down");
      }
      break;
    case 3:
      if (up) {  // DIP 3 up
        Serial.println("d3 up");
      } else {  // DIP 3 down
        Serial.println("d3 down");
      }
      break;
    case 4:
      if (up) {  // DIP 4 up
        Serial.println("d4 up");
      } else {  // DIP 4 down
        Serial.println("d4 down");
      }
      break;
    case 5:
      if (up) {  // DIP 5 up
        Serial.println("d5 up");
      } else {  // DIP 5 down
        Serial.println("d5 down");
      }
      break;
    case 6:
      if (up) {  // DIP 6 up
        Serial.println("d6 up");
      } else {  // DIP 6 down
        Serial.println("d6 down");
      }
      break;
    case 7:
      if (up) {  // DIP 7 up
        Serial.println("d7 up");
      } else {  // DIP 7 down
        Serial.println("d7 down");
      }
      break;
  }
}