In my
previous post on this topic, I set up the Netduino to receive
MIDI messages via the serial input on the board. In that example, I
simply blinked an LED when the messages arrived. This time, I want
to actually parse the messages.
The MidiMessageReceiver Class
The first thing I need to do is refactor the code so I have a
properly encapsulated MIDI class. This is a relatively low-level
class that doesn't have much intelligence around the messages other
than what's required to parse them.
using System;
using System.IO.Ports;
using System.Collections;
using PeteBrown.NetduinoMidiTest.Messages;
namespace PeteBrown.NetduinoMidiTest
{
class MidiMessageReceiver : IDisposable
{
private SerialPort _midiIn =
new SerialPort("COM1", 31250, Parity.None, 8, StopBits.One);
private Queue _dataBuffer = new Queue();
public event MidiMessageReceivedEventHandler MessageReceived;
public void Open()
{
_midiIn.DataReceived += new SerialDataReceivedEventHandler(OnDataReceived);
_midiIn.ErrorReceived +=new SerialErrorReceivedEventHandler(OnErrorReceived);
_midiIn.Open();
}
public void Close()
{
if (_midiIn.IsOpen)
{
_midiIn.Close();
_midiIn.DataReceived -= OnDataReceived;
_midiIn.ErrorReceived -= OnErrorReceived;
}
}
public void Dispose()
{
Close();
}
private void OnErrorReceived(object sender, SerialErrorReceivedEventArgs e)
{
}
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
// buffer for the data. May be more efficient to
// preallocate a larger fixed-size buffer and
// read bytes into that (memory vs. cpu)
byte[] readBuffer = new byte[_midiIn.BytesToRead];
// Read the bytes from the buffer. There's no way
// to read bytes without allocating a buffer, so
// we'll allocate a temp buffer before putting the
// results in the queue
_midiIn.Read(readBuffer, 0, readBuffer.Length);
foreach (byte b in readBuffer)
{
_dataBuffer.Enqueue(b);
}
ParseMidiStream();
}
// reference: http://www.tonalsoft.com/pub/pitch-bend/pitch.2005-08-24.17-00.aspx
// Channel messages are the primary messages used for controlling synthesizers
// and for receiving input from MIDI controllers. Channel messages require two
// or three bytes, depending on the specific message. The first byte is always
// divided into two nibbles (4-bits). The first nibble contains the message
// number, and the second nibble contains the channel number. Channels have a
// value between 0 and 15.
//
// reference: http://www.cs.cf.ac.uk/Dave/Multimedia/node158.html
// - MIDI message includes a status byte and up to two data bytes.
// - Status byte
// - The most significant bit of status byte is set to 1.
// - The 4 low-order bits identify which channel it belongs to (four bits produce 16 possible channels).
// - The 3 remaining bits identify the message.
// - The most significant bit of data byte is set to 0.
private bool DequeueCompleteMessage(ref MidiMessage message)
{
// peek lets you take a look without removing from queue
byte firstByte = (byte)_dataBuffer.Peek();
// parse the message and channel number
int messageNumber = firstByte >> 4;
int channelNumber = firstByte & (byte)0x0F;
int expectedDataByteCount = 0;
switch ((MidiMessageNumber)messageNumber)
{
case MidiMessageNumber.NoteOff:
case MidiMessageNumber.NoteOn:
case MidiMessageNumber.PolyphonicAftertouch:
case MidiMessageNumber.ControlChange:
case MidiMessageNumber.PitchWheelChange:
expectedDataByteCount = 2;
break;
case MidiMessageNumber.ProgramChange:
case MidiMessageNumber.ChannelPressureAftertouch:
expectedDataByteCount = 1;
break;
}
if (_dataBuffer.Count >= expectedDataByteCount + 1)
{
_dataBuffer.Dequeue(); // remove the message/channel
message.MessageNumber = (MidiMessageNumber)messageNumber;
message.Channel = (MidiChannel)channelNumber;
if (expectedDataByteCount >= 1)
message.DataByte1 = (byte)_dataBuffer.Dequeue();
if (expectedDataByteCount >= 2)
{
message.DataByte2 = (byte)_dataBuffer.Dequeue();
message.IsDataByte2Used = true;
}
// special case the pitch bend which stores a 16 bit value
if (message.MessageNumber == MidiMessageNumber.PitchWheelChange)
{
message.IsCombinedData = true;
message.DataBytesCombined = ((int)message.DataByte1 << 8) | (message.DataByte2);
}
return true;
}
else
{
return false;
}
}
private void ParseMidiStream()
{
bool moreData = _dataBuffer.Count != 0;
while (moreData)
{
MidiMessage message = new MidiMessage();
if (DequeueCompleteMessage(ref message))
{
if (MessageReceived != null)
MessageReceived(this,
new MidiMessageReceivedEventArgs() { Message = message });
// potentially more actionable data
moreData = _dataBuffer.Count != 0;
}
else
{
// no more actionable data
moreData = false;
}
}
}
}
}
A more feature-rich class might have different events for key
messages, and map things like a NoteOn with a velocity of 0 to a
NoteOff event, hiding some of the ugly innards of MIDI.
The way the class works is as follows:
- The client instantiates the class, wires up a handler for the
event, and calls the Open method
- The class opens the serial port connection and listens for
data
- When data arrives, the class adds the data to the internal
queue
- The class then checks to see if that data, combined with what
was previously received but unprocessed, constitutes at least one
complete message in the queue. If so, it parses it.
-
- For each complete message, a friendlier version of the message
is created and the event is raised.
- This continues for as long as valid messages exist in the
queue.
- The client waits forever, allowing the class to continue to
receive events from the serial port
Support Classes, Structs and Enums
There are a number of classes, structs and enums used here to
help keep the code relatively clean.
MidiMessage Struct
I used a struct rather than a class, as I felt it would be a bit
easier on the GC. I haven't done any tests at all to see if that is
even remotely true on the .NET MF :)
namespace PeteBrown.NetduinoMidiTest.Messages
{
public struct MidiMessage
{
public MidiMessageNumber MessageNumber;
public MidiChannel Channel;
public byte DataByte1;
public byte DataByte2;
public int DataBytesCombined;
public bool IsDataByte2Used;
public bool IsCombinedData;
public override string ToString()
{
// no string.format in the NETMF
string result = "Ch: " + Channel +
" Msg: " + MessageNumber.ToString();
if (IsCombinedData)
result += " DataWord: " + DataBytesCombined;
else
{
result += " DataByte1:" + (int)DataByte1;
if (IsDataByte2Used)
result += " DataByte2:" + (int)DataByte2;
}
return result;
}
}
}
MidiMessageNumber Enum
This enum is a mapping of midi messages (only a few are handled
so far) to their message number.
namespace PeteBrown.NetduinoMidiTest.Messages
{
public enum MidiMessageNumber
{
// Channel Messages
NoteOff = 0x8,
NoteOn = 0x9,
PolyphonicAftertouch = 0xA,
ControlChange = 0xB,
ProgramChange = 0xC,
ChannelPressureAftertouch = 0xD,
PitchWheelChange = 0xE
}
}
MidiChannel Enum
I created this enum so I could include Omni (receive on all
channels) should I decide to add channel filtering later. I may
trash this in the final version, opting for a simple number
instead. TBD
namespace PeteBrown.NetduinoMidiTest
{
public enum MidiChannel
{
Channel00 = 0,
Channel01,
Channel02,
Channel03,
Channel04,
Channel05,
Channel06,
Channel07,
Channel08,
Channel09,
Channel10,
Channel11,
Channel12,
Channel13,
Channel14,
Channel15,
Omni
}
}
MidiMessageReceivedEventArgs
The .NET Micro Framework doesn't include generics or the base
EventArgs class. All event handlers are created from scratch and
have their own delegate.
using PeteBrown.NetduinoMidiTest.Messages;
namespace PeteBrown.NetduinoMidiTest
{
public delegate void MidiMessageReceivedEventHandler(
object sender, MidiMessageReceivedEventArgs args);
public class MidiMessageReceivedEventArgs
{
public MidiMessage Message { get; set; }
}
}
The Program
The main program for this example displays debug output for the
message, and also turns on the LED when a NoteOn is received, and
turns it off when a NoteOff is received. This doesn't take into
account polyphony, so you could have a key down and see the LED
off. Nevertheless, it still serves as a simple activity
indicator.
using System;
using System.Threading;
using Microsoft.SPOT;
using Microsoft.SPOT.Hardware;
using SecretLabs.NETMF.Hardware;
using SecretLabs.NETMF.Hardware.Netduino;
using System.IO.Ports;
namespace PeteBrown.NetduinoMidiTest
{
public class Program
{
private static OutputPort _onboardLed =
new OutputPort(Pins.ONBOARD_LED, false);
private static MidiMessageReceiver _midiIn = new MidiMessageReceiver();
public static void Main()
{
_midiIn.MessageReceived += new MidiMessageReceivedEventHandler(OnMidiMessageReceived);
_midiIn.Open();
// wait forever
Thread.Sleep(Timeout.Infinite);
}
static void OnMidiMessageReceived(object sender, MidiMessageReceivedEventArgs args)
{
Debug.Print(args.Message.ToString());
if (args.Message.MessageNumber == Messages.MidiMessageNumber.NoteOn)
{
if (args.Message.DataByte2 == 0)
{
// a note on with velocity of zero is really a note off. LAME
_onboardLed.Write(false);
}
else
{
_onboardLed.Write(true);
}
}
if (args.Message.MessageNumber == Messages.MidiMessageNumber.NoteOff)
_onboardLed.Write(false);
}
}
}
Debug and Device Output
The output from the debug window looks something like this
(obviously differs based on what keys you hit, and if your keyboard
has aftertouch)
Ch: 0 Msg: 9 DataByte1:55 DataByte2:43
Ch: 0 Msg: 9 DataByte1:55 DataByte2:0
Ch: 0 Msg: 9 DataByte1:58 DataByte2:99
Ch: 0 Msg: 9 DataByte1:58 DataByte2:0
Ch: 0 Msg: 9 DataByte1:60 DataByte2:94
Ch: 0 Msg: 9 DataByte1:60 DataByte2:0
Ch: 0 Msg: 9 DataByte1:58 DataByte2:111
Ch: 0 Msg: 9 DataByte1:58 DataByte2:0
Ch: 0 Msg: 9 DataByte1:55 DataByte2:82
Ch: 0 Msg: 13 DataByte1:30
Ch: 0 Msg: 13 DataByte1:0
Ch: 0 Msg: 9 DataByte1:55 DataByte2:0
Ch: 0 Msg: 9 DataByte1:58 DataByte2:106
Ch: 0 Msg: 9 DataByte1:58 DataByte2:0
Ch: 0 Msg: 9 DataByte1:60 DataByte2:97
Ch: 0 Msg: 13 DataByte1:40
Ch: 0 Msg: 13 DataByte1:0
Ch: 0 Msg: 9 DataByte1:60 DataByte2:0
So far, this is working nicely. The next step is to build a
synthesizer engine, mostly in software, but with some hardware
components. I have a good chunk of that code done as I'll snag it
from my Silverlight synthesizer prototype.
Here's a quick video of the current progress.
The source code is included with this post. Please refer back to
my previous post for information on the hardware
setup.