Click here to Skip to main content
15,908,776 members
Articles / Desktop Programming / Win32

Midi: A Windows MIDI Library in C#

Rate me:
Please Sign up or sign in to vote.
5.00/5 (17 votes)
6 Jul 2020MIT33 min read 77.2K   1.2K   27   72
Provides a complete managed API for working with MIDI files, sequences and devices
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.

MIDI Slicer


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.


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");

// code here modifying file...


Modifying a single track in a file:

// get the 2nd track of the MIDI file
var track = file.Tracks[1];

// normalize - remember all 
// modifications create a copy
track = track.NormalizeVelocities();

// reassign the modified track

Enumerating MIDI devices (including rich display):

Console.WriteLine("Output devices:");
foreach (var dev in MidiDevice.Outputs)
    var kind = "";
    switch (dev.Kind)
        case MidiOutputDeviceKind.MidiPort:
            kind = "MIDI Port";
        case MidiOutputDeviceKind.Synthesizer:
            kind = "Synthesizer";
        case MidiOutputDeviceKind.SquareWaveSynthesizer:
            kind = "Square wave synthesizer";
        case MidiOutputDeviceKind.FMSynthesizer:
            kind = "FM synthesizer";
        case MidiOutputDeviceKind.WavetableSynthesizer:
            kind = "Wavetable synthesizer";
        case MidiOutputDeviceKind.SoftwareSynthesizer:
            kind = "Software synthesizer";
        case MidiOutputDeviceKind.MidiMapper:
            kind = "MIDI Mapper";
    Console.WriteLine(dev.Name + " " + dev.Version + " " + kind);
Console.WriteLine("Input devices:");
foreach (var dev in MidiDevice.Inputs)
    Console.WriteLine(dev.Name + " " + dev.Version);

Opening a device and sending output:

// just grab the first output device
using(var dev = MidiDevice.Outputs[0])
    // open the device

    // send a C5 major chord
    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...");
    // note offs
    dev.Send(new MidiMessageNoteOff("C5", 127, 0));
    dev.Send(new MidiMessageNoteOff("E5", 127, 0));
    dev.Send(new MidiMessageNoteOff("G5", 127, 0));

Capturing input:

