This tutorial will show you how to write a program that creates MIDI files of guitar chords by processing text files. Although I wrote this in Objective-C and the GUI is built using Cocoa, the MIDI algorithms could easily be applied to another language or platform. Essentially, this program scans a text file to look for a chord or some other symbol that it recognizes. Then it translates that into MIDI format code, and continues parsing until it reaches the end of the file.
Start by downloading the project here: CocoaMIDI.tar.gz (32 KB). This contains the Project Builder project, with the MainMenu.nib resource and all the files already created, but no implementation for the MIDI.m class file, which is what we will do now. Here is what the MIDI.m file looks like now:
#import "MIDI.h" @implementation MIDI @end
The first thing we’ll do is add an NSDictionary where we will store the chords. Add the following line to MIDI.m before the @implementation… part:
NSMutableDictionary *chords;
We will read the chords from a file and store them in here, for easy access when building the MIDI file. We do this in the + (void) initialize
function, which gets called for every class before the first time any other call is made. Add this function between the @implementation and @end lines:
+ (void) initialize{ int i; NSString *chordFile = [[[[NSBundle mainBundle] bundlePath] stringByDeletingLastPathComponent] stringByAppendingString:@"/Chords.txt"]; NSString *chordDefs = [NSString stringWithContentsOfFile:chordFile]; NSArray *eachChord = [chordDefs componentsSeparatedByString:@"\n"]; // each line chords = [[NSMutableDictionary alloc] initWithCapacity:7]; for( i = 0; i < [eachChord count]; i++ ){ NSArray *temp, *keys; if( [[eachChord objectAtIndex:i] isEqualToString:@""] ) continue; // don't crash if Chords.txt has extra returns temp = [[eachChord objectAtIndex:i] componentsSeparatedByString:@"\t"]; keys = [[temp objectAtIndex:1] componentsSeparatedByString:@"|"]; [chords setObject:keys forKey:[temp objectAtIndex:0]]; } }
This function first calculates the complete path to the file in which the chord definitions are contained by finding the complete application path (.../CocoaMIDI.app), chopping off the .app folder, and adding Chords.txt.
Next we use the NSString constructor stringWithContentsOfFile:
to read all the chords into one long string in memory. CocoaMIDI assumes each chord name with its associated MIDI key numbers is on a separate line, so we break the big string up into an array of strings, making a new string each time there is a line break.
Now we initialize the NSMutableDictionary we defined in the first step. In the for-loop, we go through each line of the original Chords.txt file. First we check to see if it is empty, which happens if someone has put some extra blank lines in the file. Assuming it is not empty, we break it up into two parts, which are separated by a tab: the first part is the chord name, the second consists of the MIDI key numbers, which are further separated by the "|" character, and need to be split up and assigned as an array to the keys variable. Then we add these keys to the chords dictionary with the name of that chord, which is object 0 of the temp array.
Next we have the - init...
function:
- initWithIn:(NSString *)inFile out:(NSString *)outFile{ self = [super init]; lastChord = nil; DELTA = 256; deltaDivide = 4; inData = [[NSFileHandle fileHandleForReadingAtPath:inFile] availableData]; inLength = [inData length]; inData = [inData bytes]; pos = 0; self->outFile = outFile; track = [[NSMutableData alloc] init]; contents = [[NSMutableData alloc] init]; volume = 100; return self; }
This initializer takes two strings as parameters: the first is the path to the file we want to translate into MIDI, the second is the path to the MIDI file we want to create. We then initialize all the necessary variables.
This will generate several compile warnings because it is not in the best technical form. If you look at the MIDI.h header file, you will notice that the instance variable inData is declared to be of type char *, and here we are assigning it first an NSData * object, and then to a const void * which is a pointer to the actual data from the file we want to translate. In between, we make an Objective-C call on the inData variable to get the length of the data read. This works because the Objective-C runtime looks at what kind of object a pointer thinks it is to determine the correct call to make, rather than what kind of pointer you have declared it to be.
The track instance variable will hold all the note information, and the contents instance variable will hold a MIDI file header followed by the track data.
If you look at the Controller.m file, you will see that it creates and initializes a MIDI object, and then calls the - (void) writeFile
member function. This is where all the action takes place:
- (void) writeFile{ char header[18] = { 0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x40, 0x4D, 0x54, 0x72, 0x6B }; // MThd header char end[3] = { 0xFF, 0x2F, 0x00 }; // end of track marker char guitar[3] = { 0x00, 0xC0, 0x19 }; // change instrument to guitar int length; [contents appendBytes:header length:18]; [self buildTrack]; length = [track length]+3; [contents appendBytes:&length length:4]; [contents appendBytes:guitar length:3]; [contents appendData:track]; [contents appendBytes:end length:3]; [[NSFileManager defaultManager] createFileAtPath:outFile contents:contents attributes:nil]; }
These ugly-looking char arrays are the MIDI headers. For more information about these, see the references at the end of this tutorial. We start by appending the one called header to the contents data. Next we make a call to - (void) buildTrack
, which handles the translation of the input file and stores the resulting MIDI commands in the the track NSData variable.
We have to do it this way because the next thing to go in the MIDI file to format it properly is the length of the track. So we append the length of the track we've build + 3, because we want to change the instrument to guitar at the very beginning, from the default of piano, which takes an additional three bytes. So we append the length, then we append the guitar array, which is the MIDI command to switch the instrument to a guitar, and then we append the track data. The last thing we need to add is the MIDI marker that tells it the end of the track has been reached. I'm not sure why it needs this, since we have already told it how long the track is, but what the heck. So we append end. The complete MIDI file is now in memory, so we write it out to the file the user specified.
The - (void) buildTrack
function is where the translation logic is. Consequently, it is the longest function in the CocoaMIDI application:
- (void) buildTrack{ char c; int delta = 0; while( pos < inLength ){ c = inData[pos++]; if( 'A' <= c && c <= 'G' ){ // a chord NSString *chord = [NSString stringWithFormat:@"%c",c]; int i; for( i = 0; i < 13; i++ ){ // start at 7 characters (unicode, 2 bytes)... if( pos == inLength ) break; chord = [chord stringByAppendingFormat:@"%c",inData[pos++]]; } while( [chords objectForKey:chord] == nil ){ // find longest match chord = [chord substringToIndex:[chord length]-1]; pos -= 2; // unicode again } [self closeLastChord:delta]; [self writeChord:chord atDelta:delta]; delta = DELTA/deltaDivide; } else if( c == 'x' || c == 'X' ){ // kill the chord [self closeLastChord:delta]; lastChord = nil; } else if( c == '-' ){ // a continuation delta += DELTA/deltaDivide; } else if( c == '(' ){ // time change NSString *num = @""; while( (c = inData[pos++]) != ')' ) num = [num stringByAppendingFormat:@"%c",c]; deltaDivide = [num intValue]; } else if( c == '^' ){ // step offset NSString *num = @""; while( (c = inData[pos++]) != '^' ) num = [num stringByAppendingFormat:@"%c",c]; stepOffset = [num intValue]; } else if( c == ':' ){ NSString *num = @""; while( (c = inData[pos++]) != ':' ) num = [num stringByAppendingFormat:@"%c",c]; volume = [num intValue]; } } [self closeLastChord:delta]; [self writeVarTime:0]; }
We use a while loop to read all the data that we have stored in memory from the input file. The pos variable marks where we currently are in memory, and inLength gives the end of the data.
Each time we start the new loop by reading in the current character c and advancing the position marker. Next we check to see what kind of information we can expect to follow, based on what character this is. If it is between 'A' and 'G', then it will be a chord. An 'x' signifies that we want to silence the last-played chord, a '-' means extend the last played chord. The next three else-if clauses read in an integer enclosed by the given characters and apply it to some variable. An integer between parentheses means change the timing, an integer between carets means change the key, and an integer between colons means change the volume.
To match a chord, we start by building a string that is up to 7 characters long, or however much data is left in the inData variable. This is what that first for-loop does, grabbing the next character and appending it to the string. Then we start checking that string against the names of chords defined in our chords dictionary. We want to match the longest string possible, which is why we start at 7 characters and work our way down. This way, we will match "C#m7" instead of "C#m" or "C#" or "C". Using 7 characters as the maximum length was a purely arbitrary decision, but seems a reasonable length. Since we know that the first character is somewhere from 'A' - 'G', we are guaranteed to have at least a one-character chord match, assuming the user hasn't edited the Chords.txt file to remove those definitions! Then we stop the previously played chord (that function handles the case of there being no previous chord) and play the chord we just read.
You have seen the delta, DELTA, and deltaDivide variables several times now. DELTA defines the number of "pulses" in each whole note, i.e., you can play notes as short as 256th notes. deltaDivide sets what kind of timing we are currently using. For example, if deltaDivide is 4, then we are using 4th notes, etc. The expression DELTA/deltaDivide tells how many pulses are in each note for the current timing scheme. delta simply tells how long has passed since the last event. Every time we play a new chord, we start delta over from then, and every time we hit a chord extension "-", we add that amount again. Changing the timing using "(n)" changes how fast you want the notes to be played by adjusting how many pulses are added to delta.
Next we need the - (void) writeChord:...
function:
- (void) writeChord:(NSString *)c atDelta:(int)delta{ int i; NSArray *keys; keys = [chords objectForKey:c]; lastChord = [c retain]; lastOffset = stepOffset; lastVolume = volume; [self writeVarTime:delta]; [self appendNote:[[keys objectAtIndex:0] intValue]+stepOffset state:YES]; for( i = 1; i < [keys count]; i++ ){ [self writeVarTime:0]; [self appendNote:[[keys objectAtIndex:i] intValue]+stepOffset state:YES]; } }
This function takes a chord name and a delta time and inserts the proper data into the MIDI data track variable. It first finds the array of keys to be played from the chords dictionary, then it sets the lastChord variable to this chord name (this is need to close it off when we want to play another chord), and sets the last step offset and volumes to their respective current values. Next we write the offset delta we have been given followed by the first key in the chord. Since all the following keys are played at the same time, they are all preceded by a delta of 0. Additionally, we add stepOffset to each MIDI key value in case the user has desired to change the key by using the "^step^" command in the input file.
The - (void) closeLastChord:...
function is very similar:
- (void) closeLastChord:(int)delta{ int i; NSArray *keys; if( lastChord == NULL ) return; [lastChord autorelease]; keys = [chords objectForKey:lastChord]; [self writeVarTime:delta]; [self appendNote:[[keys objectAtIndex:0] intValue]+lastOffset state:NO]; for( i = 1; i < [keys count]; i++ ){ [self writeVarTime:0]; [self appendNote:[[keys objectAtIndex:i] intValue]+lastOffset state:NO]; } }
We start by making sure there actually is a currently playing chord to close, since this function is called before every chord is played. Then we write the current delta time and the first note, this time with the state being NO, or off. As in writeChord, we then iterate through the rest of the notes, playing each with a delta of 0.
By now you are probably wondering where the actual MIDI code goes. Well, most of it goes here in the - (void) appendNote:...
function:
- (void) appendNote:(int)note state:(BOOL)on{ char c[3]; if( on ){ c[0] = 0x90; c[2] = volume; } else { c[0] = 0x80; c[2] = lastVolume; } c[1] = note; [track appendBytes:&c length:3]; }
This function constructs a 3-char array and fills it with the appropriate values to make the MIDI note on or off command. The first character is 0x90 for on, or 0x80 for off. Then we put the volume in the third byte (current volume for on, last volume for off), the note value in the second byte, and append the whole thing to the the MIDI track data.
The last function you need to add is the - (void) writeVarTime:...
function:
- (void) writeVarTime:(int)value{ char c[2]; if( value < 128 ){ c[0] = value; [track appendBytes:&c length:1]; } else { c[0] = value/128 | 0x80; c[1] = value % 128; [track appendBytes:&c length:2]; } }
Basically, if the delta in pulses since the last event (value) is less than 128, we put it in one byte. If it's larger, we have to put it into two bytes. Technically, the MIDI file format supports up to four bytes for the time delta, but I haven't bothered to put support in for that here, since that would be more than 16 seconds. But if you want, you could change that. See the MIDI references below for more information.
You have now completed the CocoaMIDI program. As you can see, it is fairly easy to add new commands to the buildTrack function. Try out some of the included tune definition files, and make your own. Have fun!
Here is the complete source in a PB project CocoaMIDISource.tar.gz
Useful MIDI resources
- MIDI Specification talks about different MIDI commands
- MIDI File Format describes headers and the delta time format
Now this is cool…. 🙂 Maybe asking too much, but care to take a stab at the reverse? Analyzing a MIDI file and pulling the chords out of it? {smile}
That’s an intriguing idea, Steve… I’ll have to think about that.
I’m trying to get this to work on OS 10.4.11, but having a lot of trouble. Any tips?
Hi,
This tutorial was very useful in explaining the Midi file specification, so thanks for that.
I’ve tried to follow it to create my own midi file, but I keep getting an error message that the midi file cannot be opened when I try to import the file into Logic Pro.
Below follows my code:
[code]- (void)midiTest {
NSString *fileAtPathName = [[NSString alloc] initWithFormat:@”%@MidiTest.mid”, [mainDelegate documentsDirectoryName:YES]];
char header[18] = { ‘M’, ‘T’, ‘h’, ‘d’, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x40, 0x4D, 0x54, 0x72, 0x6B };
char end[3] = {0xFF, 0x2F, 0x00};
char guitar[3] = { 0x00, 0xC0, 0x19};
int iLength;
NSMutableData *contents = [[NSMutableData alloc] init];
NSMutableData *track = [[NSMutableData alloc] init];
[contents appendBytes:header length:18];
char deltaChar[1] = {0x00};
char noteOn[3] = { 0x90, 60, 100 };
char noteOff[3] = { 0x80, 60, 100 };
[track appendBytes:deltaChar length:1];
[track appendBytes:guitar length:3];
[track appendBytes:deltaChar length:1];
[track appendBytes:noteOn length:3];
deltaChar[0] = 0x24;
[track appendBytes:deltaChar length:1];
[track appendBytes:noteOff length:3];
[track appendBytes:end length:3];
iLength = [track length];
[contents appendBytes:&iLength length:4];
[contents appendData:track];
[[NSFileManager defaultManager] createFileAtPath:fileAtPathName contents:contents attributes:nil];
}
[/code]
What gives?
Hi again,
I figured out what the issue was with your code. In method writeFile you do:
length = [track length]+3;
[contents appendBytes:&length length:4];
You can’t do that. You need to convert it into a char array just like you do in writeVarTime and appendNote.
Calling the following method will fix it:
- (NSMutableData *)appendUnsignedLong:(NSMutableData *)track longValue:(unsigned long)ul {
char ch[4];
ch[0] = ul >> 24;
ch[1] = ul >> 16 & 0xff;
ch[2] = ul >> 8 & 0xff;
ch[3] = ul & 0xff;
[track appendBytes:ch length:4];
return track;
}
So in your writeFile method you would say:
contents = [self appendUnignedLong:contents unsignedLong:length];
instead of:
[contents appendBytes:&length length:4];
Furthermore when you measure a track’s length, you have to include the 3 bytes in the end of track marker { 0xFF, 0x2F, 0x00}
brgds