You will write a program to read a song represented as a MIDI file and produce a sampled signal in WAVE format suitable for playing on a computer. The MIDI file format represents music as a sequence of events, the fundamental ones being “note on” and “note off”. Each event also specifies the note, it’s “velocity”, and the time to the next event. This allows us to encode a single note as two on/off events separated by a time delay, rather than a single note and duration, but now we can specify chords as a sequence of on events with zero time delay between them, followed by off events. There are also events other than note on/off for specifying the current instrument, the tempo, and other parameters.
Translating the simple example from milestone 0:
C, 10.0, 2.5 G, 20.8, 5.0 C, 10.0, 3.0
into a sequence of events gives:
NOTE ON, C, 10.0 WAIT 2.5 NOTE OFF, C WAIT 0 NOTE ON, G, 20.8 WAIT 5.0 NOTE OFF, G WAIT 0 NOTE ON, C, 10.0 WAIT 3.0 NOTE OFF, C END TRACK
In the new design a MIDIEvent is any change in the instruments that may affect the output sound. A Track is a sequence of MIDI events representing a recording from a single instrument. Each instrument has a specific algorithm for generating the sound samples from the event stream. In this milestone we will use a single instrument using an algorithm very similar to milestone 0 (see below) that we will call the default instrument.
In this milestone we will define only three events:
- TempoEvent, the tempo of the music should change
- NoteEvent, a note should be turned on or off with a “velocity”, and
- EndTrackEvent, the track has ended.
The synthesizer reads tracks from the input file and converts it into sound samples that can be written as a WAVE file (the same way as in milestone 0). Because there are multiple simultaneous notes this process becomes slightly more complicated.
The synthesizer can be viewed as a state machine where each state is the status of all Instruments, e.g. what notes are on, and the output state is the current sound sample from the instrument. Transitions between synthesizer states depend on the stream of events that make up the track.
Consider a track as a list of events ordered with the absolute time from the start of the track. The synthesizer computes the time of the next tick and processes all events from the list with absolute times less than that. Based on these events, the Instrument’s state is updated and the sound sample stored.
In this milestone, the synthesizer converts each track separately to a signal, one signal per track. We will tackle mixing tracks in the next milestone.
The default instrument algorithm is a state machine whose state is the notes that are currently on, the time elapsed since each was turned on, and their velocity.
The instrument output is computed as the sum over each note currently on where i is the note index, and subscripted t is the elapsed time since note i was turned on. V(i) is the velocity of note i, f(i) is the frequency of note i, and E is the value of the envelope function at elapsed time t. The constant 200 simply scale the velocity to represent an amplitude.
The envelope function is a piece-wise function,
The time axis is scaled by a note length in seconds. The default instrument should use a note length of 0.25 sec (i.e., the envelope is non-zero from 0 to 0.25).
Notes supported are those in the MIDI standard, 128 notes numbered 0-127. Use the following frequency mapping (in Hz)
- index 60 is middle C = 261.63
- index 61 is C# = 277.18
- index 62 is D = 293.66
- index 63 is D# = 311.13
- index 64 is E = 329.63
- index 65 is F = 349.63
- index 66 is F# = 369.994
- index 67 is G = 392.0
- index 68 is G# = 415.305
- index 69 is A = 440.0
- index 70 is A# = 466.164
- index 71 is B = 493.88
The remaining note numbers follow the same pattern as 60-71, with a change in octave (multiply or divide by 2):
- indices 0-11 are five octaves lower
- indices 12-23 are four octaves lower
- indices 24-35 are three octaves lower
- indices 36-47 are two octaves lower
- indices 48-59 are one octave lower
- indices 72-83 are one octave higher
- indices 84-95 are two octaves higher
- indices 96-107 are three octaves higher
- indices 108-119 are four octaves higher
- indices 120-127 are five octaves higher (stops at G)
The sample time is at regular intervals given by the sample rate. The relative timing of MIDI ticks is determined by both the MIDI tick-time (the clock rate per beat), M, and the tempo, T. The MIDI clock rate is in time units of ticks-per-beat. The tempo T is in units of microseconds per beat. Thus the real-time between MIDI ticks is
Thus, the real-time t of a MIDI event at tick m, given a MIDI tick-time (clock) M and tempo T is The sample time and MIDI times both start at zero, but in general are not aligned. Consider the following figure.
The time axis is real (floating point) time. MIDI events occur on a MIDI clock tick, but are spaced by the delay time between them (three are shown in the figure). The samples are taken at regular intervals. For each MIDI event, the state of the instrument should be updated before the next sample is taken. That is, suppose a MIDI event occurs between two sampling events. The second sample should reflect the change in instrument state, but not the first (there is no time interpolation). If the event is a Tempo event, the tempo should change at the next sample. If it is an end track event, the track should end at the next sample. If it is a note-on event the note should start at the next sample. Thus notes always begin on a sample boundary with elapsed time reset to zero. A note cannot be turned on again, until it has been turned off.
Your C++ code implementing the synth program must be divided into the following modules, consisting of a header and implementation pair (.hpp and .cpp). You should not define additional source files, but you are free to modify the header and implementation files as desired as long as you do not break the module API. See the comments in the header file of each module in the starter code for detailed documentation.
A module for reading and parsing (a limited subset of) MIDI files is provided in midi.hpp and midi.cpp. This module uses your Track class to read and store events from the file using it’s API. Do not modify these files.
This milestone will use the same WAVE format as milestone 0. A clarification is that quantization into 16 bits should use rounding to nearest integer and normalization (scaling magnitude) should only be done if the signal would be clipped.
The file main.cpp should provide the entry point (main) for the synth program. The file names to read and write are specified as command line arguments to milestone 0. However the second argument is the base filename (without the .wav extension) to use when writing synthesized tracks, one wav file per track.
For example, the following program invocation reads the file MIDI_sample.mid, which contains 6 tracks, and converts it to the files output-0.wav,output-1.wav, …, output-5.wav(Assuming Windows platform, $ as the command line prompt)
$ .\synth.exe MIDI_sample.mid output
If no file names are specified, or the specified files cannot be opened, the program should print an appropriate error message to standard error and return EXIT_FAILURE. If the input file is invalid or the tracks cannot be synthesized, then an error message should be printed to standard error and the program should return EXIT_FAILURE. However any previously synthesized and written tracks should remain. Otherwise it should convert the file(s) and return EXIT_SUCCESS.
The initial git repository has a sub-directory called test which has several examples of input files (ending in .mid) and corresponding expected output (ending in .wav). The included CMakeLists.txt file sets up these tests for you. Just configure the build, run the build, and then run the tests. These functional tests are used to determine if your overall program works as expected.
Accept the GitHub invitation above and then clone the git repository to your local machine. Implement your program in a source files provided. Do not add additional files or modify the ones marked DO NOT EDIT at the top. You should use git to commit versions of your program source (only) as you go along, demonstrating good incremental programming technique.
- This project might seem daunting with several modules and files. The first step is to write stubs for each module so that the entire project compiles.
- Work incrementally and cyclically, from the Event module, to the Track module, then using the MIDI module, the Signal module, the Synthesize module, to finally the WAV module. Write the tests as you go, using them to drive your development.
- Work initially on functional test0 as that is the simplest.
- Use a tagged union (see help sessions) to implement the MIDIEvent class.
- Pay close attention to default states and Constructors.
- Leverage the standard library where you can.