// just grab the first input device
using(var dev = MidiDevice.Inputs[0])
    Console.Error.WriteLine("Press any key to exit...");
    // hook the input
    dev.Input += delegate(object s,MidiInputEventArgs ea) {
    // open the device
    // start capturing
    // wait for keypress

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:

// assume file (variable) is our MidiFile
// modify track #2 (index 1)
var track = file.Track[1];
track = track.NormalizeVelocities();
track = track.ScaleVelocities(.5);
track = track.Stretch(.5);
// reassign our 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:

// assume IList<MidiSequence> trks is declared
// and contains the list of tracks to work on
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 represents the already existing sequence 
// we want to insert an absolutely timed event into 
// while absoluteTicks specifies the position at 
// which to insert the message, and msg contains 
// the MidiMessage to insert 

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:

// myTrack represents the already existing sequence 
// we want to insert an absolutely timed event into
// while absoluteTicks specifies the position at
// which to insert the message, and msg contains
// the MidiMessage to insert

// create a new MidiSequence and add our absolutely
// timed event as the single event in this sequence
var newTrack = new MidiSequence();
newTrack.Events.Add(new MidiEvent(absoluteTicks, msg));
// now reassign myTrack with the result of merging
// it with newTrack:
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>();
// add a C#5 note at position zero, channel 0, 
// velocity 127, length 1/8 note @ 480 timebase
noteMap.Add(new MidiNote(0,0,"C#5",127,240));
// add a D#5 note at position 960 (1/2 note in), channel 0,
// velocity 127, length 1/8 note @ 480 timebase
noteMap.Add(new MidiNote(960,0,"D#5",127,240));
// now get a MidiSequence
var seq = MidiSequence.FromNoteMap(noteMap);

You can also get a note map from any sequence by calling ToNoteMap().


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.

// assume file holds a MidiFile we're working with
var start = file.FirstDownBeat;
var offset = 16; // 16 beats from start @ 4/4
var length = 8;  // copy 8 beats from start
// convert beats to ticks
offset *= file.TimeBase;
length *= file.TimeBase;
// get the range from the file, copying timing
// and patch info from the start of each track
file = file.GetRange(start+offset,length,true);
// file now contains an 8 beat loop

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:

// assume a member field is declared: 
// Thread _previewThread and file
// contains a MidiFile instance
// to play. 
    // create a clone of file for
    // thread safety. not necessary
    // if "file" is never touched
    // again
    var f = file.Clone();
    _previewThread = new Thread(() => f.Preview(0, true));
} else {
    // kill the thread
    // wait for it to exit
    // update our _previewThread
    _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.

// just grab the first output stream
using (var stm = MidiDevice.Streams[0])
    // open it
    // read a MIDI file
    var mf = MidiFile.ReadFrom(@"..\..\Feel_good_4beatsBass.mid");
    // merge the tracks for playback
    var seq = MidiSequence.Merge(mf.Tracks);
    // set the stream timebase
    stm.TimeBase = mf.TimeBase;
    // start the playback
    Console.Error.WriteLine("Press any key to exit...");
    // if we weren't looping
    // we wouldn't need to
    // hook this:
    stm.SendComplete += delegate (object s, EventArgs e)
        // loop
    // kick things off
    // wait for exit

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.

// demonstrate streaming a midi file 100 events at a time
// this allows you to handle files with more than 64kb
// 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

    // start it

    // first set the timebase
    stm.TimeBase = mf.TimeBase;
    // set up our send complete handler
    stm.SendComplete += delegate (object sender,EventArgs eargs)
        // clear the list    

        // iterate through the next events
        var next = pos+EVENT_COUNT;
            // if it's past the end, loop it
            if (len <= pos)
                pos = 0;
            // otherwise add the next event
        // send the list of events
    // add the first events
    for(pos = 0;pos<EVENT_COUNT;++pos)
        // if it's past the end, loop it
        if (len <= pos)
            pos = 0;
        // otherwise add the next event
    // send the list of events
    // loop until a key is pressed
    Console.Error.WriteLine("Press any key to exit...");

    // close the stream

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)
            // this is so we can pass through and hear 
            // our input while recording
        // open the input
        // and output
        // start recording, waiting for input
        // wait to end it
        Console.Error.WriteLine("Press any key to stop recording...");
        // get our MidiFile from this
        mf = idev.EndRecording();
        // the MIDI file is always two
        // tracks, with the first track
        // being the tempo map

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])
    // TODO: currently this doesn't let you
    // change the tempo in the middle of recording

    // match these two variables to your input rate
    short timeBase = 480;
    var microTempo = MidiUtility.TempoToMicroTempo(120);

    // track 0 - meta track for tempo info
    var tr0 = new MidiSequence();
    // our seq for recording
    var seq = new MidiSequence();
    // compute our timing based on current microTempo and timeBase
    var ticksusec = microTempo / (double)timeBase;
    var tickspertick = ticksusec / (TimeSpan.TicksPerMillisecond / 1000) * 100;
    var pos = 0;
    // set this to _PreciseUtcNowTicks in order
    // to start recording now. Otherwise it will
    // not record until the first message is 
    // received:
    var startTicks = 0L;

    using (var odev = MidiDevice.Outputs[0])
        // hook up the delegate
        idev.Input += delegate (object s, MidiInputEventArgs ea)
            // initialize start ticks with the current time in ticks
            if (0 == startTicks)
                startTicks = _PreciseUtcNowTicks;
            // compute our current MIDI ticks
            var midiTicks = (int)Math.Round((_PreciseUtcNowTicks - startTicks) / tickspertick);
            // pass through to play
            // HACK: technically the sequence isn't threadsafe but as long as this event
            // is not reentrant and the MidiSequence isn't touched outside this it should
            // be fine
            seq.Events.Add(new MidiEvent(midiTicks - pos, ea.Message));
            // this is to track our old position
            // so we can compute deltas
            pos = midiTicks;
        // open the input device
        // open the output device
        // add our tempo to the beginning of track 0
        tr0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo(microTempo)));
        // start listening
        Console.Error.WriteLine("Recording started.");
        // wait
        Console.Error.WriteLine("Press any key to stop recording...");
        // stop the buffer and flush any pending events
    // create termination track
    var endTrack = new MidiSequence();
    var len = seq.Length;
    // comment the following to terminate 
    // without the trailing empty score:
    len = unchecked((int)((_PreciseUtcNowTicks - startTicks) / tickspertick));
    endTrack.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
    // terminate the tracks
    tr0 = MidiSequence.Merge(tr0, endTrack);
    seq = MidiSequence.Merge(seq, endTrack);
    // build a type 1 midi file
    var mf = new MidiFile(1, timeBase);
    // add both tracks

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 (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():

// first we clone the file to be safe
// that way in case there's no modifications
// specified in the UI we'll still return 
// a copy.
var result = _file.Clone();

// transpose it if specified
    result = result.Transpose((sbyte)TransposeUpDown.Value, 

// resample if specified
if (ResampleUpDown.Value != _file.TimeBase)
    result = result.Resample(unchecked((short)ResampleUpDown.Value));

// compute our offset and length in ticks or beats/quarter-notes
var ofs = OffsetUpDown.Value;
var len = LengthUpDown.Value;
if (0 == UnitsCombo.SelectedIndex) // beats
    len = Math.Min(len * _file.TimeBase, _file.Length);
    ofs = Math.Min(ofs * _file.TimeBase, _file.Length);
switch (StartCombo.SelectedIndex)
    case 1:
        ofs += result.FirstDownBeat;
    case 2:
        ofs += result.FirstNoteOn;

// nseq holds our patch and timing info
var nseq = new MidiSequence();
if(0!=ofs && CopyTimingPatchCheckBox.Checked)
    // we only want to scan until the
    // first note on
    // we need to check all tracks so
    // we merge them into mtrk and scan
    // that
    var mtrk = MidiSequence.Merge(result.Tracks);
    var end = mtrk.FirstNoteOn;
    if (0 == end) // break later:
        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)
        var m = ev.Message;
        switch (m.Status)
            // the reason we don't check for MidiMessageMetaTempo
            // is a user might have specified MidiMessageMeta for
            // it instead. we want to handle both
            case 0xFF:
                var mm = m as MidiMessageMeta;
                switch (mm.Data1)
                    case 0x51: // tempo
                    case 0x54: // smpte
                        if (0 == nseq.Events.Count)
                            nseq.Events.Add(new MidiEvent(0,ev.Message.Clone()));
                            nseq.Events.Insert(ins, new MidiEvent(0,ev.Message.Clone()));
                // check if it's a patch change
                if (0xC0 == (ev.Message.Status & 0xF0))
                    if (0 == nseq.Events.Count)
                        nseq.Events.Add(new MidiEvent(0, ev.Message.Clone()));
                        nseq.Events.Insert(ins, new MidiEvent(0, ev.Message.Clone()));
                    // increment the insert count
    // set the track to the loop length
    nseq.Events.Add(new MidiEvent((int)len, new MidiMessageMetaEndOfTrack()));
// see if track 0 is checked
var hasTrack0 = TrackList.GetItemChecked(0);

// slice our loop out of it
if (0!=ofs || result.Length!=len)
    result = result.GetRange((int)ofs, (int)len,CopyTimingPatchCheckBox.Checked,false);

// normalize it!
if (NormalizeCheckBox.Checked)
    result = result.NormalizeVelocities();

// scale levels
if (1m != LevelsUpDown.Value)
    result = result.ScaleVelocities((double)LevelsUpDown.Value);

// create a temporary copy of our
// track list
var l = new List<MidiSequence>(result.Tracks);

// now clear the result
for(int ic=l.Count,i=0;i<ic;++i)
    // if the track is checked in the list
    // add it back to result
if (0 < nseq.Events.Count)
    // if we don't have track zero we insert
    // one.
        // otherwise we merge with track 0
        result.Tracks[0] = MidiSequence.Merge(nseq, result.Tracks[0]);                    
// stretch the result. we do this
// here so the track lengths are
// correct and we don't need ofs
// or len anymore
if (1m != StretchUpDown.Value)
    result = result.Stretch((double)StretchUpDown.Value, AdjustTempoCheckBox.Checked);

// if merge is checked merge the
// tracks
if (MergeTracksCheckBox.Checked)
    var trk = MidiSequence.Merge(result.Tracks);
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 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();
// we'll need a track 0 for our tempo map
var track0 = new MidiSequence();
// set the tempo at the first position
track0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo((double)TempoUpDown.Value)));
// compute the length of our loop
var len = ((int)BarsUpDown.Value) * 4 * file.TimeBase;
// add an end of track marker just so all
// of our tracks will be the loop length
track0.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
// here we need a track end with an 
// absolute position for the MIDI end
// of track meta message. We'll use this
// later to set the length of the track
var trackEnd = new MidiSequence();
trackEnd.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
// add track 0 (our tempo map)

