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

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

  • Comments 12

In part 1 of this series, I constructed a pair of classes to supporting persisting MIDI data to files.  In this entry, I’ll now leverage that code to support an (admittedly limited) music editor experience.

Caveat:  As I mentioned in the first post, 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 would love to be able to write a full-fledged editor with all of the music printed out, all WYSIWYG, but unfortunately I have a day job which would be impacted and so I’m opting for the Commodore-128 experience I mentioned in the previous post.  Basically, I will (arbitrarily) support four tracks of music, each track containing notes, and each note derived from the following format:

dTv

Where:

·         d is the duration of the note (a floating-point value; i.e., since 4/4 time is assumed, 1.0 would be a quarter note, 1.5 would be a dotted quarter note, 0.5 an eighth note, etc.)

·         T is the tone of the note (i.e., anything from '''''C through G'''''' or R (for rest))

·         v is the volume from 0 to 127 (i.e., &H0 through &HFF), which is never used for a rest.

So, in my design, a track which played a whole-note (4 beats) middle C, rested for a half-note (2 beats), and then dropped down to 'G for dotted quarter-note (1.5 beats), all at a volume of 60, would look like this:

4.0C60, 2.0R, 1.5'G60

That’s not nearly as nice to look at as the actual musical score, but it will allow me to give an example of parsing text data, so on we go…

The form

On my Windows application, I have four combo boxes in a vertical pattern, with a long text box adjacent to each.  The combos’ DropDownStyle properties are all set to DropDownList, and the Items property for each is preset to be the numbers 1 through 15 (these numbers indicate MIDI channels), plus “blank” (a simple carriage return) – blank is the default value (i.e., unused track).  The text boxes (which will contain the “dTv” notes) all start out as ReadOnly = True, and switch to editable only if the corresponding combo channel is non-blank.  I also have a “Save” button to generate the MIDI output, and I added a SaveDialog object as well.  That’s pretty much the extent of the form. 

Back in the code, I’ll add an instance of the MIDI object I defined in the previous post:

Public Class Form1

    Private Song As New MIDI

 

Now, I need to deal with the notes!

Specifying the notes that can be used

In order to support the parsing of the track information, I’ll need to translate the tone values from characters to the numbers that MIDI expects (of which there are 128).  The best way to do this is to create a collection mapping the text value to the numeric value, so we can create the mapping once and be done with it:

    Private Notes As New Collection

    Const NumberOfNotes As Integer = 128

Now, I could do something lame like:

Notes.Add(60, "C")

Notes.Add(61, "C#")

 

And so on, but there are 128 potential values, and some of them have multiple names (for every sharp, there’s a corresponding flat), so that would be a lot of work.  So, I wrote some helper routines to try to do this in a smarter way.

First, I need to find out how many single quote marks I need to add to the note.  Each quote preceding the note indicates an octave one lower than the one occupied by middle C; conversely, a quote following a note is indicative of a higher octave.  (That is, ‘C indicates a note that is one octave lower than middle C, whereas C’ indicates a note that is one octave higher.)  There are twelve distinct notes in an octave, including accidentals (flats and sharps), so for a given value, I divide by twelve to determine its octave, and use the remainder (modulus) to determine its position within that octave:

    Private Sub InitializeNotes()

        For i As Integer = 0 To NumberOfNotes - 1

            Dim octave As Integer = i \ 12

            Dim tone As Integer = i Mod 12

 

Then, for each number, I call a helper function to do the actual work of creating the textual representation of the corresponding note.  “C” is the first note in MIDI, and so would have a remainder of zero:

            Select Case tone

                Case 0

                    AddNote(i, octave, "C")

                    AddNote(i, octave - 1, "B#") ' A sharped B is technically in the next lower octave

                Case 1

                    AddNote(i, octave, "C#")

                    AddNote(i, octave, "Db")

                Case 2

                    AddNote(i, octave, "D")

 

(And so on.)  Note that I am adding two notes for each accidental (one for its sharp representation, and one for its flat representation), since I don’t want to force the user to use a specific one.  I also accommodate B# (= C), E# (= F), Fb ( = E), and Cb (= B), though these are rarely used in most music.  (I don’t currently support double-flats or double-sharps, though it would be easy enough to do – just add more entries.)

At the end of these 128 additions, I also add an entry for rests directly:

                Case 11

                    AddNote(i, octave, "B")

                    AddNote(i, octave + 1, "Cb") ' A flatted C is technically in the next higher octave

            End Select

        Next

 

        Notes.Add(NumberOfNotes, "R")

    End Sub

 

AddNote() is a simple method that determine the quotes (if any) for the note and appends them or prepends them in the right order.  (The 5th octave is the middle octave and therefore has no quotes.)

    Private Sub AddNote(ByVal value As Integer, ByVal octave As Integer, ByVal noteName As String)

        Dim octaveQuotes As String = GetOctaveQuotes(octave)

        If octave < 5 Then

            Notes.Add(value, octaveQuotes & noteName)

        ElseIf octave > 5 Then

            Notes.Add(value, noteName & octaveQuotes)

        Else

            Notes.Add(value, noteName)

        End If

    End Sub

 

You’ll note that I use yet another helper function (GetOctaveQuotes) to get the actual number of quotes which are appended or prepended.  The number of quotes increases as you move away from the middle octave:

    Private Function GetOctaveQuotes(ByVal octave As Integer) As String

        Dim octaveQuotes As New StringBuilder

        If octave < 5 Then

            octaveQuotes.Append("'", 5 - octave)

        Else

            octaveQuotes.Append("'", octave - 5)

        End If

        Return octaveQuotes.ToString

    End Function

 

And now we have everything we need to initialize the notes.  My form’s Load() event calls that initialization, and also creates the four tracks that we’ll support:

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load

        Song.AddTrack()

        Song.AddTrack()

        Song.AddTrack()

        Song.AddTrack()

 

        InitializeNotes()

    End Sub

Form events

There are only two interesting events in my code – changing the channel on a track, and saving the music to a MIDI file.

Track changes

When the track changes, I want to disable or enable the corresponding text box based on whether or not the channel is blank.  If it is blank, then I disable the text box and clear its contents; otherwise, I enable it.  Rather than create a separate event handler for each combo box, I address them all in one handler:

    Private Sub Track1Channel_SelectedIndexChanged(ByVal sender As System.Object, ByVal e As System.EventArgs) _

        Handles Track1Channel.SelectedIndexChanged, Track2Channel.SelectedIndexChanged, Track3Channel.SelectedIndexChanged, Track4Channel.SelectedIndexChanged

 

By casting the sender object to a ComboBox, I’ll be able to determine which combo box sent the event, and which text box corresponds with it.  (The index value will be used later to identify the song’s specific track which corresponds to a given combo/text set.)

        Dim combo As ComboBox = CType(sender, ComboBox)

        Dim text As TextBox

        Dim index As Integer = 0

        If combo Is Track1Channel Then

            text = Track1

            index = 0

        ElseIf combo Is Track2Channel Then

            text = Track2

            index = 1

        ElseIf combo Is Track3Channel Then

            text = Track3

            index = 2

        Else

            text = Track4

            index = 3

        End If

 

Now, it’s pretty straightforward to the activations or deactivations.  The “blank” choice is in index 0 in my combobox, so if the selected index is 0, I turn everything off (after a prompt, of course), otherwise, I turn everything on.  In either case, I update the appropriate track with its new channel:

        If combo.SelectedIndex = 0 Then

            If MsgBox("Delete all of the track information?") = MsgBoxResult.Ok Then

                text.ReadOnly = True

                text.Text = ""

                Song.Tracks(index).TrackData.Clear()

                Song.Tracks(index).Channel = -1

            Else

                ' Change the index back -- Channel must be non-zero

                combo.SelectedIndex = Song.Tracks(index).Channel - 1

            End If

        Else

            text.ReadOnly = False

            Song.Tracks(index).Channel = combo.SelectedIndex - 1

        End If

 

Note that I’m cheating a little in that code – I happen to know that channel 0 is in index 1, channel 1 is in index 2, etc.  This allows me to take a shortcut and a priori know that the channel is simply the selected index minus 1, rather than getting the text from the combobox and converting it to an integer.

Saving the music to a MIDI file

In order to save the music the user has specified, I need to translate the text he or she entered into MIDI format bytes and insert them into the tracks.  I do the bulk of this work via a function called GenerateTrack, which takes a textbox and it’s corresponding track index as arguments, and which returns False if the track generation failed (because of a bad format or because it was not a valid track). Throughout this method, I will throw an exception any time I see a badly formatted music string so I can handle those errors all in one place. 

    Private Function GenerateTrack(ByVal trackTextBox As TextBox, ByVal index As Integer) As Boolean

 

The first thing to do verify that that track is valid (i.e., has a valid channel assigned to it), and clear out any existing track information:

        If Not Song.Tracks(index).ValidTrack Then

            Return False

        End If

        Song.Tracks(index).TrackData.Clear()

 

Next, I retrieve the music text and remove any spaces in the text:

        Dim musicText As String = trackTextBox.Text

        musicText = musicText.Replace(" ", "") ' Remove whitespace characters

 

I’ve then defined a couple of index counters to allow me to walk through the musicText, as well as a Double which will allow me to remember rests (which, in my application, are just delays between two notes and not really a thing in and of themselves):

        Dim currentIndex As Integer = 0

        Dim count As Integer = 0

        Dim restBeats As Double = 0.0

 

Now, I loop over the characters in the string to begin parsing them:

        While currentIndex < musicText.Length()

 

Step 1 is to get the beats associated with a note.  I start with the current character and keep stepping until I find something that isn’t a number or a decimal point.  (And If I find a decimal point, I make sure that I haven’t already found one on this note.)  If I find no numbers, or too many decimals, or a decimal without a fractional part, I throw an error.  Otherwise, I convert the number to a true Double.  During all of this, currentIndex points to the presumed beginning of the beats, and count contains the number of characters to consume in the beats:

            Dim beats As Double = 0.0

            Dim dot As Boolean = False

            While (musicText(currentIndex + count) >= "0" _

             AndAlso musicText(currentIndex + count) <= "9") _

              OrElse musicText(currentIndex + count) = "."

                If musicText(currentIndex + count) = "." Then

                    If dot = True Then

                        Throw New ApplicationException("Bad beat format in track " & _

                           index.ToString())

                    Else

                        dot = True

                    End If

                End If

                count += 1

            End While

 

            ' Now get the beats!

            If count = 0 OrElse (count = 1 AndAlso dot = True) Then

                Throw New ApplicationException("No beats found for a note in track " & _

                   index.ToString())

            End If

            beats = Double.Parse(musicText.Substring(currentIndex, count))

 

Having gotten this far, I update currentIndex point past the beats and reset count to zero – time to get the note!

            currentIndex = currentIndex + count

            count = 0

 

            Dim note As Integer = 0

 

Getting the note is very similar to getting the beats, except in this case I’m paying attention to quotes and accidentals instead of decimal points.  Quotes can appear on either the left side or the right, but not both, so I need to have a Boolean to indicate if I’ve already found quotes on the left side:

            Dim foundQuotes As Boolean = False

            While musicText(currentIndex + count) = "'"

                count += 1

                foundQuotes = True

            End While

 

Next I’ll look for the note.  Since the note (excluding the # or b) only takes one character, this is an easy check to see if the next character is A through G (notes) or R (a rest):

           If (musicText(currentIndex + count) < "A" _

             OrElse musicText(currentIndex + count) > "G") _

AndAlso musicText(currentIndex + count) <> "R" Then

                Throw New ApplicationException("Unrecognized note in track " & _

                  index.ToString())

            End If

            count += 1

 

And then I check for a flat or sharp and update the counter if I find one. 

            If musicText(currentIndex + count) = "#" _

              OrElse musicText(currentIndex + count) = "b" Then

                count += 1

            End If

 

Now I check again for quote marks, remembering that if I found them on the left side, I’d better not find them on the right side (a note can’t be both higher and lower than the middle octave):

            While musicText(currentIndex + count) = "'"

                If foundQuotes = True Then

                    Throw New ApplicationException("Quotes on both side of a note in track " & _

                       index.ToString())

                End If

                count += 1

            End While

 

Now I can get the note and look it up in my collection, and then update the counters again to grab the volume:

            Dim key As String = musicText.Substring(currentIndex, count)

            note = Notes(key)

 

            currentIndex = currentIndex + count

            count = 0

 

Okay, that’s two down.  The final thing I need to retrieve from a given “dTv” triplet is the volume, which is an integer.  However, rests don’t have volumes, and since I’ve defined a rest as NumberOfNotes, I can just check that value to see if I need to read a volume:

            Dim volume As Integer = 0

            If note <> NumberOfNotes Then ' Rests don't have volumes

 

(If the user provided a volume for a rest, I’ll catch that later and correctly throw an error when I try to parse the next note.)

Since this is just an integer, the rest of the code is easy – I keep crawling while the current character is 0 through 9, and if I get to the end and have found nothing, I’ll throw an error.  Otherwise, I’ll just translate the volume to a true Integer and update the counters for the next thing to find:

                While currentIndex + count < musicText.Length() _

                 AndAlso musicText(currentIndex + count) >= "0" _

                 AndAlso musicText(currentIndex + count) <= "9"

                    count += 1

                End While

 

                If currentIndex = 0 Then

                    Throw New ApplicationException("No volume found for a note in track " & index.ToString())

                End If

 

                volume = Integer.Parse(musicText.Substring(currentIndex, count))

 

                currentIndex = currentIndex + count

                count = 0

            End If

 

That’s it for the parsing of that note; now I can update the track with the results.  There are two cases here: either I have a note, or I have a rest.  In the case of a rest, all I do is cache the duration of the rest.  If it’s a note, I turn the note on after any cached rest, turn it off after a certain number of beats, and then clear the rest cache:

            If note = NumberOfNotes Then

                restBeats = beats ' A rest; just have the next note start later

            Else

                Song.Tracks(index).AddNoteOnOffEvent(restBeats,_

                       MIDI.Track.NoteEvent.NoteOn, CByte(note), CByte(volume))

                Song.Tracks(index).AddNoteOnOffEvent(beats, _

                       MIDI.Track.NoteEvent.NoteOff, CByte(note), 0)

                restBeats = 0

            End If

 

I’ve finished working with the note, so I’ll see if there’s another note by checking the value of currentIndex compared to the length of the string.  If I’m at the end, I can exit the while loop:

            If currentIndex >= musicText.Length Then

                Exit While ' No more notes!

            End If

 

Otherwise, I’ll verify that the next character is a comma.  If it is, then I’ll increment appropriately and loop back.  Otherwise, it’s an error:

            If musicText(currentIndex) = "," Then

                currentIndex += 1

            Else

                Throw New ApplicationException ("Missing comma in track " & index.ToString())

            End If

        End While

 

Being out of the While loop, I need to verify that data got saved.  If currentIndex is non-zero, then I know that I saved data (because it I ran into trouble later, I would have thrown an error). If I didn’t save data, then I need to return False:

        If currentIndex = 0 Then

            Return False ' Nothing got written out

        End If

 

If we’ve gotten this far, then the generation was successful, and we’re done:

        Return True

    End Function

 

Now, finally, I can support the “Save” button.  I’ll double click on it to generate the handler, and then start adding code.  First, I need try to generate each track.  If all of them returned False, then there was nothing to save, and I’ll abort the operation.  But, since GenerateTrack() can also throw exceptions, I’ll add a Try-Catch structure to catch those.  The catch handler will display the error to the user, and then abort the save:

    Private Sub Save_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Save.Click

        Try

            If GenerateTrack(Track1, 0) = False And _

               GenerateTrack(Track2, 1) = False And _

               GenerateTrack(Track3, 2) = False And _

               GenerateTrack(Track4, 3) = False Then

                MsgBox("No tracks had valid data to write out")

                Return

            End If

        Catch ex As Exception

            MsgBox("Error while saving: " & ex.Message.ToString)

            Return

        End Try

 

It’s important to use “And” for that instead of “AndAlso,” because otherwise you’ll short-circuit tracks and not write them in.

Next, I need to pop up the save dialog box to determine where to save the file to.  I’ll pre-populate this to call the file “Untitled.mid” and have it point to “My Documents:”

        Me.SaveMIDIDialog.DefaultExt = "MID"

        Me.SaveMIDIDialog.FileName = _

My.Computer.FileSystem.CombinePath(My.Computer.FileSystem.SpecialDirectories.MyDocuments, _

"Untitled.mid")

        Me.SaveMIDIDialog.InitialDirectory = _

            My.Computer.FileSystem.SpecialDirectories.MyDocuments

        Me.SaveMIDIDialog.Filter = "VB MIDI files (*.MID)|*.MID"

 

Then, assuming that the user didn’t cancel, I just call Save on the MIDI song:

        Dim result As DialogResult = Me.SaveMIDIDialog.ShowDialog

        If result = DialogResult.OK Then

            Song.Save(SaveMIDIDialog.FileName)

        End If

 

    End Sub

 

And that’s it!  Launch the app, and you can start inserting music, save it, and then double-click on the resulting file to play it in Windows Media Player (or whatever you’ve got MIDI file set to).  Here’s a couple samples from Howard Shore’s “The Fellowship of the Ring” score:

Sample 1:

First track, channel 0: 1B'60,1.75C''65, 0.25B'70, 0.25C''75, 0.25B'80, 0.25A'85, 0.25C''90, 1.0B'95, 3.0E'80

Second track, channel 1: 4.0G60, 4.0A60

Third track, channel 2: 4.0'D#60, 4.0'F#60

Fourth track, channel 3: 4.0''C60, 2.0'''B80, 1.0''C#60, 1.0''D60

 

Sample 2:

First track, channel 0: 2.0D60, 1.5C70, 0.25C70,0.25C70, 3.5D70, 0.25G60, 0.25A60, 1.5Bb60, 0.25A70, 0.25G70, 1.5F70, 0.25G70, 0.25A70, 2.0G70, 1.0F65, 1.0E60

Second track, channel 12.0'A60, 1.5'A60, 0.25'A70,0.25'A70, 3.5'A70,0.25'G60, 0.25'A60, 2.0'F60, 2.0'A70, 2.0'G70, 1.0'F65, 1.0'G60

Third track, channel 2: 2.0'F#60, 1.5'F60, 0.25'F70,0.25'F70, 4.0'F#70, 2.0'D60, 2.0'F70, 2.0'D70, 1.0'D65, 1.0'E60

Fourth track, channel 3: 2.0''D60, 1.5''F60, 0.25''F70,0.25''F70,4.0''D70, 2.0''Bb60, 2.0''A70, 2.0''D70, 1.0''Bb65, 1.0'C60

 

This is obviously just the tip of the iceberg – by adding to this application, I could assign different instruments to different tracks (via the “ProgramChange” event), persist metadata containing lyrics, and so on.  (I don’t plan on doing that myself, since for me I simply wanted to learn more about MIDI, but who knows?  Maybe later....)  The completed code (along with another method which describes setting metadata) is attached to this blog post, and will also be posted on my Temple of VB site.

‘Til next time,

  --Matt--*

Attachment: VBMidiEditor.zip
Leave a Comment
  • Please add 7 and 1 and type the answer here:
  • Post
  • PingBack from http://www.clickandsolve.com/?p=16325

  • Good series. I like every article you write, Matt.

    I did have one question, though: shouldn't this use regular expressions for the parsing? Seems like it would be a lot clearer and shorter, if the regexes were done properly. And so many developers (especially in VB) don't write them right, it'd be good to have some examples from the VB team on how to do it.

    Just a thought.

  • Hi, Nathan,

     Yes, you are right, I should really use regex.  I have a habit of doing it on my own which I need to outgrow.  It's funny because parsing is one of my "interview questions" for prospective Microsoft hires -- write a parser (not using regex!) which compares a filename to a pattern and see if they match, so hand-grown parsing is always on my brain.

    --Matt--*

  • This is great Matt!

    I attempted to type in the code and form into VB2008

    Express Edition.  I get syntax errors when I use the

    variables Track1, etc. in the Sub:

    Track1Channel_SelectedIndexChanged

    because they are undeclared.  I searched both your

    web pages on this blog and could not find their

    declarations.  In the same function a few lines later

    the assignment:

    Song.Tracks(index).Channel = -1

    gets a syntax error:

    Constant expression not representable in type 'Byte'.

    Could you offer me some guidance on these problem?

    Thanks,

    Tom

  • Hi, Tom,

     Track1 (etc.) are the names of the edit boxes on the form -- sorry if that wasn't clear.  Change teh names, and you should be OK.

    For the other error, you need to declare Channel to be an SByte, not a Byte -- that will fix things.

    By the way, if you download the ZIP file I attached to the blog & extract the files, you should be able to open up the project in VBExpress 2008 & save yourself a lot of typing!  Often, there's code that I don't print in the actual blog article because it's boilerplate stuff or isn't germane to the ideas I'm discussing, but the ZIP file has it all -- including the comments (which I leave out in the blog posts since blog is already doing the commenting).

    Hope this helps,

     --Matt--*

  • The solutions do help.  Thanks.

    By the way, the reason I did the extra typing on purpose is to have a more effective time of learning the syntax of VB.  I do however appreciate the files you provided.

    As an exercise, I am attempting the "load file" procedure that you suggested.

    Cheers,

    Tom

  • Hi, Tom,

     That makes total sense.  And please let us know how your "Load" exercise goes -- I'd love to hear about what caveats you find during that process!

    --Matt--*

  • There's a project on Codeplex called NAudio, which might be useful if you want to do some higher-level MIDI stuff quickly :)

    http://naudio.codeplex.com/

  • That looks neat -- at first glance, it looks like it's working its way to be somthing similar to Cool Edit (for those who remember that Syntrillium product). When I get some free time I'll definitely take a closer look.  Thanks for the pointer, Ed!

    --Matt--*

  • I’ve always enjoyed writing. Putting pen to paper (or, these days, fingers to keyboard) is something

  • <a href="www.download-midi.com/.../Drum+and+Bass">Drum and Bass MIDI</a>

  • <a href="www.download-midi.com/.../Drum+and+Bass">Drum and Bass MIDI</a>

Page 1 of 1 (12 items)