A group blog from members of the VB team
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:
· 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…
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!
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:
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
AddNote(i, octave, "C")
AddNote(i, octave - 1, "B#") ' A sharped B is technically in the next lower octave
AddNote(i, octave, "C#")
AddNote(i, octave, "Db")
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:
AddNote(i, octave, "B")
AddNote(i, octave + 1, "Cb") ' A flatted C is technically in the next higher octave
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)
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
octaveQuotes.Append("'", 5 - octave)
octaveQuotes.Append("'", octave - 5)
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
There are only two interesting events in my code – changing the channel on a track, and saving the music to a MIDI file.
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
text = Track4
index = 3
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).Channel = -1
' Change the index back -- Channel must be non-zero
combo.SelectedIndex = Song.Tracks(index).Channel - 1
text.ReadOnly = False
Song.Tracks(index).Channel = combo.SelectedIndex - 1
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.
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
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 " & _
dot = True
count += 1
' 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 " & _
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) = "'"
foundQuotes = True
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 " & _
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
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):
If foundQuotes = True Then
Throw New ApplicationException("Quotes on both side of a note in track " & _
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)
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
If currentIndex = 0 Then
Throw New ApplicationException("No volume found for a note in track " & index.ToString())
volume = Integer.Parse(musicText.Substring(currentIndex, count))
currentIndex = currentIndex + count
count = 0
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
MIDI.Track.NoteEvent.NoteOn, CByte(note), CByte(volume))
MIDI.Track.NoteEvent.NoteOff, CByte(note), 0)
restBeats = 0
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!
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
Throw New ApplicationException ("Missing comma in track " & index.ToString())
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
If we’ve gotten this far, then the generation was successful, and we’re done:
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
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")
Catch ex As Exception
MsgBox("Error while saving: " & ex.Message.ToString)
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 = _
Me.SaveMIDIDialog.InitialDirectory = _
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
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:
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
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,
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.
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.
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:
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
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?
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,
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.
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!
There's a project on Codeplex called NAudio, which might be useful if you want to do some higher-level MIDI stuff quickly :)
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!
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>