midi
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
.)
SEQ
TypeTo 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
Once you have a sequence, you can read Midi data into it from a file. You do this in three steps:
open-binary
, a
Nyquist extension to XLisp). 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.
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.
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.
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
.
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))
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.
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)) )
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.
SEQ
DataIn 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.
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.
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) ... endHere,
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.
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)) endMost 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-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).