Midi is a small library providing a full featured, easy to use managed wrapper over Microsoft Windows' MIDI API, as well as providing for reading, writing and manipulating MIDI files and in-memory MIDI sequences. It is smaller than other libraries like Dry Wet MIDI and lower level in many ways.
Introduction
I do some MIDI sequencing and recording and I found it helpful to be able to splice sections out of a MIDI file, but I didn't have a tool that made it easy to do. In the process of creating such a tool, I made a Midi assembly that contained the core MIDI file manipulation options. I also wrote some remedial playback code at first, which used the 32-bit Windows MIDI API.
That library grew as I added more features and shored up what I had. I added some more demos, streaming support, MIDI input support, device enumeration and more. Eventually, I had wrapped maybe 90-95% of the API, and had a battery of MIDI manipulation functions for searching and modifying in memory sequences and files.
In the process, MidiSlicer
moved from a first class application to just another demo project, so the solution is still named MidiSlicer
- I'm stuck with the GitHub of that name. The core library project is named Midi
.
I've produced articles on using bits and pieces of it, but never a comprehensive guide, and I aim to do that here.
Update: Added experimental tempo synchronization functionality to the library. It doesn't get the timing perfect because I can't get the latency low enough consistently to make it super accurate, but I've provided it in the interest of completeness.
Update 2: Added a few MidiSequence improvements to help with locating positions within a track based on time, like GetPositionAtTime()
, and GetNextEventAtPosition()
. As before you can still use MidiSequence.GetContext(position).Time
to get a time from a position.
Update 3: Fixed stability issue in MidiStream
. It turns out I misunderstood something about the way the MIDI driver api works, and it's not very well documented so I didn't have a lot of help. It worked, until I "optimized" it to reduce unmanaged heap fragmentation a little, but it couldn't take the optimization for reasons. It turns out it just wasn't doing with the memory what I thought it was. Anyway, I fixed that. Get this update, especially if your app is randomly crashing.
Update 4: Finally added MidiSequence.AddAbsoluteEvent()
which is an optimized way to add a single absolutely positioned MidiEvent
to a MidiSequence
without having to resort to Merge()
which is a bit more complicated and less efficient. This is explored more in techniques.
Update 5: Not directly an update to this particular article, but I've published a related article here on how I get some of the trickier P/Invoke in this library to work. It covers some of the the low level internals behind MidiStream
in particular.
Update 6: MidiStream
now derives from MidiOutputDevice
Update 7: Fixed MidiSequence.ToNoteMap()
bug and added MidiUI project which contains the beginnings of user interface controls for MIDI sequencing, including a piano control and a MIDI sequence visualizer control. I am still working on these so they are what I'd consider rough proofs. When I get further along I'll write an article about them.
Conceptualizing this Mess
There are two major parts of this library, though they are completely and seamlessly integrated with one another.
One is the portion dealing with MIDI files and in-memory sequences that provide manipulation and querying.
The other portion deals with communicating and querying MIDI devices. This is how you read musical keyboard key presses or make sound with a synthesizer (including the wavetable synthesizer built into your computer's sound hardware)
Once we dive into those, we're going to cover the MIDI protocol because both files and the MIDI device API rely on the MIDI protocol format. The MIDI protocol format is described later in this section, but first, we'll cover the API for representing it.
MIDI API
Protocol API
Message API
The protocol consists primarily of MIDI messages which represent the various actions like adjusting a knob or striking a note on a keyboard. The API for the MIDI messages is relatively straightforward. It is a series of MidiMessage
derivatives that closely mirror the underlying protocol, plus provide higher level representations of each action such as MidiMessageNoteOn
/MidiMessageNoteOff
to signify a note strike and release, and MidiMessageCC
to signify a control change, such as a knob tweak.
Since for almost all messages each type of message is a specific length each MidiMessage
further derives from MidiMessageByte
for a message with a single byte payload or MidiMessageWord
for a message with a double byte payload which provide raw byte level access to the data in the message. Finally, these are derived by the final high level midi message that represents the message like MidiMessageNoteOn
which derives from MidiMessageWord
because it requires two bytes to represent it.
It's recommended to use the high level members like Note
and Velocity
on MidiMessageNoteOn
to adjust the data even though it's also available through Data1
and Data2
inherited from MidiMessageWord
. Each high level message has high level members that represent the specific parameters for the message as just described for MidiMessageNoteOn
.
While most messages have a fixed size payload of either zero, one, or two bytes, there are two exceptions. The first is MIDI system-exclusive messages a.k.a. sysex messages which pass device specific information to or from a MIDI output device or a MIDI input device respectively. These are represented by MidiMessageSysex
which has a variable length payload represented by Data
.
The second exception typically only appears in files, and is not sent or received over the wire from devices. These are called MIDI meta messages and provide things like tempo changes or copyright information. Despite only occurring in files, the device API wrapper will accept certain meta events like tempo changes but these are never sent to the output device as MIDI messages nor will they be received from a device. This is provided by the wrapper code itself to make it easier to read from a file directly to a device but is provided as a convenience. Basically what happens is whenever the MIDI stream wrapper finds one of these messages, it adjusts its internal tempo. These meta messages are represented by derivatives of MidiMessageMeta
like MidiMessageMetaTempo
which signifies a tempo change. The type of a MidiMessageMeta
message is represented by Type
which represents the kind of meta message and comprises the first part of the payload and the remainder of the payload is represented by Data
.
The other part of the protocol which is used in files and for queuing up messages for timed playback consists of events which are simply MIDI messages as above but also with a timestamp delta associated with them. The timestamp delta is the number of MIDI ticks since the previous message. The duration of a MIDI tick is based on the timebase (resolution) and the tempo of a sequence or queued event set. A series of events represents a particular score that is suitable for storing in a file or for queued playback. A MidiEvent
represents a MIDI event which consists of Position
that represents the timestamp delta in ticks and a Message
which contains the associated MIDI message. While events almost always contain a timestamp delta, getting the AbsoluteEvents
from MidiSequence
(see below) will fill Position
with the absolute position of the message within the sequence, in ticks.
Finally, there is the MidiContext
class which makes it easy to track the current position in the score, and the state of all CC knobs, and notes in a message playback stream. Basically, it holds the state of all note velocities and CC values, plus pitch wheel, current tempo, current song position, and other information. You feed it MidiMessage
messages and/or MidiEvent
events as you go along and it handles all of the tracking. You can then query it for the state of any aspect of the playback.
File and Sequence API
The core of the MIDI API and the basis for most of the functionality is MidiSequence
, which simply contains an in-memory series of MIDI events represented by MidiEvent
and various members for querying and manipulating the events. Everything operates in ticks.
The Events
list is your primary access to modifying a sequence event by event. It uses timestamp deltas in MidiEvent
instances to represent the events. There is also a read only enumeration called AbsoluteEvents
which yields MidiEvent
objects with the Position
set to the absolute position in the sequence, in ticks, which makes it easier sometimes to operate on. Currently, you cannot modify this enumeration but it may be a modifiable list in a future version.
The members like Lyrics
, Tempos
and Copyright
are retrieved by scanning the sequence for the appropriate MidiMessageMeta
derived messages. Currently, in order to change these, you'll have to add and remove meta messages in the sequence yourself, as these properties are read-only. This may change in a future release.
There are some high level queries like FirstDownBeat
and FirstNoteOn
which fetch the location of their respective targets.
Using MIDI note on/note off messages is perfect for real time performance but leaves something to be desired when it comes to higher level analysis of sequences and scores. It's often better to understand a note as something with an absolute position, a velocity and a length. MidiSequence
provides the ToNoteMap()
method which retrieves a list of MidiNote
instances representing the notes in a sequence, complete with lengths, rather than the note on/note off paradigm. It also provides the static FromNoteMap()
method which gets a sequence from a note list of MidiNote
objects. This can make it easier to both create and analyze scores.
There are also methods like AdjustTempo()
, Stretch()
, Resample()
, GetRange()
, Merge()
which each return a new sequence with the indicated operation applied to it. Merge()
in particular is a versatile method that allows you to combine queries across multiple sequences by merging them, or doing things like merging for playback.
Preview()
will play the sequence on the calling thread using the optionally indicated MidiOutputDevice
. It can optionally loop, but it's recommended to do this on a separate thread that you can abort as there is no way to exit the loop. This method does not stream. Instead, it sends each message immediately to the hardware. This is CPU intensive. There is a better method for playing a sequence by streaming it to the hardware, which can be done asynchronously. This is covered in the techniques section.
MidiFile
represents an in-memory MIDI file. A MIDI file contains multiple tracks, each represented by a MidiSequence
. The first track typically - at least for "type 1" MIDI files - contains only meta messages including a tempo map, without performance messages. The API assumes this so when you're querying things like Tempo
it will look on the first track.
MidiFile
contains many of the same members as MidiSequence
which either operate on the first track or all tracks, depending on what makes sense for the operation. You can always modify each individual track itself, but remember to assign the sequence you modified back to the Tracks
list at that index, because modifications to sequences always return a copy of the sequence - they don't modify the sequence itself. Any methods that modify a MIDI file like Stretch()
will return a new MIDI file, similar to how MidiSequence
works.
MidiFile
also contains ReadFrom()
and WriteTo()
which can read a MIDI file from a Stream
or a file. Naturally, this is how you turn an in-memory representation into an actual MIDI file, or turn a MIDI file into an in-memory MidiFile
. In case it isn't clear, all operations on a MidiFile
operate in-memory. The process for modifying a file involves reading it, modifying it, and then writing the new, modified file to disk over the old one.
The support classes include MidiTimeSignature
which represents a time signature, MidiKeySignature
which represents a key signature, MidiNote
, which we'll cover below, and MidiUtility
which you shouldn't need that much.
Device API
Note: All events are potentially called from a different thread.
The device API consists primarily of MidiDevice
and its derivatives, MidiOutputDevice
, and MidiInputDevice
which are used for communicating with MIDI devices, plus MidiStream
which is used for high performance asynchronous output streaming.
You can enumerate each of the above off of MidiDevice
's Inputs
, Outputs
and Streams
members, but usually you'll get the stream off of MidiOutputDevice
's Stream
member. Each time you enumerate them the system is requeried, so you can get these lists every time you want a fresh device list, but don't query it more than you need to, obviously.
MidiOutputDevice
includes several members for communicating with an open device. Normally, the process is to Open()
it, and then begin using Send()
to send messages, before finally calling Close()
to close it. Reset()
is kind of a panic method that sends note off messages to all channels so it basically clears all playing notes. You can also get or set the volume using the Volume
property if it's supported, which takes/reports a left and right volume through MidiVolume
. The object is disposable, so it will close when disposed.
MidiStream
provides a more efficient way to communicate with a device that can accept several queued MIDI events and play them in the background, although it also supports sending messages immediately. Using it is similar to using MidiOutputDevice
except it also must be started using Start()
before the queued events will start playing, since once it's opened with Open()
it starts out paused. You probably want to set the TimeBase
and possibly the Tempo
or MicroTempo
as well.
If you call Send()
with a MidiMessage
, the message will be sent immediately to the output. If you call Send()
with one or more MidiEvent
objects, they will be queued for playback. Unless you're firing and forgetting once you'll need to handle the SendComplete
event which will tell you when the queued events have been played. Note that you cannot queue more events until all events have been played. Send accepts tempo change messages and will respect track end messages. Other meta messages are discarded. In the techniques section, it is shown how to stream a file or sequence.
MidiInputDevice
includes members for capturing MIDI input. What you do is you hook the relevant events including Input
, Open()
the device, Start()
the device to begin capturing. You can easily record MIDI performances to a file using StartRecording()
and EndRecording()
. Each time you get a valid message, Input
is fired with arguments that tell you the message and the number of milliseconds elapsed since Start()
was called. There is also Error
, Opened
, and Closed
. Error
is fired if an invalid or malformed message is received.
Protocol Format
Message Format
The following guide is presented as a tutorial on the MIDI protocol format, but it's not necessary to be completely familiar with it in order to use this library. All of the MIDI protocol features are wrapped by the API.
MIDI works using "messages" which tell an instrument what to do. MIDI messages are divided into two types: channel messages and system messages. Channel messages make up the bulk of the data stream and carry performance information, while system messages control global/ambient settings.
A channel message is called a channel message because it is targeted to a particular channel. Each channel can control its own instrument and up to 16 channels are available, with channel #10 (zero based index 9) being a special channel that always carries percussion information, and the other channels being mapped to arbitrary devices. This means the MIDI protocol is capable of communicating with up to 16 individual devices at once.
A system message is called a system message because it controls global/ambient settings that apply to all channels. One example is sending proprietary information to a particular piece of hardware, which is done through a "system exclusive" or "sysex" message. Another example is the special information included in MIDI files (but not present in the wire protocol) such as the tempo to play the file back at. Another example of a system message is a "system realtime message" which allows access to the transport features (play, stop, continue and setting the timing for transport devices)
Each MIDI message has a "status byte" associated with it. This is usually** the first byte in a MIDI message. The status byte contains the message id in the high nibble (4-bits) and the target channel in the low nibble. Ergo, the status byte 0xC5 indicates a channel message type of 0xC and a target channel of 0x5. The high nibble must be 0x8 or greater for reasons. If the high nibble is 0xF, this is a system message, and the entire status byte is the message id since there is no channel. For example, 0xFF is a message id for a MIDI "meta event" message that can be found in MIDI files. Once again, the low nibble is part of the status if the high nibble is 0xF.
** due to an optimization of the protocol, it is possible that the status byte is omitted in which case the status byte from the previous message is used. This allows for "runs" of messages with the same status but different parameters to be sent without repeating the redundant byte for each message.
The following channel messages are available:
0x8 Note Off
- Releases the specified note. The velocity is included in this message but not used. All notes with the specified note id are released, so if there are two Note Ons followed by one Note Off for C#4 all of the C#4 notes on that channel are released. This message is 3 bytes in length, including the status byte. The 2nd byte is the note id (0-0x7F/127), and the 3rd is the velocity (0-0x7F/127). The velocity is virtually never respected for a note off message. I'm not sure why it exists. Nothing I've ever encountered uses it. It's usually set to zero, or perhaps the same note velocity for the corresponding note on. It really doesn't matter. 0x9 Note On
- Strikes and holds the specified note until a corresponding note off message is found. This message is 3 bytes in length, including the status byte. The parameters are the same as note off. 0xA Key Pressure/Aftertouch
- Indicates the pressure that the key is being held down at. This is usually for higher end keyboards that support it, to give an after effect when a note is held depending on the pressure it is held at. This message is 3 bytes in length, including the status byte. The 2nd byte is the note id (0-0x7F/127) while the 3rd is the pressure (0-0x7F/127) 0xB Control Change
- Indicates that a controller value is to be changed to the specified value. Controllers are different for different instruments, but there are standard control codes for common controls like panning. This message is 3 bytes in length, including the status byte. The 2nd byte is the control id. There are common ids like panning (0x0A/10) and volume (7) and many that are just custom, often hardware specific or customizably mapped in your hardware to different parameters. There's a table of standard and available custom codes here. The 3rd byte is the value (0-0x7F/127) whose meaning depends heavily on what the 2nd byte is. 0xC Patch/Program Change
- Some devices have multiple different "programs" or settings that produce different sounds. For example, your synthesizer may have a program to emulate an electric piano and one to emulate a string ensemble. This message allows you to set which sound is to be played by the device. This message is 2 bytes long, including the status byte. The 2nd byte is the patch/program id (0-0x7F/127) 0xD Channel Pressure/Non-Polyphonic Aftertouch
- This is similar to the aftertouch message, but is geared for less sophisticated instruments that don't support polyphonic aftertouch. It affects the entire channel instead of an individual key, so it affects all playing notes. It is specified as the single greatest aftertouch value for all depressed keys. This message is 2 bytes long, including the status byte. The 2nd byte is the pressure (0x7F/127) 0xE Pitch Wheel Change
- This indicates that the pitch wheel has moved to a new position. This generally applies an overall pitch modifier to all notes in the channel such that as the wheel is moved upward, the pitch for all playing notes is increased accordingly, and the opposite goes for moving the wheel downward. This message is 3 bytes long, including the status byte. The 2nd and 3rd byte contain the least significant 7 bits (0-0x7F/127) and the most significant 7 bits respectively, yielding a 14-bit value.
The following system messages are available (non-exhaustive):
0xF0 System Exclusive
- This indicates a device specific data stream is to be sent to the MIDI output port. The length of the message varies and is bookended by the End of System Exclusive message. I'm not clear on how this is transmitted just yet, but it's different in the file format than it is over the wire, which makes it one-off. In the file, the length immediately follows the status byte and is encoded as a "variable length quantity" which is covered in a bit. Finally, the data of the specified byte length follows that. 0xF7 End of System Exclusive
- This indicates an end marker for a system exclusive message stream 0xFF Meta Message
- This is defined in MIDI files, but not in the wire-protocol. It indicates special data specific to files such as the tempo the file should be played at, plus additional information about the scores, like the name of the sequence, the names of the individual tracks, copyright notices, and even lyrics. These may be an arbitrary length. What follows the status byte is a byte indicating the "type" of the meta message, and then a "variable length quantity" that indicates the length, once again, followed by the data.
Here's a sample of what messages look like over the wire.
Note on, middle C, maximum velocity on channel 0:
90 3C 7F
Patch change to 1 on channel 2:
C2 01
Remember, the status byte can be omitted. Here's some note on messages to channel 0 in a run:
90 3C 7F 3F 7F 42
That yields a C major chord at middle C. Each of the two messages with the status byte omitted are using the previous status byte, 0x90.
The MIDI File Format
Once you understand the MIDI wire-protocol, the file format is fairly straightforward as about 80% or more of an average MIDI file is simply MIDI messages with a timestamp on them.
MIDI files typically have a ".mid" extension, and like the wire-protocol it is a big-endian format. A MIDI file is laid out in "chunks." A "chunk" meanwhile, is a FourCC code (simply a 4 byte code in ASCII) which indicates the chunk type followed by a 4-byte integer value that indicates the length of the chunk, and then followed by a stream of bytes of the indicated length. The FourCC for the first chunk in the file is always "MThd". The FourCC for the only other relevant chunk type is "MTrk". All other chunk types are proprietary and should be ignored unless they are understood. The chunks are laid out sequentially, back to back in the file.
The first chunk, "MThd" always has its length field set to 6 bytes. The data that follows it are 3 2-byte integers. The first indicates the MIDI file type which is almost always 1 but simple files can be type 0, and there's a specialized type - type 2 - which stores patterns. The second number is the count of "tracks" in a file. A MIDI file can contain more than one track, with each track containing its own score. The third number is the "timebase" of a MIDI file (often 480) which indicates the number of MIDI "ticks" per quarter note. How much time a tick represents depends on the current tempo.
The following chunks are "MTrk" chunks or proprietary chunks. We skip proprietary chunks, and read each "MTrk" chunk we find. An "MTrk" chunk represents a single MIDI file track (explained below) - which is essentially just MIDI messages with timestamps attached to them. A MIDI message with a timestamp on it is known as a MIDI "event." Timestamps are specified in deltas, with each timestamp being the number of ticks since the last timestamp. These are encoded in a funny way in the file. It's a byproduct of the 1980s and the limited disk space and memory at the time, especially on hardware sequencers - every byte saved was important. The deltas are encoded using a "variable length quantity".
Variable length quantities are encoded as follows: They are 7 bits per byte, most significant bits first (little endian!). Each byte is high (greater than 0x7F) except the last one which must be less than 0x80. If the value is between 0 and 127, it is represented by one byte while if it was greater it would take more. Variable length quantities can in theory be any size, but in practice they must be no greater than 0xFFFFFFF - about 3.5 bytes. You can hold them with an int, but reading and writing them can be annoying.
What follows a variable length quantity delta is a MIDI message, which is at least one byte, but it will be different lengths depending on the type of message it is and some message types (meta messages and sysex messages) are variable length. It may be written without the status byte in which case the previous status byte is used. You can tell if a byte in the stream is a status byte because it will be greater than 0x7F (127) while all of the message payload will be bytes less than 0x80 (128). It's not as hard to read as it sounds. Basically for each message, you check if the byte you're on is high (> 0x7F/127) and if it is, that's your new running status byte, and the status byte for the message. If it's low, you simply consult the current status byte instead of setting it.
MIDI File Tracks
A MIDI type 1 file will usually contain multiple "tracks" (briefly mentioned above). A track usually represents a single score and multiple tracks together make up the entire performance. While this is usually laid out this way, it's actually channels, not tracks that indicate what score a particular device is to play. That is, all notes for channel 0 will be treated as part of the same score even if they are scattered throughout different tracks. Tracks are just a helpful way to organize. They don't really change the behavior of the MIDI at all. In a MIDI type 1 file - the most common type - track 0 is "special". It doesn't generally contain performance messages (channel messages). Instead, it typically contains meta information like the tempo and lyrics, while the rest of your tracks contain performance information. Laying your files out this way ensures maximum compatibility with MIDI devices out there.
Very important: A track must always end with the MIDI End of Track meta message.
Despite tracks being conceptually separate, the separation of scores is actually by channel under the covers, not by track, meaning you can have multiple tracks which when combined, represent the score for a device at a particular channel (or more than one channel). You can combine channels and tracks however you wish, just remember that all the channel messages for the same channel represent an actual score for a single device, while the tracks themselves are basically virtual/abstracted convenience items.
See this page for more information on the MIDI wire-protocol and the MIDI file format .
Coding this Mess
The sample projects contain more or less real world code which puts the library through its paces. Here, we'll cover some basics and then go over techniques.
Reading and writing a MIDI file to and from disk:
var file = MidiFile.ReadFrom("sample.mid");
file.WriteTo("sample.mid");
Modifying a single track in a file:
var track = file.Tracks[1];
track = track.NormalizeVelocities();
file.Tracks[1]=track;
Enumerating MIDI devices (including rich display):
Console.WriteLine("Output devices:");
Console.WriteLine();
foreach (var dev in MidiDevice.Outputs)
{
var kind = "";
switch (dev.Kind)
{
case MidiOutputDeviceKind.MidiPort:
kind = "MIDI Port";
break;
case MidiOutputDeviceKind.Synthesizer:
kind = "Synthesizer";
break;
case MidiOutputDeviceKind.SquareWaveSynthesizer:
kind = "Square wave synthesizer";
break;
case MidiOutputDeviceKind.FMSynthesizer:
kind = "FM synthesizer";
break;
case MidiOutputDeviceKind.WavetableSynthesizer:
kind = "Wavetable synthesizer";
break;
case MidiOutputDeviceKind.SoftwareSynthesizer:
kind = "Software synthesizer";
break;
case MidiOutputDeviceKind.MidiMapper:
kind = "MIDI Mapper";
break;
}
Console.WriteLine(dev.Name + " " + dev.Version + " " + kind);
}
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("Input devices:");
Console.WriteLine();
foreach (var dev in MidiDevice.Inputs)
{
Console.WriteLine(dev.Name + " " + dev.Version);
}
Opening a device and sending output:
using(var dev = MidiDevice.Outputs[0])
{
dev.Open();
dev.Send(new MidiMessageNoteOn("C5", 127, 0));
dev.Send(new MidiMessageNoteOn("E5", 127, 0));
dev.Send(new MidiMessageNoteOn("G5", 127, 0));
Console.Error.WriteLine("Press any key to exit...");
Console.ReadKey();
dev.Send(new MidiMessageNoteOff("C5", 127, 0));
dev.Send(new MidiMessageNoteOff("E5", 127, 0));
dev.Send(new MidiMessageNoteOff("G5", 127, 0));
}
Capturing input:
using(var dev = MidiDevice.Inputs[0])
{
Console.Error.WriteLine("Press any key to exit...");
dev.Input += delegate(object s,MidiInputEventArgs ea) {
Console.WriteLine(ea.Message);
};
dev.Open();
dev.Start();
Console.ReadKey();
}
Techniques With the API
Terminating Sequences/Tracks
Important: We'll start here, since this is critical. The API will usually automatically terminate sequences for you with the end of track marker when you use operations like Merge()
, Concat()
or GetRange()
, but if you build a sequence from scratch, you will need to insert it at the end manually. While this API will basically work without it, many, if not most MIDI applications will not, so writing a file without them is essentially akin to writing a corrupt file:
track.Events.Add(new MidiEvent(0,new MidiMessageMetaEndOfTrack()));
You should rarely have to do this, but again, you'll need to if you construct your sequences manually from scratch. Also, 0
will need to be adjusted to your own delta time to get the length of the track right.
Executing Sequence and File Transformations in Series
This is simple. Every time we do a transformation, it yields a new object so we replace the variable each time with the new result:
var track = file.Track[1];
track = track.NormalizeVelocities();
track = track.ScaleVelocities(.5);
track = track.Stretch(.5);
file.Track[1]=track;
The same basic idea works with MidiFile
instances, too.
Searching or Analyzing Multiple Tracks Together
Sometimes, you might need to search multiple tracks at once. While MidiFile
provides ways to do this for common searches across all tracks in a file, you might need to operate over a list of sequences or some other source. The solution is simple: Temporarily merge your target tracks into a new track and then operate on that. For example, say you want to find the first downbeat wherever it occurs in any of the target tracks:
var result = MidiSequence.Merge(trks).FirstDownBeat;
You can do manual searches by looping through events in the merged tracks to. This technique works for pretty much any situation. Merge()
is a versatile method and it is your friend.
Inserting Absolutely Timed Events
It's often a heck of a lot easier to specify events in absolute time. There are a couple of ways to do it. The first is to do it directly:
myTrack.AddAbsoluteEvent(absoluteTicks,msg);
The above directly inserts an event with the message specified at the indicated absolute position. However, a lot of times, you'll need to insert a MidiMessageMetaEndTrack
to an already existing track. The problem with using the above is that one of those end track messages is almost certainly already present unless you built it yourself. You'll need to remove it before adding your own. The following technique handles all of that, both inserting the new event and removing the old end track:
var newTrack = new MidiSequence();
newTrack.Events.Add(new MidiEvent(absoluteTicks, msg));
myTrack = MidiSequence.Merge(myTrack,newTrack);
First, we create a new sequence and add our absolutely timed message to it. Basically, since it's the only message, the delta is the number of ticks from zero which is the same as an absolute position. Finally, we take our current sequence and reassign it with the result of merging our current sequence with the sequence we just created. All operations return new instances. We don't modify existing instances, so we often find we are reassigning variables like this.
Creating a Note Map
An easier way to do the above, at least when dealing with notes, is to use FromNoteMap()
. Basically, you just queue up a list of absolutely positioned notes and then call FromNoteMap()
to get a sequence from it.
var noteMap = new List<MidiNote>();
noteMap.Add(new MidiNote(0,0,"C#5",127,240));
noteMap.Add(new MidiNote(960,0,"D#5",127,240));
var seq = MidiSequence.FromNoteMap(noteMap);
You can also get a note map from any sequence by calling ToNoteMap()
.
Looping
It can be much easier to specify our loops in beats (1/4 notes at 4/4 time), so we can multiply the number of beats we need by the MidiFile
's TimeBase
to get our beats, at least for 4/4. I won't cover other time signatures here as that's music theory, and beyond the scope. You'll have to deal with time signatures if you want this technique to be accurate. Anyway, it's also helpful to start looping at the FirstDownBeat
or the FirstNote
or at least an offset of beats from one of those locations. The difference between them is FirstDownBeat
hunts for a bass/kick drum while FirstNote
hunts for any note. Once we compute our offset and length, we can pass them to GetRange()
in order to get a MidiSequence
or MidiFile
with only the specified range, optionally copying the tempo, time signature, and patches from the beginning of the sequence.
var start = file.FirstDownBeat;
var offset = 16;
var length = 8;
offset *= file.TimeBase;
length *= file.TimeBase;
file = file.GetRange(start+offset,length,true);
Previewing/Playing Without Streaming
You can play any MidiSequence
or MidiFile
using Preview()
. This is synchronous unlike the streaming API but doesn't require the use of MidiStream
. Using it from the main application thread is almost never what you want, since it blocks. This is especially true when specifying the loop
argument because it will hang the calling thread indefinitely while it plays forever. What you actually want to do is spawn a thread and play it on the thread. Here's a simple technique to do just that by toggling whether it's playing or not any time this code runs:
if(null==_previewThread)
{
var f = file.Clone();
_previewThread = new Thread(() => f.Preview(0, true));
_previewThread.Start();
} else {
_previewThread.Abort();
_previewThread.Join();
_previewThread = null;
}
You can then call this code from the main thread to either start or stop playback of "file".
Previewing/Playing With Streaming (Simple)
The following is the easy way to stream a sequence for playback.
using (var stm = MidiDevice.Streams[0])
{
stm.Open();
var mf = MidiFile.ReadFrom(@"..\..\Feel_good_4beatsBass.mid");
var seq = MidiSequence.Merge(mf.Tracks);
stm.TimeBase = mf.TimeBase;
stm.Start();
Console.Error.WriteLine("Press any key to exit...");
stm.SendComplete += delegate (object s, EventArgs e)
{
stm.Send(seq.Events);
};
stm.Send(seq.Events);
Console.ReadKey();
}
Note that we're only hooking SendComplete
so we can loop the playback.
Previewing/Playing With Streaming (Complex)
The following technique allows more real-time control, but the drawback is that it's more complicated to use. This way, you can work on the stream in blocks.
var mf = MidiFile.ReadFrom(@"..\..\Bohemian-Rhapsody-1.mid");
const int EVENT_COUNT = 100;
int pos = 0;
var seq = MidiSequence.Merge(mf.Tracks);
int len = seq.Events.Count;
var eventList = new List<MidiEvent>(EVENT_COUNT);
using (var stm = MidiDevice.Streams[0])
{
stm.Open();
stm.Start();
stm.TimeBase = mf.TimeBase;
stm.SendComplete += delegate (object sender,EventArgs eargs)
{
eventList.Clear();
var next = pos+EVENT_COUNT;
for(;pos<next;++pos)
{
if (len <= pos)
{
pos = 0;
break;
}
eventList.Add(seq.Events[pos]);
}
stm.SendDirect(eventList);
};
for(pos = 0;pos<EVENT_COUNT;++pos)
{
if (len <= pos)
{
pos = 0;
break;
}
eventList.Add(seq.Events[pos]);
}
stm.SendDirect(eventList);
Console.Error.WriteLine("Press any key to exit...");
Console.ReadKey();
stm.Close();
}
What we're doing here is merging the file's tracks into a single sequence for playback. We open the stream, and then start it, and grab up to 100 (EVENT_COUNT
) events at a time and queue them using SendDirect()
instead of Send()
. The reason for that is the former does not buffer, although it is lower level and limited to 64kb worth of event memory. We're already buffering above so we don't need to. We've hooked SendComplete
so each time if fires we grab the next 100 and then send those to the queue. If we go past the end, we reset the position to zero in order to loop. We do this until a key is pressed.
Recording a Performance (Simple)
You can record a performance to a MidiFile
quite simply by using StartRecording()
and EndRecording()
. Basically, what you do is you Open()
the input device, optionally Start()
it - it will be started for you if need be - and call StartRecording()
passing a boolean value that indicates whether recording should commence immediately or wait for the first MIDI input. EndRecording()
should be called when the recording is complete. You can optionally trim the remainder to the last MIDI signal received. Otherwise, all of the remaining empty time will be at the end of the file. EndRecording()
returns a Type 1 MIDI file with two tracks. The first track contains the tempo map, but no performance data. The second track contains the performance data. If you want to pass the input through to the output so you can hear what you are recording you'll need to hook the Input
event and Send()
what you receive to an output device. This is shown below:
MidiFile mf;
using (var idev = MidiDevice.Inputs[0])
{
using (var odev = MidiDevice.Outputs[0])
{
idev.Input += delegate (object s, MidiInputEventArgs e)
{
odev.Send(e.Message);
};
idev.Open();
odev.Open();
idev.StartRecording(true);
Console.Error.WriteLine("Press any key to stop recording...");
Console.ReadKey();
mf = idev.EndRecording();
}
}
Recording a Performance (Complex)
Recording manually allows you to do processing on the input before it is recorded. It can be somewhat involved especially since tracking the MIDI tick position can be tricky. You can use a Stopwatch
for this but I prefer using the "precise time" API available in Windows 7 and beyond just to ensure there's no "drift" - see the scratch project for the Win32 P/Invoke declaration and helper property:
using (var idev = MidiDevice.Inputs[0])
{
short timeBase = 480;
var microTempo = MidiUtility.TempoToMicroTempo(120);
var tr0 = new MidiSequence();
var seq = new MidiSequence();
var ticksusec = microTempo / (double)timeBase;
var tickspertick = ticksusec / (TimeSpan.TicksPerMillisecond / 1000) * 100;
var pos = 0;
var startTicks = 0L;
using (var odev = MidiDevice.Outputs[0])
{
idev.Input += delegate (object s, MidiInputEventArgs ea)
{
if (0 == startTicks)
startTicks = _PreciseUtcNowTicks;
var midiTicks = (int)Math.Round((_PreciseUtcNowTicks - startTicks) / tickspertick);
odev.Send(ea.Message);
seq.Events.Add(new MidiEvent(midiTicks - pos, ea.Message));
pos = midiTicks;
};
idev.Open();
odev.Open();
tr0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo(microTempo)));
idev.Start();
Console.Error.WriteLine("Recording started.");
Console.Error.WriteLine("Press any key to stop recording...");
Console.ReadKey();
idev.Stop();
idev.Reset();
}
var endTrack = new MidiSequence();
var len = seq.Length;
len = unchecked((int)((_PreciseUtcNowTicks - startTicks) / tickspertick));
endTrack.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
tr0 = MidiSequence.Merge(tr0, endTrack);
seq = MidiSequence.Merge(seq, endTrack);
var mf = new MidiFile(1, timeBase);
mf.Tracks.Add(tr0);
mf.Tracks.Add(seq)
}
Here, the bulk of our work is in the setup and then the handling the Input
event. For the setup, we have to compute the timing in terms of exactly how long a midi tick is. We get this into tickspertick
which is the number of .NET "ticks" in a MIDI tick (or the reverse, I forget which now. It's confusing!). We then use this to track our position. We keep subtracting the old position from the current position to get a delta. Note that we're touching seq
from another thread. This is okay because of several conditions, including the fact that seq
is not touched outside of the delegate once Input
starts firing. Anyway, at the end, we make sure we terminate the tracks with an end track marker and we create a simple in-memory MIDI file. This should be able to be played to listen to what was just recorded and/or written to disk. Note that recording doesn't start until the first MIDI signal received, and the silent remainder of the recording is preserved. That can easily be changed by modifying the code.
Demo Projects
MidiSlicer
MidiSlicer (pictured at the top) allows you to perform several operations on a MIDI file, like extracting portions of the MIDI file, extracting certain tracks, changing the volume, transposing, and more. It is useful for operating on raw MIDI files you have sequenced.
The main mess of code that does the magic is here in Main.cs _ProcessFile()
:
var result = _file.Clone();
if(0!=TransposeUpDown.Value)
result = result.Transpose((sbyte)TransposeUpDown.Value,
WrapCheckBox.Checked,!DrumsCheckBox.Checked);
if (ResampleUpDown.Value != _file.TimeBase)
result = result.Resample(unchecked((short)ResampleUpDown.Value));
var ofs = OffsetUpDown.Value;
var len = LengthUpDown.Value;
if (0 == UnitsCombo.SelectedIndex)
{
len = Math.Min(len * _file.TimeBase, _file.Length);
ofs = Math.Min(ofs * _file.TimeBase, _file.Length);
}
switch (StartCombo.SelectedIndex)
{
case 1:
ofs += result.FirstDownBeat;
break;
case 2:
ofs += result.FirstNoteOn;
break;
}
var nseq = new MidiSequence();
if(0!=ofs && CopyTimingPatchCheckBox.Checked)
{
var mtrk = MidiSequence.Merge(result.Tracks);
var end = mtrk.FirstNoteOn;
if (0 == end)
end = mtrk.Length;
var ins = 0;
for (int ic = mtrk.Events.Count, i = 0; i < ic; ++i)
{
var ev = mtrk.Events[i];
if (ev.Position >= end)
break;
var m = ev.Message;
switch (m.Status)
{
case 0xFF:
var mm = m as MidiMessageMeta;
switch (mm.Data1)
{
case 0x51:
case 0x54:
if (0 == nseq.Events.Count)
nseq.Events.Add(new MidiEvent(0,ev.Message.Clone()));
else
nseq.Events.Insert(ins, new MidiEvent(0,ev.Message.Clone()));
++ins;
break;
}
break;
default:
if (0xC0 == (ev.Message.Status & 0xF0))
{
if (0 == nseq.Events.Count)
nseq.Events.Add(new MidiEvent(0, ev.Message.Clone()));
else
nseq.Events.Insert(ins, new MidiEvent(0, ev.Message.Clone()));
++ins;
}
break;
}
}
nseq.Events.Add(new MidiEvent((int)len, new MidiMessageMetaEndOfTrack()));
}
var hasTrack0 = TrackList.GetItemChecked(0);
if (0!=ofs || result.Length!=len)
result = result.GetRange((int)ofs, (int)len,CopyTimingPatchCheckBox.Checked,false);
if (NormalizeCheckBox.Checked)
result = result.NormalizeVelocities();
if (1m != LevelsUpDown.Value)
result = result.ScaleVelocities((double)LevelsUpDown.Value);
var l = new List<MidiSequence>(result.Tracks);
result.Tracks.Clear();
for(int ic=l.Count,i=0;i<ic;++i)
{
if(TrackList.GetItemChecked(i))
{
result.Tracks.Add(l[i]);
}
}
if (0 < nseq.Events.Count)
{
if(!hasTrack0)
result.Tracks.Insert(0,nseq);
else
{
result.Tracks[0] = MidiSequence.Merge(nseq, result.Tracks[0]);
}
}
if (1m != StretchUpDown.Value)
result = result.Stretch((double)StretchUpDown.Value, AdjustTempoCheckBox.Checked);
if (MergeTracksCheckBox.Checked)
{
var trk = MidiSequence.Merge(result.Tracks);
result.Tracks.Clear();
result.Tracks.Add(trk);
}
return result;
You can see this is pretty involved, simply because there are so many options. It really runs MidiSequence
through its paces, using many of the techniques outlined earlier.
FourByFour
FourByFour is a simple drum machine step sequencer that can create MIDI files.
There's a beat control we won't cover here, but here's the main magic in Main.cs _CreateMidiFile()
:
var file = new MidiFile();
var track0 = new MidiSequence();
track0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo((double)TempoUpDown.Value)));
var len = ((int)BarsUpDown.Value) * 4 * file.TimeBase;
track0.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
var trackEnd = new MidiSequence();
trackEnd.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
file.Tracks.Add(track0);
var track1 = new MidiSequence();
var trks = new List<MidiSequence>(BeatsPanel.Controls.Count);
foreach (var ctl in BeatsPanel.Controls)
{
var beat = ctl as BeatControl;
var note = beat.NoteId;
var noteMap = new List<MidiNote>();
for (int ic = beat.Steps.Count, i = 0; i < ic; ++i)
{
if (beat.Steps[i])
noteMap.Add(new MidiNote(i * (file.TimeBase / 4), 9,
note, 127, file.TimeBase / 4-1));
}
trks.Add(MidiSequence.FromNoteMap(noteMap));
}
var t = MidiSequence.Merge(trks);
track1 = MidiSequence.Merge(track1, t, trackEnd);
file.Tracks.Add(track1);
return file;
Basically, all we're doing here is using note maps to create our drum sequence, and then setting the track length and program data using the technique outlined earlier.
MidiMonitor
The MIDI monitor simply monitors a MidiInputDevice
for incoming MIDI messages and displays them. It's very very simple. Here's the meat of it in Main.cs:
private void InputsComboBox_SelectedIndexChanged(object sender, EventArgs e)
{
if (null != _device)
_device.Close();
_device = InputsComboBox.SelectedItem as MidiInputDevice;
_device.Input +=device_Input;
_device.Open();
_device.Start();
}
private void device_Input(object sender, MidiInputEventArgs args)
{
try
{
Invoke(new Action(delegate ()
{
MessagesTextBox.AppendText(
args.Message.ToString() +
Environment.NewLine);
}));
}
catch
{
}
}
All we're doing here is capturing the incoming messages as shown earlier, and then appending it to text box. The gotchas here are since we're firing from another thread, we need to use the control's Invoke()
method to marshal the code onto the main thread for execution. We also wrap it in a try
/catch
just in case somehow we get a message while shutting down, but I'm not sure this is necessary.
taptempo
Taptempo demonstrates manual (as opposed to automatic) tempo synchronization functionality. The manual syncing is more accurate than using MidiStream.
UseTempoSynchronization=true
because it doesn't have to rely on a timer. Instead, it spins a tight loop and uses that to do the timing. Unfortunately, there is no equivalent for receiving tempo sync messages in a timely way - we must rely on callbacks so the timing isn't perfect on the receive end.
scratch
Scratch simply demonstrates some of the techniques already outlined above, so it's not worth covering here. It's basically just a playground for testing code.
The CPP project that accompanies it is just a testbed for calling the API from C++ to make sure I was doing it right, but I'm not using it right now.
Bugs
First, tempo-sychronization isn't very accurate, which is why it's experimental at this time. The limitation may be insurmountable.
Second, not all real-time messages are respected yet. The only synching capability is tempo.
History
- 28th June, 2020 - Initial submission
- 2nd July, 2020 - Two codebase updates, listed at the top
- 3rd July, 2020 - Stability fix, API improvement
- 5th July, 2020 - refactored MidiStream to derive from MidiOutputDevice
- 6th July, 2020 - Fixed MidiSequence.ToNoteMap() and added some UI controls in the MidiUI project