Tutorial: The MidiMessage class

This tutorial introduces the MidiMessage class, which is used for representing MIDI data. The MidiBuffer class is also introduced for handling buffers of MIDI messages.

Level: Beginner

Platforms: Windows, Mac OS X, Linux, iOS, Android

Classes: MidiMessage, MidiBuffer, Time, Timer

Getting started

Note
This tutorial assumes that you are familiar with MIDI in general. You should also be familiar with using JUCE buttons and sliders (see Tutorial: Slider values and Tutorial: Listeners and broadcasters).

Download the demo project for this tutorial here: tutorial_midi_message.zip. Unzip the project and open it in your IDE.

If you need help with this step, see Tutorial: Getting started with the Projucer.

The demo project

The demo project presents four buttons to create MIDI messages on MIDI channel 10. These buttons create note-on messages for four of the standard (General MIDI) drum sounds: bass drum, snare drum, closed hi-hat, and open hi-hat. There is also a slider that creates a volume controller message (continuous controller 7). The interface is shown in the following screenshot.

tutorial_midi_message_screenshot1.png
Drum pad buttons and a volume slider

The panel on the right-hand side displays the list of MIDI messages than have been generated, along with a timestamp (relative to the time that the application was launched).

Note
The application doesn't send any MIDI data, or make any sound, it only displays the MIDI data.

The MidiMessage class

This tutorial illustrates the code required to create some MIDI message types. It also includes some code to parse most MIDI message types. In general, the MidiMessage class contains a range of static member functions for creating MidiMessage objects (for example the MidiMessage::noteOn() function for creating note-on messages). There are also a range of member functions for querying and accessing MidiMessage objects (for example, the MidiMessage::isNoteOn() and MidiMessage::getNoteNumber() functions).

Creating MidiMessage objects

Have a look at the public static member functions for the MidiMessage class. This lists all of the functions for creating different types of MIDI message. You can also create MidiMessage objects from the individual bytes or raw data but these must be valid MIDI messages according to the MIDI specification. (An assertion will be generated in a debug build if you create an invalid MidiMessage object.)

Note
MidiMessage objects should normally be stored as local or member variables and passed by value.

To create a note-on message use the MidiMessage::noteOn() function. This needs the MIDI channel (numbered 1 .. 16), the note number (0 .. 127), and the velocity (as a uint8 value 0 .. 127). Alternatively, the velocity can be expressed as a float value which will be converted to 0 .. 127 internally (rounded to the nearest integer).

Note
A note-on with zero velocity is actually a note-off message so note-on velocities are in the range 1 .. 127 (which makes the minimum floating-point velocity for a note-on around 0.004f). There is also the MidiMessage::noteOff() function for specifically creating note-off messages that also allows you to specify a note-off velocity (which is recognised by some synthesisers).

In our demo project we create a note-on message with velocity of 100 and a different note number depending on which button was clicked:

void buttonClicked (Button* button) override
{
int noteNumber = -1; // just used as a check that this as been set before we create a MidiMessage object
if (button == &bassDrumButton) noteNumber = 36;
if (button == &snareDrumButton) noteNumber = 38;
if (button == &closedHiHatButton) noteNumber = 42;
if (button == &openHiHatButton) noteNumber = 46;
if (noteNumber >= 0)
{
MidiMessage message = MidiMessage::noteOn (midiChannel, noteNumber, (uint8) 100);
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToList (message);
}
}

Notice the value of -1 that is used to check that at least one of our buttons set the noteNumber value. We also need to cast the value 100 to the uint8 type. If we don't do this then there is a compiler ambiguity regarding which of the versions of the MidiMessage::noteOn() function should be called.

Setting the timestamp of a MidiMessage is optional but it's very useful for keeping track of the time that events were generated or received. The default timestamp is zero and the time units of the timestamp are not defined. In general, it is up to the application to decide what time units to use. In this simple case we are using seconds as the units by obtaining the current time using the Time::getMillisecondCounterHiRes() function and multiplying by 0.001 (and subtracting the time that the application started so that this is relative to that point in time).

The volume slider is used to create a continuous controller (CC) message. CC7 is the the volume control change message:

void sliderValueChanged (Slider* slider) override
{
if (slider == &volumeSlider)
{
MidiMessage message = MidiMessage::controllerEvent (midiChannel, 7, (int) volumeSlider.getValue());
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToList (message);
}
}

Parsing MidiMessage objects

Our addMessageToList() function parses the timestamp and the MIDI message so that it can be displayed in the list of messages in our interface:

