Midi Tutorial

Extension name: midi

This extension shows how to read and write MIDI files with Nyquist.
Author Name: Roger B. Dannenberg;
Author Email: rbd@cs.cmu.edu;

Additional File: use-midi-directory.sal;
Additional File: demo.mid.b64;
Additional File: example.sal;
Additional File: midishow.lsp;

Nyquist can read and write midi files. Midi files are read into and written from a special XLisp data type called a SEQ, which is short for "sequence". (This is not part of standard XLisp, but rather part of Nyquist.) Nyquist can examine the contents of a SEQ type, modify the SEQ by adding or deleting Midi notes and other messages. Finally, and perhaps most importantly, Nyquist can use the data in a SEQ type to generate a score. In other words, Nyquist can become a Midi synthesizer.

In the examples below, key code examples are presented in SAL syntax. Lisp expressions (untested) appear in small print. Several complete working examples are available in example.sal). (There is no complete Lisp translation of example.sal.)

The SEQ Type

To create a SEQ data object:

set my-seq = seq-create()
print type-of(my-seq)
SEQ

(setf my-seq (seq-create))
(type-of my-seq)
SEQ

Reading a Midi File

Once you have a sequence, you can read Midi data into it from a file. You do this in three steps:

  1. Open the file in binary mode (using open-binary, a Nyquist extension to XLisp).
  2. Read from the file.
  3. Finally, you (normally) close the file.

For this tutorial, we will use the demo.mid file you can get by installing the "midi" extension using the Extension Manager. Since the location of this file depends on your Nyquist installation, the "midi" extension includes a simple file, use-midi-directory, that will set the current directory to lib/midi, enabling all the example code here to work.

So, if you are working through the tutorial, start with:

load "midi/use-midi-directory"

(sal-load "midi/use-midi-directory")
Alternatively, to load your own midi file, you will need a full path to the midi file or a path relative to your current directory. (Note that when you load a file with the Nyquist IDE, the IDE will set the current directory to the directory of the source code.)

Now we are ready to open, read, and close the midi file:

set midi-file = open-binary("demo.mid")
exec seq-read-smf(my-seq, midi-file)
exec close(midi-file)

(setf midi-file (open-binary "demo.mid")) (seq-read-smf my-seq midi-file) (close midi-file)

The variable my-seq now contains the midi data.

Writing a Midi File

A sequence can be written to a file. First you open the file as a binary output file; then you write it. The file is automatically closed.

set midi-file = open-binary("copy.mid", direction: :output)
exec seq-write-smf(my-seq, midi-file)
(setf midi-file (open-binary "copy.mid" :direction :output))
(seq-write-smf my-seq midi-file)

The result may not be a bit-for-bit copy of the original Midi file because the SEQ representation is not a complete representation of the Midi data. For example, the Midi file can contain headers and meta-data that is not captured by Nyquist. Nevertheless, the resulting Midi file should sound the same if you play it with a sequencer or Midi file player.

Writing a Text Representation

One very handy feature of the SEQ datatype is that it was originally developed for a text-based representation of files called the Adagio Score Language, or just "Adagio." You can write an Adagio file from a sequence by opening a text file and calling seq-write.

set gio-file = open("demo.gio", direction: :output)
exec seq-write(my-seq, gio-file, t)
exec close(gio-file)
(setf gio-file (open "demo.gio" :direction :output))
(seq-write my-seq gio-file t)
(close gio-file)

Now, demo.gio has an Adagio format version of demo.mid, but you might wonder where to find it. The current directory can be printed as follows:

print setdir(".")
(print (setdir "."))

Now, open demo.gio with your favorite editor had have a look. The basic format of the Adagio file is pretty intuitive, but you can find the full description in the CMU Midi Toolkit manual or in a chapter of the Nyquist manual, including the online version in HTML.

Reading an Adagio File

