Veni, MIDI, Vici: Generating a simple MIDI file using VB, part 1 (Matt Gertz)

Veni, MIDI, Vici: Generating a simple MIDI file using VB, part 1 (Matt Gertz)

  • Comments 4

As I’ve alluded to in previous blogs, music has always been a big part of my life, particularly performance music.  I’ve been a clarinet and saxophone player for many years, am an avid singer, and (with the help of friends) I’ve done my best to teach myself piano and drums. 

Composition of music, however, has always been a problem for me.  I’ve written a few small pieces, but any attempt to write something larger has always ended up with me giving up after fifty or so measures.  Programs to help a person create their own compositions have certainly come a long way, but they all have their idiosyncrasies.  For example, one aspect of this that I’ve never been able to wrap my head around are MIDI channels and instruments – invariably, I always get these set wrong as my brain seems incapable of grokking the nuances of those areas.  I’m still waiting for a low-cost composition program in which I can say “Piano plays this, clarinet plays this, and bass plays that,” and ignore the details of channels and whatnot.

It was simpler (though far less powerful!) when I had a Commodore 128.  This was a gift I’d received for graduation, a fun machine that had three different operating systems (C64, C128, and CP/M) built into it.  In the C128-mode, the version of BASIC included in the OS had a “Play” command which took a string of characters representing note pitches and durations.  There were three voices which you could set to different instruments (well, envelopes, really), and the Play() call was asynchronous, so you could make chords by coding three Play() commands in a row.  In practice, there was still a certain lag between the voices – it was not a speedy machine – but it was still a lot of fun to set up music in it.

