High Performance Streaming with My Little MIDI Library

Updated on 2020-06-27

Extensive performance and API improvements for streaming MIDI and more

Introduction

I provided my MIDI Library as part of a larger project that included a MIDI file slicer and a simple drum machine. These worked, but they required you to stop playing the output before any changes were made, and they also took quite a bit of CPU to preview.

MIDI Library

Fortunately, Windows provides an efficient hardware accelerated MIDI streaming API you can use to send MIDI events to the output. The native API is not easy to use from C#, but I have wrapped it to make it much easier.

The upshot of this is background playback of an in-memory MIDI sequence without stealing a bunch of CPU cycles or creating another thread.

I have updated the MIDI library to reflect this. I have also updated the MIDI Slicer and FourByFour drum machine apps to allow you to edit in a more real time manner, reflecting the new capabilities of the library.

Conceptualizing this Mess

For the basics on using my MIDI library, see this article. Mostly, I'll be covering the additional features I've added here.

article

As mentioned, Windows provides a streaming API for sending MIDI events out to a device. The events, like a standard MIDI event, are stamped with the delta in ticks so that multiple events at different times can be sent at once. There are some limitations to this API, such that it's not always hardware accelerated, but more importantly the send buffer is only 64kb, meaning you can only queue up 64kb worth of events for playback at any given time.

What we do with this feature is we queue up events as we go, so that when a user changes a setting, that can be reflected in our event stream almost right away.

We've got some new classes to explore:

MidiDevice is the base class for MIDI devices and contains accessors to get the available output devices and streams. It has Outputs and Streams properties which enumerate each respectively.

MidiOutputDevice is a specialized MidiDevice that contains features specific to MIDI output devices. You can get the associated MidiStream for the output device by retrieving MidiOutputDevice.Stream. Each device has a Name and Index which identify it.

Both streams and devices must be opened before being used. However, when opening them, be aware that you cannot have both a MIDI output device and its associated stream open at the same time. If you need the features of both, MidiStream allows you to send messages immediately, like the output device, plus it allows you to queue up events.

Using the MidiOutputDevice is simply a matter of opening it using Open() and then using Send() to send MidiMessage objects. Unlike the previous versions of this library, this one should be able to send sysex messages if the underlying device supports them.

Using a MidiStream, you can do the same thing as above, which is simple, or to use the streaming features, you have to set some things up first. You typically need a SendComplete event handler to tell you when the queued up events have all been played. In addition to using Open(), you'll also typically need to set the TimeBase and finally, you'll use Start() to make the queued events begin playing.

Coding this Mess

The scratch project contains code to stream a file to the output 100 events at a time.

// demonstrate streaming a midi file 100 events at a time
// this allows you to handle files with more than 64k
// of in-memory events (not the same as "on disk" size)
// this replays the events in a loop
var mf = MidiFile.ReadFrom(@"..\..\Bohemian-Rhapsody-1.mid"); // > 64kb!

// we use 100 events, which should be safe and allow
// for some measure of SYSEX messages in the stream
// without bypassing the 64kb limit
const int EVENT_COUNT = 100;
// our current cursor pos
int pos = 0;
// merge our file for playback
var seq = MidiSequence.Merge(mf.Tracks);
// the number of events in the seq
int len = seq.Events.Count;
// stores the next set of events
var eventList = new List<MidiEvent>(EVENT_COUNT);
// just grab the first output stream
// should be the wavetable synth
using (var stm = MidiDevice.Streams[0])
{
    // open the stream
    stm.Open();
    // start it
    stm.Start();

    // first set the timebase
    stm.TimeBase = mf.TimeBase;

    // set up our send complete handler
    stm.SendComplete += delegate (object sender,EventArgs eargs)
    {
        // clear the list
        eventList.Clear();
        // iterate through the next events
        var next = pos+EVENT_COUNT;
        for(;pos<next;++pos)
        {
            // if it's past the end, loop it
            if (len <= pos)
            {
                pos = 0;
                break;
            }
            // otherwise add the next event
            eventList.Add(seq.Events[pos]);
        }
        // send the list of events
        stm.Send(eventList);
    };
    // add the first events
    for(pos = 0;pos<EVENT_COUNT;++pos)
    {
        // if it's past the end, loop it
        if (len <= pos)
        {
            pos = 0;
            break;
        }
        // otherwise add the next event
        eventList.Add(seq.Events[pos]);
    }
    // send the list of events
    stm.Send(eventList);

    // loop until a key is pressed
    Console.WriteLine("Press a key...");
    Console.ReadKey();
    // close the stream
    stm.Close();
}

As you can see, this is a little bit involved. That's the price you pay for streaming. However, once you strip away all the comments, the core isn't that complicated. Basically, what we're doing is taking a MIDI file, and merging all the tracks into a single sequence for playback. We then get the Events off of that MidiSequence and we start iterating through them, at a maximum of 100 at a time. The less events you use, the more real time you can alter them, but the more CPU intensive playback will be. It's a tradeoff. If we reach the end, we start over so we can loop. For each batch of events, we add them to eventList for playback. We then queue those events for playback using Send(). Note how we're doing this inside the SendComplete handler, and also once at the beginning to kick things off. Finally, we simply wait for a key. Closing the stream will stop the playback and stop the events from firing. Remember that Send() can take either MidiEvent or MidiMessage message objects, but the former will be queued for playback while the latter will not.

We do the above technique in our demo projects as well, except instead of loading the file from disk, we create it, or load it and preprocess it depending on the settings in the UI.

History

  • 27th June, 2020 - Initial submission