Tutorial 16. Metronomes
In which we discuss how to organize musical events in time. |
In most musical systems/compositions, there is a desire for certain events to happen at specific times. Maybe we want a constant tempo pulse underlying a song, maybe we want the notes of a melody to happen in a rhythm, maybe we want to move to another section of the piece every two minutes.
In all of these cases, there are events that we want to happen at some point in the future (the next tempo pulse, the beginning of the next note, movement into the next section of your piece, etc.) and there is a specific amount of time we want to wait before that event happens.
The primary way in which we will be timing events in MEAP for now is through the Mozzi EventDelay object. I like to conceptualize this object as a kitchen timer. We tell it an amount of time we want to wait before doing something, press start, and it will tell us once that time has passed. It doesn't take our pie out of the oven, but it does tell us to do so.
There are really just two EventDelay functions that we care about:
- start(unsigned int delay_milliseconds): this
sets the length of the timer in milliseconds and starts
it.
- ready(): This asks the timer if the time is up or
if we should keep waiting. It returns true if the
number of milliseconds specified in start() has
passed since the timer was started. We typically want to
continuously check this in updateControl().
In this tutorial, we will be setting up two timers, which we will be referring to as metronomes. Each metronome will trigger a note to play when it ticks. The first metronome will play a G with a constant period of 250 milliseconds and the second metronome will play a D with a period determined by pot #1.
Setting up our voices (globally):
First let's set up two basic voices. Each will consist of a triangle oscillator whose volume will be controlled by an ADSR envelope. We'll also create an EventDelay object for each voice to act as our metronomes, and create a variable to hold the period of each metronome.
EventDelay metro1;
int metro1_period = 250; // constant period of 250 ms
mOscil<tri8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> oscil1(tri8192_int16_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env1;
EventDelay metro2;
int metro2_period; // will be set by pot 0 in updateControl
mOscil<tri8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> oscil2(tri8192_int16_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env2;
Note that our ADSR envelopes are set to both update and interpolate at AUDIO_RATE. Since these envelopes can get moving pretty quickly in this program as we turn up our tempo, that will keep them smooth.
Initializing everything in setup():
oscil1.setFreq(mtof(55)); // G
oscil2.setFreq(mtof(62)); // D
env1.setADLevels(255, 0); // set envelope sustain level to 0, effectively skipping sustain and release phases
env2.setADLevels(255, 0);
env1.setTimes(1, 75, 1, 1);
env2.setTimes(1, 75, 1, 1);
In this case we want all of the envelopes to be the same length and we don't want to worry about turning them off with noteOff()s, so we're going to use a little trick to turn the ADSR envelopes into AD envelopes, basically skipping the sustain and release phases. The setADLevels() function takes two inputs that set the level (between 0 and 255) that the envelope will land at after the attack and decay phase respectively end. If we set the second argument to 0, the envelope will rise for the attack phase and then decay to zero, staying there for both sustain and release phases. Using setTimes(), we set the decay time to 75ms and the rest of the envelope times to 1ms.
Setting up our signal-flow in updateAudio():
env1.update();
env2.update();
int64_t out_sample = oscil1.next() * env1.next() + oscil2.next() * env2.next(); // 16bit osc * 8 bit env + 16bit osc * 8 bit env = 25bits
return StereoOutput::fromNBit(25, (out_sample * meap.volume_val) >> 12, (out_sample * meap.volume_val) >> 12);
Since we set our envelopes to both update and interpolate at AUDIO_RATE, we need to call both env1.update() and env1.next() in updateAudio(). Other than that, everything else looks normal. We grab samples from the oscillators, multiply by the envelopes and add the results together. The triangle wavetable we used above is 16-bit, multiplied by an 8-bit envelope, brings us to 24 bits, and adding those together brings the total output to 25 bits.
Finally we'll use our metronomes in updateControl():
First let's set up our constant metronome:
if(metro1.ready()) {
metro1.start(metro1_period);
env1.noteOn();
}
We check if the timer is done using metro1.ready(). If it is, the if statement will evaluate as true and the inner code will run. First we just restart the timer with the same period. And finally, we trigger the envelope to begin.
The second metronome is almost the same:
if (metro2.ready()) {
metro2_period = map(meap.pot_vals[0], 0, 4095, 10, 500);
metro2.start(metro2_period);
env2.noteOn();
}
The only thing we're changing here is the period of the metronome. Instead of ticking at a constant rate, it will depend on the position of pot #0: 10ms when counterclockwise and 500ms when clockwise.
Upload the code. Pot #0 will control the frequency at which the higher of the two notes is triggered. Note that it won't have an effect until the start of the next note. If you start a timer cycle with a 500ms period and then turn pot #0 down, you'll have to wait until the 500ms are up before it starts playing fast notes.
FULL CODE BELOW
/*
Two separate metronomes triggering two notes
The first consistently ticks with a period of 250 ms
The second has a period that changes with pot 0
*/
#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/tri8192_int16.h>
EventDelay metro1;
int metro1_period = 250; // constant period of 250 ms
mOscil<tri8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> oscil1(tri8192_int16_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env1;
EventDelay metro2;
int metro2_period; // will be set by pot 0 in setup
mOscil<tri8192_int16_NUM_CELLS, AUDIO_RATE, int16_t> oscil2(tri8192_int16_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env2;
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 ----------
oscil1.setFreq(mtof(55)); // G
oscil2.setFreq(mtof(62)); // D
env1.setADLevels(255, 0); // set envelope sustain level to 0, effectively skipping sustain and release phases
env2.setADLevels(255, 0);
env1.setTimes(1, 75, 1, 1);
env2.setTimes(1, 75, 1, 1);
}
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 ----------
if (metro1.ready()) {
metro1.start(metro1_period);
env1.noteOn();
}
if (metro2.ready()) {
metro2_period = map(meap.pot_vals[0], 0, 4095, 10, 500);
metro2.start(metro2_period);
env2.noteOn();
}
}
/** 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() {
env1.update();
env2.update();
int64_t out_sample = oscil1.next() * env1.next() + oscil2.next() * env2.next(); // 16bit osc * 8 bit env + 16bit osc * 8 bit env = 25bits
return StereoOutput::fromNBit(25, (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
} else { // Any pad released
}
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;
}
}