void addMessageToList (const MidiMessage& message)
{
const double time = message.getTimeStamp();
const int hours = ((int) (time / 3600.0)) % 24;
const int minutes = ((int) (time / 60.0)) % 60;
const int seconds = ((int) time) % 60;
const int millis = ((int) (time * 1000.0)) % 1000;
const String timecode (String::formatted ("%02d:%02d:%02d.%03d",
hours,
minutes,
seconds,
millis));
logMessage (timecode + " - " + getMidiMessageDescription (message));
}

The getMidiMessageDescription() function actually parses the MIDI data to get a human-readable description of the message.

static String getMidiMessageDescription (const MidiMessage& m)
{
if (m.isNoteOn()) return "Note on " + MidiMessage::getMidiNoteName (m.getNoteNumber(), true, true, 3);
if (m.isNoteOff()) return "Note off " + MidiMessage::getMidiNoteName (m.getNoteNumber(), true, true, 3);
if (m.isProgramChange()) return "Program change " + String (m.getProgramChangeNumber());
if (m.isPitchWheel()) return "Pitch wheel " + String (m.getPitchWheelValue());
if (m.isAftertouch()) return "After touch " + MidiMessage::getMidiNoteName (m.getNoteNumber(), true, true, 3) + ": " + String (m.getAfterTouchValue());
if (m.isChannelPressure()) return "Channel pressure " + String (m.getChannelPressureValue());
if (m.isAllNotesOff()) return "All notes off";
if (m.isAllSoundOff()) return "All sound off";
if (m.isMetaEvent()) return "Meta event";
if (m.isController())
{
if (name.isEmpty())
name = "[" + String (m.getControllerNumber()) + "]";
return "Controller " + name + ": " + String (m.getControllerValue());
}
}
Note
The same functionality is already available through the member function MidiMessage::getDescription(). We do not use the ready-made implementation here but implement it ourselves to illustrate how to work with MIDI messages of different types.

This function attempts to parse all types of MIDI message (even though we have only looked at creating note-on and controller messages so far). Here you can see the recommended method of accessing the data in a MidiMessage object:

  • determine the type of MIDI message (using one of the functions that start with "is"); then
  • use appropriate functions for accessing that type of MIDI message.

We would only reach the final line of this function if the message was a system message (system exclusive, for example). You can access the raw data of any message using MidiMessage::getRawData() but generally it is easier (and more readable) to use the range of built-in functions for most purposes.

Warning
Using functions to access data in a MidiMessage for messages of the wrong type will lead to errors. For example, the MidiMessage::getNoteNumber() function will return a value from any MidiMessage object but this doesn't confirm that the message is either a note-on or note-off message. You must check first with one of the functions MidiMessage::isNoteOn(), MidiMessage::isNoteOff(), or MidiMessage::isNoteOnOrOff().
Exercise
Modify the getMidiMessageDescription() function so that it lists the velocity of note-on messages. Check the API reference to find out which function you should use.

The MidiBuffer class

One problem with our demo application is that it doesn't create note-off messages. We are just creating MIDI messages intended for percussion sounds, so this doesn't seem like a big problem. But, it's bad practice not to create note-off messages for corresponding note-on messages (with sustaining sounds it will lead to stuck notes).

We could just add a note-off immediately following the note-on in the buttonClicked() function:

if (noteNumber >= 0)
{
MidiMessage message = MidiMessage::noteOn (1, noteNumber, (uint8) 100);
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToList (message);
MidiMessage messageOff (MidiMessage::noteOff (message.getChannel(), message.getNoteNumber()));
messageOff.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToList (messageOff);
}

We could even change the timestamp of the note-off message (for example 0.1s after the note-on message) but this won't change when the messages are posted to the list:

if (noteNumber >= 0)
{
MidiMessage message = MidiMessage::noteOn (1, noteNumber, (uint8) 100);
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToList (message);
MidiMessage messageOff (MidiMessage::noteOff (message.getChannel(), message.getNoteNumber()));
messageOff.setTimeStamp (message.getTimeStamp() + 0.1);
addMessageToList (messageOff);
}

The MidiBuffer class provides functions for iterating over buffers of MIDI messages based on their timestamps. To illustrate this we will set up a simple scheduling system where we add MidiMessage objects with specific timestamps to a MidiBuffer object. Then we use a Timer object that checks regularly whether any MIDI messages are due to be delivered.

Warning
The Timer class is not suitable for high-precision timing. This is used to keep the example simple by keeping all function calls on the message thread. For more robust timing you should use another thread (in most cases the audio thread is appropriate for rendering MidiBuffer objects in to audio).

Add some members to our MainContentComponent class:

MidiBuffer midiBuffer; // [1]
double sampleRate; // [2]
int previousSampleNumber; // [3]
  • [1]: The MidiBuffer object itself.
  • [2]: The MidiBuffer class uses samples as the units for the timestamps of MIDI messages. Although we are not generating audio we need to choose something to use as the sample rate. We use this member to store the sample rate. (We use 44,100 since this is a common value.)
  • [3]: We need to keep track of which timestamp we have already reached within the MidiBuffer. We use this member to store the this timestamp in samples.

