Tutorial 17. A melody sequencer
In which we play a melody. |
It's a common misconception that computer music is all about writing algorithms to generate music from scratch. There's no reason we can't just use a computer to play pre-programmed melodies! In this tutorial we'll look at an approach to playing a melody using EventDelay.
A melody can be conceptualized as a sequence of events; at certain points in time, a pitch will be chosen, a note will start, and a note will end. We wait an amount of time between these events that depends on the rhythm of our notes. EventDelay is a perfect tool for implementing a melody.
To implement a basic melody, we'll need two main things: an array of the sequence of notes in our melody, and an array of the time between note onsets. For simplicity's sake we'll be using MIDI note numbers to choose pitches, and the length of each note in sixteenth notes to handle rhythm.
For the purposes of this tutorial, we'll be recreating the intro melody from Untrust Us by Crystal Castles. The first step is to transcribe the melody which I have done in Ableton's piano roll below.
Once we know what notes we have, we want to write them in a way our MEAP can understand. We have 20 notes in this melody so we want to make an array containing the pitches of these 20 notes and an array containing how many sixteenth notes there are between note onsets. To make MIDI note entry easier, we can use MEAP's MIDI note definitions header file. This will allow us to write notes in terms of their musical pitch value rather than looking them up in a MIDI note table every time.
Writing our rhythms in terms of sixteenth notes similarly allows us to simplify note input. We can just calculate the length of a sixteenth note at our desired tempo once, and then convert all of our note lengths to milliseconds by multiplying by that number. Untrust Us is at 126 BPM so we can find the length of a sixteenth note as follows:
- Divide 60 by your BPM. This will get you the number of seconds per beat (aka quarter note)
- Multiply by 1000 to get the number of milliseconds per quarter note
- Divide by 4 to get the number of milliseconds per sixteenth note (there are 4 sixteenth notes in a quarter note)
If we calculate that out, we'll find that there are 119 milliseconds per sixteenth note at 126 BPM. This approach simplifies note entry, but it also lets us change tempo easily. All we need to do is calculate a new sixteenth length and all the rhythms will follow our new tempo.
The final variable we want to create is an indexing
variable which we will call
sequence_index
. We will use this variable to
keep track of which note from the melody we need to play
next. We'll initialize it to 0 to start at the beginning of
our sequence.
int sequence_length = 20;
int sequence_pitches[20] = {mCs3, mFs3, mCs3, mA2, mCs3, mE3, mD3, mCs3, mB2, mA2, mCs3, mE3, mCs3, mA2, mCs3, mE3, mD3, mCs3, mB2, mA2};
int sequence_rhythms[20] = {2, 2, 2, 2, 2, 1, 1, 2, 1, 1, 2, 2, 2, 2, 2, 1, 1, 2, 1, 1};
int sequence_index = 0;
int sixteenth_length = 119.
Beyond that, we'll need to create our EventDelay object and a synth voice. We aren't going to go too deep into sound design in this tutorial but the synth in the song sounds like a filtered square wave so we'll just use a bandlimited square wave table for our oscillator.
#include <tables/sq8192_4harm_int8.h>
EventDelay metro1;
int metro1_period; // period will be set by sequence below
mOscil<sq8192_4harm_int8_NUM_CELLS, AUDIO_RATE> oscil1(sq8192_4harm_int8_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env1;
In setup() we'll set up our envelope:
env1.setADLevels(255, 0); // set envelope sustain level to 0, effectively skipping sustain and release phases
env1.setTimes(1, 75, 1, 1);
And in updateAudio() we'll connect our synth objects together:
env1.update();
int64_t out_sample = oscil1.next() * env1.next(); // 16bit osc * 8 bit env = 24bits
return StereoOutput::fromNBit(16, (out_sample * meap.volume_val) >> 12, (out_sample * meap.volume_val) >> 12);
Logic in updateControl()
As is often the case, the interesting part of the code comes in updateControl().
if (metro1.ready()) {
metro1.start(sequence_rhythms[sequence_index] * sixteenth_length);
oscil1.setFreq(mtof(sequence_pitches[sequence_index]));
env1.noteOn();
sequence_index = (sequence_index + 1) % sequence_length;
}
Inside of our metronome if statement:
- The first thing we need to do is to figure out how long to wait until the next note starts. We can do this by multiplying the length (in sixteenths) of the note we're starting by the number of milliseconds in a sixteenth note.
- Next, we set the pitch of
our note in a similar way: by grabbing that MIDI
note from
the
sequence_pitches
array and converting it to a frequency withmtof()
. - We then start the note's envelope.
- Finally, we need to tell the sequence_index variable to move on to the next note of the sequence. We can do this simply by just adding one to its value, but what happens when we get to the end of the sequence and try to play the 21st note? Of course this note doesn't exist so we need to decide what to do. In this case, we'll just jump back to the beginning of the melody and play it on loop. The easiest way to do this is using the modulo operator which is represented by the % symbol. This is a mathematical operator (similar to addition or subtraction) that essentially return the remainder of a division of the number on the left of the % by the number on the right of the %. Since our argument on the right is 20, the result will alway be a number less than 20. If you divide 20 by 20, the remainder is 0, so you will jump back to the start of the sequence.
Go ahead and upload the code. It should play our melody over and over again, unchanging forever.
Defining our melody as a sequence of numbers with an indexing variable allows a lot of flexibility in how the melody is played back (it also seems suspiciously similar to how samples and wavetables are stored and used). As an exercise, try playing the melody backwards, or skipping every other note and see how differnt possibilities can emerge from a single melody.
FULL CODE BELOW
/*
Plays through the intro melody to Untrust Us by Crystal Castles
*/
#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_4harm_int8.h>
EventDelay metro1;
int metro1_period; // period will be set by sequence below
mOscil<sq8192_4harm_int8_NUM_CELLS, AUDIO_RATE> oscil1(sq8192_4harm_int8_DATA);
ADSR<AUDIO_RATE, AUDIO_RATE> env1;
int sequence_length = 20;
int sequence_pitches[20] = {mCs3, mFs3, mCs3, mA2, mCs3, mE3, mD3, mCs3, mB2, mA2, mCs3, mE3, mCs3, mA2, mCs3, mE3, mD3, mCs3, mB2, mA2};
int sequence_rhythms[20] = {2, 2, 2, 2, 2, 1, 1, 2, 1, 1, 2, 2, 2, 2, 2, 1, 1, 2, 1, 1};
int sequence_index = 0;
int sixteenth_length = 125;
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 ----------
env1.setADLevels(255, 0); // set envelope sustain level to 0, effectively skipping sustain and release phases
env1.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(sequence_rhythms[sequence_index] * sixteenth_length);
oscil1.setFreq(mtof(sequence_pitches[sequence_index]));
env1.noteOn();
sequence_index = (sequence_index + 1) % sequence_length;
}
}
/** 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();
int64_t out_sample = oscil1.next() * env1.next(); // 8bit osc * 8 bit env = 16bits
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
} 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;
}
}