Because Adagio is text, you can easily edit or compose your own Adagio file. You should be aware that Adagio supports numerical parameters, where pitch and duration are just numbers, and symbolic parameters, where a pitch might be Bf4 (for B-flat above middle-C) and a duration might be QT (for a quarter note triplet). Symbolic parameters are especially convenient for manual entry of data. Once you have an Adagio file, you can create a sequence from it in Nyquist:

set seq-2 = seq-create()
set gio-file = open("demo.gio")
exec seq-read(seq-2, gio-file)
exec close(gio-file)
(setf seq-2 (seq-create))
(setf gio-file (open "demo.gio"))
(seq-read seq-2 gio-file)
(close gio-file)

Now you have another sequence, seq-2 similar to my-seq, but the data was loaded from the text file demo.gio.

Adding Notes to a SEQ Type

Although not originally intended for this purpose, XLisp and Nyquist form a powerful language for generating Midi files. These can then be played using a Midi synthesizer or using Nyquist, as will be illustrated later.

To add notes to a sequence, you call seq-insert-note as illustrated in this routine, called midinote. Since seq-insert-note requires integer parameters, with time in milliseconds, midinote performs some conversions and limiting to keep data in range:

function midinote(seq, time, dur, chan, pitch, vel)
  begin
    set time = round(time * 1000)
    set dur = round(dur * 1000)
    set pitch = round(pitch)
    set vel = round(vel)
    exec seq-insert-note(seq, time, 0, chan, pitch, dur, vel)
  end
(defun midinote (seq time dur chan pitch vel)
  (setf time (round (* time 1000)))
  (setf dur (round (* dur 1000)))
  (setf pitch (round pitch))
  (setf vel (round vel))
  (seq-insert-note seq time 0 chan pitch dur vel))

Now, let's add some notes to a sequence:

variable *seq*
function test()
  begin
    set *seq* = seq-create()
    exec midinote(*seq*, 0.0, 1.0, 1, c4, 100)
    exec midinote(*seq*, 1.0, 0.5, 1, d4, 100)
    exec midinote(*seq*, 2.0, 0.8, 1, a4, 100)
    set seqfile = open-binary("test.mid", direction: :output)
    exec seq-write-smf(*seq*, seqfile)
    exec close(seqfile)
  end
(defun test ()
  (setf *seq* (seq-create))
  (midinote *seq* 0.0 1.0 1 c4 100)
  (midinote *seq* 1.0 0.5 1 d4 100)
  (midinote *seq* 2.0 0.8 1 a4 100)
  (setf seqfile (open-binary "test.mid" :direction :output))
  (seq-write-smf *seq* seqfile)
  (close seqfile))

A Larger Example

