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:
- .setDroneOn() and .setDroneOff(): mOperator can be placed into a drone mode in which the oscillator will bypass the envelope. Off by default.
- .setLoopingOn() and .setLoopingOff(): mOperator's envelope can be placed into a mode where it will be repeatedly loop, turning it into somewhat of an LFO. When this mode is enabled, the sustain step of the ADSR envelope will be skipped and when the end of the release phase is reached, a new attack phase will automatically be triggered. Off by default.
- setPhaseSyncOn() and setPhaseSyncOff(): mOperator can be instructed to set its oscillator's phase to zero every time a noteOn is received. This will ensure that all operators within a voice will have synchronized phase. On by default.
- setFreq(float f_): can be used to set the frequency of your mOperator to any value not using MIDI note numbers. This is still affected by mOperator's frequency ratio, so if you set the frequency ratio to 3 and call setFreq(100), it will actually play 300Hz.
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;
}
}