// create track 1 (our drum track)
var track1 = new MidiSequence();
// we're going to create a new sequence for
// each one of the drum sequencer tracks in
// the UI
var trks = new List<MidiSequence>(BeatsPanel.Controls.Count);
foreach (var ctl in BeatsPanel.Controls)
    var beat = ctl as BeatControl;
    // get the note for the drum
    var note = beat.NoteId;
    // it's easier to use a note map
    // to build the drum sequence
    var noteMap = new List<MidiNote>();
    for (int ic = beat.Steps.Count, i = 0; i < ic; ++i)
        // if the step is pressed create 
        // a note for it
        if (beat.Steps[i])
            noteMap.Add(new MidiNote(i * (file.TimeBase / 4), 9, 
                        note, 127, file.TimeBase / 4-1));
    // convert the note map to a sequence
    // and add it to our working tracks
// now we merge the sequences into one
var t = MidiSequence.Merge(trks);
// we merge everything down to track 1
track1 = MidiSequence.Merge(track1, t, trackEnd);
// .. and add it to the file
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.


MIDI Monitor

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 = InputsComboBox.SelectedItem as MidiInputDevice;
    _device.Input +=device_Input;

private void device_Input(object sender, MidiInputEventArgs args)
        Invoke(new Action(delegate () 
                args.Message.ToString() + 

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 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 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.


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.


  • 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


This article, along with any associated source code and files, is licensed under The MIT License

Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

Questionunmanaged exception error on the line "InputsComboBox.SelectedIndex = 0;" in the main.cs file. Pin
Member 1607506320-Aug-23 12:21
Member 1607506320-Aug-23 12:21 
AnswerRe: unmanaged exception error on the line "InputsComboBox.SelectedIndex = 0;" in the main.cs file. Pin
honey the codewitch20-Aug-23 15:09
mvahoney the codewitch20-Aug-23 15:09 
GeneralRe: unmanaged exception error on the line "InputsComboBox.SelectedIndex = 0;" in the main.cs file. Pin
Member 1607506321-Aug-23 11:43
Member 1607506321-Aug-23 11:43 
GeneralRe: unmanaged exception error on the line "InputsComboBox.SelectedIndex = 0;" in the main.cs file. Pin
honey the codewitch21-Aug-23 12:18
mvahoney the codewitch21-Aug-23 12:18 
GeneralRe: unmanaged exception error on the line "InputsComboBox.SelectedIndex = 0;" in the main.cs file. Pin
Member 1607506321-Aug-23 21:02
Member 1607506321-Aug-23 21:02 
QuestionPitch shift bytes swapped Pin
teuncb22-Mar-23 1:20
teuncb22-Mar-23 1:20 
AnswerRe: Pitch shift bytes swapped Pin
honey the codewitch22-Mar-23 5:54
mvahoney the codewitch22-Mar-23 5:54 
AnswerRe: Pitch shift bytes swapped Pin
honey the codewitch22-Mar-23 6:01
mvahoney the codewitch22-Mar-23 6:01 
QuestionMIDILIB in MidiSlicer Pin
D^Handy4-Feb-23 14:28
D^Handy4-Feb-23 14:28 
AnswerRe: MIDILIB in MidiSlicer Pin
honey the codewitch4-Feb-23 16:39
mvahoney the codewitch4-Feb-23 16:39 
GeneralRe: MIDILIB in MidiSlicer Pin
D^Handy5-Feb-23 6:15
D^Handy5-Feb-23 6:15 
GeneralRe: MIDILIB in MidiSlicer Pin
honey the codewitch5-Feb-23 9:02
mvahoney the codewitch5-Feb-23 9:02 
GeneralRe: MIDILIB in MidiSlicer Pin
D^Handy5-Feb-23 10:01
D^Handy5-Feb-23 10:01 
Questionwhat does piano boc does Pin
Member 1525751221-Jun-21 23:53
Member 1525751221-Jun-21 23:53 
AnswerRe: what does piano boc does Pin
honey the codewitch22-Jun-21 0:20
mvahoney the codewitch22-Jun-21 0:20 
QuestionBluetooth LE Midi Pin
jhoward3331-May-21 17:42
jhoward3331-May-21 17:42 
AnswerRe: Bluetooth LE Midi Pin
honey the codewitch31-May-21 17:46
mvahoney the codewitch31-May-21 17:46 
GeneralRe: Bluetooth LE Midi Pin
jhoward331-Jun-21 15:45
jhoward331-Jun-21 15:45 
GeneralRe: Bluetooth LE Midi Pin
honey the codewitch1-Jun-21 16:10
mvahoney the codewitch1-Jun-21 16:10 
GeneralRe: Bluetooth LE Midi Pin
jhoward333-Jun-21 17:32
jhoward333-Jun-21 17:32 
GeneralRe: Bluetooth LE Midi Pin
honey the codewitch3-Jun-21 18:10
mvahoney the codewitch3-Jun-21 18:10 
QuestionSystem Exclusive Identity test Pin
Adrian M Croft12-Feb-21 11:00
Adrian M Croft12-Feb-21 11:00 
AnswerRe: System Exclusive Identity test Pin
honey the codewitch13-Feb-21 12:32
mvahoney the codewitch13-Feb-21 12:32 
GeneralMy vote of 5 Pin
kjt2code4-Jan-21 23:03
kjt2code4-Jan-21 23:03 
GeneralRe: My vote of 5 Pin
honey the codewitch4-Jan-21 23:29
mvahoney the codewitch4-Jan-21 23:29 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.