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: midi.lsp;
Additional File: midi.sal;
Additional File: demo.mid.b64;
Additional File: example.sal;

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 along with a sound behavior to generate sound. In other words, Nyquist can become a Midi synthesizer.

In the examples below, code is presented in SAL syntax (also available in midi.sal) followed by LISP syntax in small print (also available in midi.lsp).

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, voice, 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, voice + 1, pitch, dur, vel)
  end
(defun midinote (seq time dur voice 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 (1+ voice) 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 note onset times using the negative exponential distribution. You can run the whole example by loading the example.sal file included in the midi extension:

load "midi/example"
As seen here, we create a negative exponential distribution by creating uniformly distributed random times, and then sort these. The function that creates times and then quantizes them to 24ths of a beat is shown here. The len parameter is the number of times, and the average-ioi parameter is the average inter-onset-interval, the average time interval between two adjacent times:

;; create list of random times and sort it
;; dur in ms.
function neg-exp-gen(len, average-ioi)
  begin
    with dur = len * average-ioi, times-list
    loop repeat len
      exec push(dur * random(10000) * 0.0001,
                times-list)
    end
    ;; to name the "less than" function, we cannot type
    ;; '<' because that is not a symbol name in SAL. Therefore
    ;; we use the string "<" and look up the symbol using
    ;; intern("<").
    set times-list = sort(times-list, intern("<"))
    display "initial list", times-list
    ;; map list to 24ths:
    return quantize-times-to-24ths(times-list)
  end
(defun neg-exp-gen (len average-ioi)
  (let ((dur (* len average-ioi)) times-list)
    (dotimes (i len)
	     (push (* dur (random 10000) 0.0001) 
                   times-list))
    (setf times-list (sort times-list #'<))
    (display "initial list" times-list)
    ;; map list to 24ths:
    (quantize-times-to-24ths times-list) ))

We add a few functions to help express time in terms of beats:

function set-tempo(tempo)
  begin
    set qtr = 60.0 / tempo
    set eighth = qtr * 0.5
    set half = qtr * 2
    set whole = qtr * 4
    set sixteenth = qtr * 0.25
  end
if ! boundp(quote(qtr)) then exec set-tempo(100)

function quantize-times-to-24ths(list)
  return mapcar(quote(quantize-time-to-24ths), list)

function quantize-time-to-24ths(time)
  return (qtr / 24.0) *
         round(24 * time / qtr)
(defun set-tempo (tempo)
  (setf qtr (/ 60.0 tempo))
  (setf eighth (* qtr 0.5))
  (setf half (* qtr 2))
  (setf whole (* qtr 4))
  (setf sixteenth (* qtr 0.25)))

(if (not (boundp 'qtr)) (set-tempo 100))

(defun quantize-times-to-24ths (list)
  (mapcar #'quantize-time-to-24ths list))

(defun quantize-time-to-24ths (time)
  (* (/ qtr 24.0)
     (round (* 24 (/ time qtr)))))

Now, let's create Midi notes using negative exponential based onset times:

function melody(seq, onsets)
  loop for onset in onsets
    exec midinote(seq, onset, sixteenth, 1, 48 + random(24), 100)
  end

function neg-exp-melody()
  begin
    set *seq* = seq-create()
    ;; adds notes to *seq*
    exec melody(*seq*, neg-exp-gen(50, eighth))
    set seqfile = open-binary("negexp.mid", direction: :output)
    exec seq-write-smf(*seq*, seqfile)
  end

exec neg-exp-melody()
(defun melody (seq onsets)
  (dolist (onset onsets)
    (midinote seq onset 16th 1 (+ 48 (random 24)) 100)))

(defun neg-exp-melody ()
  (setf *seq* (seq-create))
  (melody *seq* (neg-exp-gen 50 eighth)) ;; adds notes to *seq*
  (setf seqfile (open-binary "negexp.mid" :direction :output))
  (seq-write-smf *seq* seqfile)

(neg-exp-melody)

After evaluating neg-exp-melody, you can play the file "negexp.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 to 24th notes 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, use the seq-midi control construct. This behavior reads the data in the seq object and for each note, creates an instance of the behavior you provide. You will need an instrument, so let's define a simple FM instrument to play the notes of the Midi data:

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

(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 defined neg-exp-melody, which was saved in the variable *seq*. Here, SAL and Lisp will differ. We use seq-midi-sal to convert the sequence into a sound:

play seq-midi-sal(*seq*, quote(fm-note))

In Lisp, we use seq-midi to convert the sequence into a sound:

(play (seq-midi *seq* (note (chan pitch vel) (fm-note chan pitch vel))))
The seq-midi and seq-midi-sal construct automatically uses time transformations to place notes at the proper time and to stretch them to the indicated duration. In addition, they pass the chan, pitch, and vel parameters based on the Midi data to your behavior. In this simple example, we ignored chan and vel, but we used pitch to get the right pitch. You might write a more complicated behavior that uses chan to select different synthesis algorithms according to the Midi channel.

The syntax for these constructs are as follows: seq-midi-sal is a fairly straightforward function except that it takes functions as parameters. In SAL, the normal way to pass a function as a parameter is to pass the symbol than names the function. E.g. to pass the function fm-note as a parameter, we form the symbol that names the function using quote(fm-note). There are function parameters for each type of Midi message. The second parameter is a function that is called for each Midi note. It should take 3 parameters: channel, pitch, and velocity. The remaining function parameters are keyword parameters that default to functions that do nothing. Include these parameters if you want to be called to process each Midi control (ctrl:), bend (bend:), aftertouch (touch:), or program change (prgm:) message. The ctrl: function must take three parameters: channel, control number, and control value. The bend:, touch:, and prgm: functions must take two parameters: channel and value.

seq-midi-sal(my-seq, quote(note-fn),
             ctrl: quote(ctrl-fn),
             bend: quote(bend-fn),
             touch: quote(touch-fn),
             prgm: quote(prgm-fn))

You can also call seq-midi-sal from Lisp.

The seq-midi construct can only be called from Lisp and may be a little confusing. The syntax is shown below. The symbol note appears to be a function call, but it is not. It is really there to say that the following parameter list and behavior expression apply to Midi notes. There can be other terms for other Midi messages. Thus, you can write in-line code to handle Midi messages. The filter-expr is optional. If present, it must evaluate to non-nil or the note is ignored. For example, the filter-expr (= chan 2) will select only notes on channel 2.

(seq-midi my-seq
   (note (chan pitch velocity) filter-expr (my-note pitch velocity))
   (ctrl (chan control value) (...))
   (bend (chan value) (...))
   (touch (chan value) (...))
   (prgm (chan value) (setf (aref my-prgm chan) value))

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 neg-exp-melody above).

load "midi/midishow"
exec midi-show(*seq*)
(load "midi/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.