We need to initialise the sampleRate and previousSampleNumber members in our MainContentComponent constructor:

MainContentComponent()
: midiChannel (10),
startTime (Time::getMillisecondCounterHiRes() * 0.001),
sampleRate (44100.0),
previousSampleNumber (0)
{
//...

Adding MIDI messages to a MidiBuffer object

Instead of adding our MIDI message directly to the list of messages, we add them to our MidiBuffer object. Add this function, which calls the MidiBuffer::addEvent() function:

void addMessageToBuffer (const MidiMessage& message)
{
const double timestamp = message.getTimeStamp();
const int sampleNumber = (int) (timestamp * sampleRate);
midiBuffer.addEvent (message, sampleNumber);
}

Now modify the buttonClicked() and sliderValueChanged() functions to make use of this function. This allows us to schedule MIDI message events into the future:

void buttonClicked (Button* button) override
{
int noteNumber = -1;
if (button == &bassDrumButton) noteNumber = 36;
if (button == &snareDrumButton) noteNumber = 38;
if (button == &closedHiHatButton) noteNumber = 42;
if (button == &openHiHatButton) noteNumber = 46;
if (noteNumber >= 0)
{
MidiMessage message = MidiMessage::noteOn (1, noteNumber, (uint8) 100);
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToBuffer (message);
MidiMessage messageOff (MidiMessage::noteOff (message.getChannel(), message.getNoteNumber()));
messageOff.setTimeStamp (message.getTimeStamp() + 0.1);
addMessageToBuffer (messageOff);
}
}
void sliderValueChanged (Slider* slider) override
{
if (slider == &volumeSlider)
{
MidiMessage message = MidiMessage::controllerEvent (10, 7, (int) volumeSlider.getValue());
message.setTimeStamp (Time::getMillisecondCounterHiRes() * 0.001 - startTime);
addMessageToBuffer (message);
}
}

Iterating over a MidiBuffer object

To read the messages from the buffer we need to implement our timer. Add the Timer class as a base class:

class MainContentComponent : public Component,
private Timer
{
//...

And implement the Timer::timerCallback() function:

void timerCallback() override
{
const double currentTime = Time::getMillisecondCounterHiRes() * 0.001 - startTime;
const int currentSampleNumber = (int) (currentTime * sampleRate); // [4]
MidiBuffer::Iterator iterator (midiBuffer);
MidiMessage message;
int sampleNumber;
while (iterator.getNextEvent (message, sampleNumber)) // [5]
{
if (sampleNumber > currentSampleNumber) // [6]
break;
message.setTimeStamp (sampleNumber / sampleRate); // [7]
addMessageToList (message);
}
midiBuffer.clear (previousSampleNumber, currentSampleNumber - previousSampleNumber); // [8]
previousSampleNumber = currentSampleNumber; // [9]
}
  • [4]: Calculate the current time in samples.
  • [5]: Use a MidiBuffer::Iterator object to iterate over the messages in the buffer. The MidiBuffer::Iterator::getNextEvent() function updates the message and sampleNumber (timestamp) variables with the values for the next MIDI message in the buffer.
  • [6]: If the timestamp for the MIDI message most recently retrieved from the MidiBuffer object is in the future, then we have finished processing and we exit the while() loop.
  • [7]: The timestamps of the MidiMessage objects obtained will have the timestamps based on sample numbers. Let's reset this to our seconds-based timestamp system so that it works with our addMessageToList() function without having to modify it.
  • [8]: The MidiBuffer::clear() function clears MIDI messages from the buffer that have timestamps within a certain range. We use this to remove messages that we have just processed.
  • [9]: Keep track of the time that this function executed for use the next time that the timerCallback() function is called.

Finally, we need to start the timer in our MainContentComponent constructor:

//...
setSize (800, 300);
startTimer (1);
}
Note
The code for these modifications can be found in the MainComponent_02.h file within the Source directory of the demo project.
Exercise
Add buttons for the crash cymbal (note number 49) and ride cymbal (note number 51). Add a slider for panning control (CC10). Space has been left for you to add these three components in the resized() function.

Summary

This tutorial has introduced the MidiMessage class and the MidiBuffer class. After reading this tutorial you should be able to:

  • Create MidiMessage objects of specific types — note-on, note-off, continuous controller (control change), and so on.
  • Parse a MidiMessage object to discover its type and obtain useful data from it.
  • Store MIDI messages in a MidiBuffer object.
  • Iterate over MIDI messages in a MidiBuffer object based on their timestamps.

See also