This example illustrates the creation of random diatonic melody basically using a random walk. In the function melody, the loop starts at scale step 4 and adds a random integer from -2 to +2 for each new note. The initial time is 0.001 seconds (essentially zero, but staring a millisecond late allows us later to insert control change messages before the note-on message. The duration of each note is given by ioi, and the start time is advanced by ioi for each note. Finally, if the scale step falls outside the range 0 to 8, the scale step is reset to 4. midinote is then called to insert the note into seq. The loop ends when onset extends dur:

;; pitches are random diatonic walk starting at index 4 (G)
variable scale2step = vector(0, 2, 4, 5, 7, 9, 11, 12, 14)

function melody(seq, chan, dur, ioi, base, vel)
  loop 
    for scale_posn = 4 then scale_posn + random(5) - 2
    ;; start with note offset of 1ms so that we can slip in control changes
    ;;     before the first note onset:
    for onset = 0.001 then onset + ioi
    while onset < dur
    ;; reset to middle if we walk out of bounds
    if scale_posn <= 0 then set scale_posn = 4
    if scale_posn > 8 then set scale_posn = 4
    ;; channels here are 0 to 16:
    exec midinote(seq, onset, ioi, chan, base + scale2step[scale_posn], vel)
  end

You can run the whole example and more by loading the example.sal file included in the midi extension:

load "midi/example"

There is an additional function and some variables to help express time in terms of beats: See set-tempo.

To produce a MIDI file from melody, we use tones-melody:

variable *seq*
variable *duration* = 9.5

function tones-melody()
  begin with times
    set *seq* = seq-create()
    ;; adds notes to *seq*
    exec melody(*seq*, 0, *duration*, qtr, 60, 70)
    set seqfile = open-binary("tones.mid", direction: :output)
    exec seq-write-smf(*seq*, seqfile)
  end

exec tones-melody()

After evaluating tones-melody, you can play the file "tones.mid" to hear the result. (Again, you can find the file's location using print setdir(".") to get the current directory.) The times are quantized at a tempo of 100, so you can even use a notation editor to display the result in common music notation.

Synthesizing a Midi File

To synthesize sound from a Midi file, you can convert a sequence object to a Nyquist score using the function score-from-seq. You will need a Nyquist behavior to play notes. For example, here is a note behavior that uses FM synthesis:

function fm-note(chan: 0, pitch: 60, vel: 100)
  begin
    return pwl(0.01, 1, .5, 1, 1) * vel-to-linear(vel) *
           fmosc(pitch, step-to-hz(pitch) * 
                 pwl(0.01, 4, 0.5, 2, 1) * osc(pitch))
  end

(defun fm-note (chan p vel) ;; uses only p(itch) parameter
  (mult (pwl 0.01 1 .5 1 1)
        (fmosc p
               (mult (step-to-hz p)
                     (pwl 0.01 6 0.5 4 1)
                     (osc p)))))

Now let's use fm-note to play the previously created tones.mid MIDI file:

function synthesize-midi-file-1(filename)
  begin
    with score, seq = seq-create(), seqfile
    ;; first, read the file
    set seqfile = open-binary(filename)
    exec seq-read-smf(seq, seqfile)
    set score = score-from-seq(seq, synths: {{0 fm-note}})
    exec score-print(score)
    exec score-play(score)
  end

exec synthesize-midi-file-1("tones.mid")
The synthesize-midi-file-1 function creates a sequence, reads a MIDI file to create events in the sequence, then calls score-from-seq to create a score. The synths: keyword parameter (:synths in Lisp) contains a mapping from channel numbers to function names. Note that in SAL, braces used for {{0 fm-note}} create unevaluated lists, so this is a list containing a list containing zero and the unevaluated symbol fm-note. (In Lisp, you could write '((0 fm-note)) with a quote.) The default synth function is note, and you can provide a default using *: {{3 fm-note} {* pluck-note}} uses fm-note for channel 3 and pluck-note for all other channels.

The score printed by this function is as follows:

((0 0 (SCORE-BEGIN-END 0 9.602))
(0.001 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(0.601 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(1.201 0.601 (FM-NOTE :CHAN 0 :PITCH 69 :VEL 70))
(1.801 0.601 (FM-NOTE :CHAN 0 :PITCH 65 :VEL 70))
(2.401 0.601 (FM-NOTE :CHAN 0 :PITCH 64 :VEL 70))
(3.001 0.601 (FM-NOTE :CHAN 0 :PITCH 64 :VEL 70))
(3.601 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(4.201 0.601 (FM-NOTE :CHAN 0 :PITCH 69 :VEL 70))
(4.801 0.601 (FM-NOTE :CHAN 0 :PITCH 69 :VEL 70))
(5.401 0.601 (FM-NOTE :CHAN 0 :PITCH 69 :VEL 70))
(6.001 0.601 (FM-NOTE :CHAN 0 :PITCH 69 :VEL 70))
(6.601 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(7.201 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(7.801 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(8.401 0.601 (FM-NOTE :CHAN 0 :PITCH 67 :VEL 70))
(9.001 0.601 (FM-NOTE :CHAN 0 :PITCH 64 :VEL 70))
)

Multi-Channel MIDI Files

The next example in midi/example.sal adds notes on a new channel by calling add-some-piano and synthesizes the resulting MIDI file by calling synthesize-midi-file-2. Note that the synths: parameter is {{0 fm-note} {1 note}}, mapping channel 1 to a piano sound.

Examining SEQ Data

In the lib folder of the standard Nyquist installation, there is a file called midishow.lsp. If you load this, you can call some functions that help you examine SEQ data. Try the following (after running tones-melody above).

load "midishow"
exec midi-show(*seq*)
(load "midishow")
(midi-show *seq*)

You will see a printout of the data inside the SEQ data object. Unlike Midi, which stores note-on and note-off messages separately, the SEQ structure saves notes as a single message that includes a duration. This is translated to and from Midi format when you write and read Midi files.

You can also examine a Midi file by calling:

exec midi-show-file("demo.mid")

(midi-show-file "demo.mid")

This function can take an optional second argument specifying an opened text file if you want to write the data to a file rather than standard (console) output:

exec midi-show-file("demo.mid", open("dump.txt", direction: :output))
exec gc()
(midi-show-file "demo.mid" (open "dump.txt" :direction :output))
(gc)
What is going on here? I did not save the opened file, but rather passed it directly to midi-show-file. Therefore, I did not have a value to pass to the close function. However, I know that files are closed by the garbage collector when there are no more references to them, so I simply called the garbage collector gc to insure that the file is closed.

Working with Control Changes

MIDI represents more than just notes. The Seq structure is not a complete representation of MIDI (nor is MIDI a complete representation of Seq), but most of a MIDI file is captured. You can read and work with MIDI program changes, MIDI control changes, pitch bend, and channel aftertouch. (MIDI key pressure messages, also known as polyphonic aftertouch, are not stored in the Seq structure.)

Program Changes

In midi/example.sal, the add-program-changes function is an example showing how to insert program changes into a Seq. The heart of this function is:

exec seq-insert-ctrl(*seq*, 0, 0, seq-prgm-tag, 1, 0, 5)
where the parameters are: the Seq object, the time in milliseconds, 0 (representing a line number but serving no purpose here), seq-prgm-tag (12) indicating program change, the channel, 0 (representing a controller number, serving no purpose here), and the program number.

To generate a score with program change information, you must include the keyword parameter prog: with a value of true (t). Here is the call to score-from-seq in this example:

set score = score-from-seq(seq, prog: t, synths: {{* synth}})
The resulting score is:
((0 0 (SCORE-BEGIN-END 0 9.602))
(0.001 0.601 (SYNTH :CHAN 0 :PITCH 67 :VEL 70 :PROG 10))
(0.001 1.201 (SYNTH :CHAN 1 :PITCH 43 :VEL 70 :PROG 5))
(0.001 2.401 (SYNTH :CHAN 2 :PITCH 91 :VEL 70 :PROG 0))
(0.601 0.601 (SYNTH :CHAN 0 :PITCH 69 :VEL 70 :PROG 10))
(1.201 0.601 (SYNTH :CHAN 0 :PITCH 69 :VEL 70 :PROG 10))
(1.201 1.201 (SYNTH :CHAN 1 :PITCH 41 :VEL 70 :PROG 5))
(1.801 0.601 (SYNTH :CHAN 0 :PITCH 72 :VEL 70 :PROG 10))
(2.401 0.601 (SYNTH :CHAN 0 :PITCH 72 :VEL 70 :PROG 10))
(2.401 1.201 (SYNTH :CHAN 1 :PITCH 41 :VEL 70 :PROG 5))
...
(9.001 0.601 (SYNTH :CHAN 0 :PITCH 62 :VEL 70 :PROG 10))
)

Here, every channel uses the same function, synth. This function is defined in midi/example.sal and illustrates one way to use MIDI program numbers to select synthesis functions for each note.

Control Changes as Discrete Parameters

Generally, Nyquist scores contain note events with a list of attribute/value pairs, with numerical values. The MIDI program is a good example. MIDI key number and MIDI velocity also fit this model.

In contrast, MIDI control changes can represent time-varying quantities that do not fit the model of passing discrete numerical values to the note as parameters. However, it can be convenient to pass the current value of a MIDI control to a score event and ignore subsequent changes that happen during the event.

To obtain MIDI control values in this way, there are several more keyword parameters available in score-from-seq: bend: :onset specifies that pitch bend values should be added to score events with the bend: keyword; cpress: :onset specifies that channel pressure (aftertouch) values should be added to score events with the cpress: keyword; ctrl: {:onset 3 5 7} specifies that controllers 3, 5, and 7 should be added to score events with the ctrl: keyword. In the case of control changes, since there can be multiple controllers, the value of the ctrl: attribute is a list of the form ((3 0.1) (5 0.9) (7 0.5)), that is, a list of controller number/controller value pairs. All values are in the range 0 to 1 except bend: values range from -1 to +1.

The functions add-control-changes and synthesize-midi-file-4 create and play a score with control changes that use pitch bend to detune notes, channel pressure to control FM depth of modulation and volume pedal to change note volume (in combination with the vel: note velocity parameter.

Here is a portion of the score generated for this example:

((0 0 (SCORE-BEGIN-END 0 9000))
(0.001 0.601 (SYNTH4 :CHAN 0 :PITCH 67 :VEL 70 :PROG 10 :CTRL (QUOTE ((7 0.210938))) :BEND 0.88292))
(0.001 1.201 (SYNTH4 :CHAN 1 :PITCH 43 :VEL 70 :PROG 5 :CTRL (QUOTE ((7 0.210938))) :CPRESS 0 :BEND -0.921988))
(0.001 2.401 (SYNTH4 :CHAN 2 :PITCH 91 :VEL 70 :PROG 0 :CTRL (QUOTE ((7 0.210938))) :BEND -0.601636))
(0.601 0.601 (SYNTH4 :CHAN 0 :PITCH 69 :VEL 70 :PROG 10 :CTRL (QUOTE ((7 0.210938))) :BEND 0.88292))
(1.201 0.601 (SYNTH4 :CHAN 0 :PITCH 69 :VEL 70 :PROG 10 :CTRL (QUOTE ((7 0.296875))) :BEND 0.890734))
(1.201 1.201 (SYNTH4 :CHAN 1 :PITCH 41 :VEL 70 :PROG 5 :CTRL (QUOTE ((7 0.296875))) :CPRESS 0.101562 :BEND -0.265657))
...
(9.001 0.601 (SYNTH4 :CHAN 0 :PITCH 62 :VEL 70 :PROG 10 :CTRL (QUOTE ((7 0.953125))) :BEND -0.742278))
)

To retrieve the volume change from the ctrl: attribute, the following code is used:

  begin
    with volume = second(assoc(7, ctrl))
    set vel *= #?(volume, volume, 1)
    ...
  end
Here, assoc(7, ctrl) searches for a pair in ctrl beginning with 7 (the Volume controller number). If found, second(assoc(7, ctrl)) retreives the value. If not, nil is returned, and second(nil) returns nil. Since the variable volume might now be nil (because no Volume control change occurred on this channel before the score event), the expression #?(volume, volume, 1) replaces nil with default value 1.

Control Changes as Continuous Parameters

Nyquist is quite different from MIDI in that sounds are functions of parameters and there are no real-time updates to parameters after then sound is started. However, Nyquist parameters can be SOUNDs which evolve over time. So to use MIDI control changes to affect the evolution of sounds in Nyquist, we convert control changes into SOUNDs and pass the sounds to synthesis functions.

There are over 2000 potentially different key pressure controls (16 channels x 128 keys), but these are not supported by the Seq structure. That still leaves about 2000 control change functions. To save space and computation, the default sample rate for control change functions is 441 Hz. Only control functions of interest are captured, and controllers with no control change messages in the MIDI file are represented by nil.

The functions add-continuous-control creates the MIDI file continuous.mid, and synthesize-midi-file-5("continuous.mid") synthesizes the file using continuous pitch bend to modulate the pitch fm-note, continuous channel pressure as a discrete parameter to control FM modulation depth as before, continuous control 10 (pan) to pan piano notes, and continuous control 1 (modulation wheel) to control the depth of vibrato of a sine tone.

To insert changes into the Seq, seq-insert-ctrl is used, for example:

        exec seq-insert-ctrl(*seq*, time, 0, seq-ctrl-tag, 
                             2, 1, vib-depth(onset, offset))
which inserts a control change on channel 2 for controller 1 with a value returned by vib-depth. The values are integers from 0 through 127, just like in MIDI.

To obtain these changes from the resulting MIDI file, the call to score-from-seq looks like this:

set score = score-from-seq(seq, prog: t, synths: {{* synth5}},
                           bend: :contin, cpress: :onset, ctrl: :contin)
This will capture all pitch bend and control changes for all channels and add channel pressure data as cpress: attributes for each score event. The pitch bend and control changes SOUNDs appear as the contin: attribute of each score event.

To capture continous control data in scores, Nyquist uses an special object that stores pitch bend, channel pressure, and an array of control changes. Each control is represented by a single SOUND with a duration that spans the entire score. The object is passed as the value of contin: to each score event. There are a maximum of 16 of these objects, one per channel, so the same object will passed to many objects. (This should not be a surprise: e.g. MIDI pitch bend affects every note sounding on a channel when the bend message is send.)

Let's look at the implementation of the piano note with panning from this example. The pan function takes a mono sound and a pan location from 0 to 1:

function note5(chan: 0, pitch: 60, vel: 100, contin: nil)
  begin
    return pan(note(chan: chan, pitch: pitch, vel: vel),
               ctrlfn-ctrl(contin, 10))
  end
Most of the note5 parameters are simply passed on to note to make the piano sound. To obtain a pan control, we write ctrlfn-ctrl(contin, 10), which extracts the control function corresponding to controller 10 (pan). If contin is nil or if there were no controller 10 changes, this expression is equivalent to const(0) so the pan control is zero (so the default is pan left). Otherwise, the function returned is the entire pan control for the entire score, but the pan function will only use that part of the function that overlaps with the synthesized note.

In fm-note5, you can see how ctrlfn-bend(contin) is used to obtain the pitch bend function, and in sine-note5 you can see how ctrlfn-ctrl(contin, 1) is used to control vibrato depth.

Score to MIDI File

Finally, there is a simple way to generate MIDI files with only notes using Nyquist. The function score-write-smf is simple to use and adequate for most note-oriented work such as exporting Nyquist scores to a music notation program.
score-write-smf(score, filename,
                [programs, as-adagio, absolute])
is called with a Nyquist score, a filename (string), and some optional parameters. It writes a MIDI file with tempo 100 (always) so if you want to derive notation, create notes in a steady tempo and stretch the score if necessary to a tempo of 100 BPM. Results are generally better if times and durations are quantized to quarters, eights, etc.

For each pitch in the score, a MIDI note is produced. Events with no :pitch attribute are ignored. Events with a list value for :pitch generate one note for each pitch. Pitches are rounded to the nearest integer and an error occurs if the :pitch is not a number or list of numbers.

You can insert program changes (at time 0) using the programs parameter, which a list of program numbers for consecutive channels, starting with channel 0. E.g. {5 23} enters program 5 for channel 0 and program 23 for channel 1, while channels 2 through 16 receive no program change message.

You can also write an ASCII text file in the Adagio format by setting as-adagio to non-null (e.g. t), and in that case, you can use the optional absolute parameter to specify that start times are relative, using N notation for the next note time, or absolute, using T notation for the start time of each note. The default is nil (relative).