I’ve decided that I’d enjoy the composition process more, given the tools that I have, if I understood how MIDI actually worked.  After searching around on the Internet, I found a good article at Skytopia (http://www.skytopia.com/project/articles/midi.html) which listed out the basics of the MIDI format and also provided “for further information” links.  Given that, I set out to create a MIDI-generation program which would mimic my C-128 experience, as well as give me some grounding in the MIDI format.

Caveat:  I’m sure many readers will be far more knowledgeable about MIDI than I am, and will justifiably roll their eyes at this code due to its limited scope.  I will only be generating simple type-1 MIDI files that only use the Note On and Note Off events, and which don’t use metadata, targeting a “Save” scenario only.  The idea of this exercise was to understand MIDI and how to code against it in VB, not to generate a fully-functional music generation program.  Coding up “Load” and supporting more of the MIDI functionality would be a more time-consuming process which maybe I’ll get to one day.

I started out by creating a Windows Application called VBMidi.  But before I coded up the form, I took some time to craft out some support code to help me, as covered in the next section.

The gist of MIDI

MIDI files are composed of a well-defined header containing general information about the music, followed by a series of tracks containing actual notes, durations, and volumes for each track.  To capture this, I added a new class to my application called MIDI.  (Right-click the project, choose “Add,” and then choose “Class…”)

In this class, I set up some of the boilerplate information:

Public Class MIDI

    ' These are fixed data:

    Dim MIDIHeader() As Byte = {&H4D, &H54, &H68, &H64, &H0, &H0, &H0, &H6}

    Dim SubFormatType() As Byte = {&H0, &H1} ' Type-1 MIDI file (as opposed to Type-0)

 

MIDIHeader is always the first thing in the MIDI file, and just identifies this as a MIDI file.  SubFormatType, on the other hand, is a two-byte field which, in my case, contains the bytes to identify this as a type-1 MIDI file (i.e., supporting multiple tracks, unlike type-0).  Note that I’m using hexadecimal to define the bytes.  When working with byte information, it’s traditional to use hex instead of decimal, as it’s a lot easier to see what will happen when bytes are concatenated or split apart into “nibbles” (groups of 4 bits).

The speed of the music is something I’ll also want to track at a high level, and I define that here:

    Const ticksPerBeat = &H80

    ' These could be changed (in theory) by the program

    Dim Speed() As Byte = {&H0, ticksPerBeat } ' Default to 128 ticks per beat

 

Speed can support two bytes, but as I’m defaulting to 128 (= &H80) ticks per beat, the first byte is 0.

All of the other data is owned by the tracks.  I opted to create a nested class called “Track” to hold it.  One of the first things I did in Track was to define the events that could exist:

    Public Class Track

 

        Public Enum NoteEvent

            NoteOff = &H8

            NoteOn = &H9

 

            ' Advanced

            AfterTouch = &HA

            ControlChange = &HB

            ProgramChange = &HC

            ChannelPressure = &HD

            PitchWheel = &HE

        End Enum

In this example, I’ll only be using the first two events – turning a note on, and turning it off.  Each Track also has header and exit information:

        ' These are fixed data

        Dim TrackHeader() As Byte = {&H4D, &H54, &H72, &H6B}

        Dim TrackOut() As Byte = {&H0, &HFF, &H2F, &H0}

 

And then there’s all of the stuff in-between, such as the actual note data (stored in bytes) and metadata (also stored in bytes).  I’ll be defining a container for metadata for the sake of completeness, even though I won’t be using it in this example:

        ' These can be changed by the program

        Public TrackData As New List(Of Byte)

        Dim TrackMetadata As New List(Of Byte)

 

Music can be played on one of 16 channels (&H0 through &HF).  I’ve opted to create a “one channel per track” rule in my code, although this isn’t a requirement in the MIDI format.  I’ve used channel = -1 to indicate that the track isn’t being used (even using a signed byte, I still have space for this, since the channel information only takes half of a byte), and I created a function to tell me if that’s the case:

        Public Channel As SByte = -1

        Public Function ValidTrack() As Boolean

            Return Channel >= 0

        End Function

 

Now, I need a way to actually add a note to the track.  That function looks like this:

        Public Sub AddNoteOnOffEvent(ByVal beatOffset As Double, ByVal ev As NoteEvent, _

ByVal note As Byte, ByVal volume As Byte)

 

This method requires a bit of explanation. First, let’s cover the arguments passed in: 

·         The caller will pass in the note’s beat offset (the difference between this event and the previous one)  via a Double -- so, for example, assuming we’re in 4/4 time with a quarter note taking one beat, an eighth-note offset would be passed in as 0.5. 

·         The NoteEvent determines whether I’m turning on or off a note (there are other event possibilities, but as I’ve mentioned before, I’m going to ignore them for this application). 

·         The Note is just an index into the list of possible tones (for example, middle C is a 60 = &H3C).

·          Finally, volume is just a value between 0 and 127.

 Now, into the code for the function. If the track currently has no channel set for it, then I don’t bother adding the note and instead just return, since the note information has to be combined with the channel information. 

            If Not ValidTrack() Then Return

 

The next thing I do in that code is translate the note’s beatOffset to something MIDI can understand.  The duration is with respect to the ticks per beat (defined earlier in the constant called rate as being 128 ticks per beat), so I multiply the two numbers together to get the number of ticks, casting it as an unsigned integer into something called tickOffset.

            Dim tickOffset As UInt32 = CType(beatOffset * rate, UInt32)

 

Now I can actually start adding bytes to the TrackData.  Assuming that the NoteEvent is either an On or Off, I’ll first add the tickOffset into the data.  However, I can’t just blast in the value of tickOffset.  MIDI requires a special format for durations which is a little screwy.  Instead of saying “the duration data lasts this many bytes, and here they are,” they instead require a format where the final byte in the duration sequence is less than 128 (i.e., less than &H80).  So, 127 ticks would be formatted as &H7F, but 128 ticks would be formatted as &H80 &H00, and this increases up to maximum possible value of &HFF &HFF &HFF &H7F, representing 268435455 ticks.  I have a helper function to create this formatting for me, which I found at http://jedi.ks.uiuc.edu/~johns/links/music/midifile.htm and which I translated to VB:

 

        Private Function TranslateTickTime(ByVal ticks As UInt32) As Byte()

            Dim value As UInt32 = ticks

            Dim buffer As UInt32

            buffer = ticks And &H7F

            value = value >> 7

            While value > 0

                buffer = buffer << 8

                buffer = buffer Or ((value And &H7F) Or &H80)

                value = value >> 7

            End While

 

            ' The encoded values are now in the buffer backwards, so retrieve them...

            Dim blist As New List(Of Byte)

            While True

                blist.Add(CByte(&HFF And buffer))

                If (buffer And &H80) > 0 Then

                    buffer = buffer >> 8

                Else

                    Exit While

                End If

            End While

 

            Return blist.ToArray

        End Function

 

I’m not going to get too deep into that function; suffice to say that I step through each byte in the ticks variable, retrieve up to &H7F bytes worth, increment the adjacent byte to a base of &H80 if it goes over, and continue through the ticks in that way.  When I’m done, the bytes will be in reverse order in a buffer, so I pop bytes off that buffer and into a list of bytes, from which I generate a byte array to pass back.  Back in the AddNoteOnOffEvent method, I then call it thusly:

 

            If ev = NoteEvent.NoteOn OrElse ev = NoteEvent.NoteOff Then

                TrackData.AddRange(TranslateTickTime(tickOffset))

 

which will add all of the bytes in the array that I returned into the TrackData.  The next byte that needs to go in contains both the event and the channel – each of those takes up one nibble in the byte, so I’ll shift the event four places to the left and “or” the result with the channel.  Since Channel is currently a signed byte and I need it to be a nibble, I need to explicitly cast it to Byte.  (I also “and” it with &HF to eliminate the upper nibble in the resulting channel byte, though that really isn’t required since the upper nibble should already be zero.)

                TrackData.Add((ev << 4) Or (CByte(Channel) And &HF))

 

Note and Volume are, thankfully, already in the correct format (byte), so I just add them directly.

                TrackData.Add(note)

                TrackData.Add(volume)

            End If

        End Sub

TrackData now contains the music, and I now create a Save() function for the track object to write it out to the MIDI file.  This is pretty straightforward except for TrackSize.  Since that value is an integer and I don’t necessarily know what order its bytes will be persisted in (it varies based on chip architecture), I’m explicitly saving it out byte-by-byte, using shifting and casting to make it work correctly:

        Public Sub Save(ByVal filepath As String)

            If ValidTrack() Then

                My.Computer.FileSystem.WriteAllBytes(filepath, TrackHeader, True)

 

                Dim TrackSize As UInt32 = TrackData.Count() + TrackMetadata.Count() + TrackOut.Count()

                Dim byteTrackSize(0 To 3) As Byte

                byteTrackSize(0) = CByte((TrackSize >> 24) And &HFF)

                byteTrackSize(1) = CByte((TrackSize >> 16) And &HFF)

                byteTrackSize(2) = CByte((TrackSize >> 8) And &HFF)

                byteTrackSize(3) = CByte((TrackSize And &HFF))

                My.Computer.FileSystem.WriteAllBytes(filepath, byteTrackSize, True)

                My.Computer.FileSystem.WriteAllBytes(filepath, TrackData.ToArray, True)

                My.Computer.FileSystem.WriteAllBytes(filepath, TrackMetadata.ToArray, True)

                My.Computer.FileSystem.WriteAllBytes(filepath, TrackOut, True)

            End If

        End Sub

 

That’s it for the Track subclass.  Back in the MIDI class, I can add code to set up the tracks:

    Public Tracks As New List(Of Track)

    Public Function AddTrack() As Track

        Dim t As New Track

        Tracks.Add(t)

        Return t

    End Function

 

And then a Save() method to save the MIDI header information and all of the tracks.  Again, this is pretty straightforward.  I save out the header and format information to the file, determine how many tracks are valid and persist that information out to the file in the appropriate byte-order, save out the speed data for the MIDI file as well, and then iterate through each track and let each one save its data (the track will handle the case where’s it’s invalid).  I’ve wrapped the whole thing up in a try-catch structure in case there’s a problem accessing the file at any time.

    Public Sub Save(ByVal filepath As String)

        Try

            My.Computer.FileSystem.WriteAllBytes(filepath, MIDIHeader, False)

            My.Computer.FileSystem.WriteAllBytes(filepath, SubFormatType, True)

 

            Dim numTracks As UShort = 0

            For Each t In Tracks

                If t.ValidTrack Then

                    numTracks += 1

                End If

            Next

            Dim byteTracks(0 To 1) As Byte

            byteTracks(0) = CByte((numTracks >> 8) And &HFF)

            byteTracks(1) = CByte((numTracks And &HFF))

            My.Computer.FileSystem.WriteAllBytes(filepath, byteTracks, True)

 

            My.Computer.FileSystem.WriteAllBytes(filepath, Speed, True)

 

            For Each t In Tracks

                t.Save(filepath)

            Next

        Catch ex As Exception

            MsgBox("Unable to save the file due to the following error:" & vbCrLf & ex.Message)

            Return

        End Try

    End Sub

One thing I should note:  you’ll see that the first call to WriteAllBytes uses “False” for its third parameter, whereas the other all use “True.”  A value of True means that the data will be appended to the file instead of overwriting the file, so it’s the first call I make that overwrites any existing file, and the subsequent calls append data to the new incarnation of that file.

So, that’s it for the support code.  In part two of this series, I’ll use this code to drive the music editing functionality.

‘Til next time,

  --Matt--*

Leave a Comment
  • Please add 8 and 8 and type the answer here:
  • Post
Page 1 of 1 (4 items)