A primary goal of KeyKit has been to build a highly extensible system,
therefore the built-in capabilities of the KeyKit language are
intentionally minimal. The default user-defined function library
contains KeyKit code for an extensive multi-window
sequencer-like environment with
sliders, buttons, and other tools for creating and editing MIDI music.
A separate reference manual and tutorial describe this user interface.
This document describes only those capabilities that are
actually built into the KeyKit language.
If you're just getting started with KeyKit, this is not the document
you want to read first.
Any variable that is not global (either implicitly, because it starts with
an upper-case character, or explicitly, because of a global statement
or function) automatically becomes a local variable in
the function in which it is encountered. Previous versions of KeyKit
required that you "declare" local variables by including them as extra
arguments in the parameter list of a function (as in awk). This is
no longer required. So, local variables are created merely by using them.
and an attribute value (terminated with another grave quote)
Some of the modifiers don't make sense (but are not disallowed)
for some note expressions, for example the rest
and pitch
expressions will
ignore the octave modifier.
If any of these modifiers is omitted from a note expression, its
value defaults
to the value of that modifier for the
previous note. For example, all the notes in the
phrase 'ao2v90,b,f,d' would be in
the 2nd octave and have a volume of 90.
At the beginning of each constant phrase, the default values are:
octave 3, volume 63, duration 96 (the number of clicks in a beat), channel 1.
The separator between notes in a phrase constant
determines the default starting time of the next note.
A comma separator (possibly surrounded by white space) sets the default
starting time to the end of the previous previous note. Hence
the phrase 'e,f,g' is equivalent
to 'et0,ft96,gt192'. If there is no comma separator (ie. only
white space) between notes, the
default starting time will be the starting time of the previous note, so
the phrase 'c e g' is a chord, equivalent to 'ct0,et0,gt0'.
Naturally, an explicit time modifier on a note will
override the default starting time implied by the separator.
As a convenience,
the o preceding positive octave numbers can be omitted,
e.g. b4 is b in the 4th octave.
Negative octave numbers
must
be specified with the o, to avoid
ambiguity with the - used for flats.
Normally, the length of a phrase constant is equal to the ending time
of the last note. The length can be explicitly set by
using an l (lower-case L) followed by the length in clicks.
E.g. 'a,b,c,l96' would have a length of 96 clicks (even though
some of the notes extend beyond that).
A phrase constant can include arbitrary
MIDI bytes by using x followed by hex characters. For example, the
constant 'xb07b00' would be a phrase consisting of a 3-byte MIDI
message - an all-notes-off for channel 1. MIDI byte messages can also
include a time modifier if timing
of the message is important. For example, 'xfe,xfet24' is a
phrase containing
2 single-byte messages, the second one occurring at click number 24.
MIDI bytes can be combined with normal notes
in the same constant phrase, e.g. 'e,f,g,xc005,a,b' is a phrase that
contains a program change command in the middle of several normal notes.
As a convention for embedding arbitrary
textual information
in a MIDI message, a KeyKit phrase constant can contain
a string enclosed in double quotes, e.g. 'a,b,"hello world",c,d'.
This type of note is called a
"text note."
It is turned into a system-exclusive message, beginning with
the bytes f0, 00, 7f, followed by the
ASCII characters of the string, and ending with the byte f7.
A normal note implies two MIDI messages, a note-on and
a note-off. In some cases, you may want only the note-on or note-off.
These can be specified with an initial + (for note-on) or - (for
note-off). For
example, '+a,-at96' is equivalent (in terms of MIDI output)
to 'a'.
Expressions
Expressions can make use of the following operators, listed
in order of increasing priority:
unary minus, not, one's complement
If two strings are combined with the + operator, the result is
the concatenation of the strings. If two strings are
compared with a relational operator (e.g. == != <= ),
an ASCII string comparison is done.
The ~~ relational operator can be thought of as a "contains" operator
- the result is true if the first operand contains a substring that matches the
second operand, a regular expression.
If two phrases are
compared with a relational operator, all of their notes are used
in the comparison. Two notes are equal only if all of their attributes
(pitch, duration, channel, volume, type) are equal.
Phrase Operators
Phrases can be manipulated with the following operators:
- phrase + phrase
-
The result is the concatenation
of the 2 phrases in series, using the length (NOT the ending time
of the last note) of the first phrase
as the starting time of the second phrase.
- phrase | phrase
-
The result is the merging of
the 2 phrases in parallel, and the length is
the maximum of the 2 lengths.
- phrase - phrase
-
The result is a copy of the first phrase, after removing all notes
that match notes in the second phrase.
- phrase & phrase
-
The result contains all notes in the first phrase
that exactly match notes in the second phrase.
- phrase % number
-
The result is a single-note phrase containing the n-th note
of the first operand, where n is the value of the second operand.
For example, 'a,b,c'%2 would be equal to 'bt96'.
Notice that the original time of the note is retained.
This operator can also be used on the left-hand side of an assignment
(e.g. ph%2='c' )
to replace (or delete, if the right-hand side is the null phrase, '' )
a single note of a phrase.
- phrase { condition }
-
The result of this operation (referred to as a select),
is a phrase containing all notes for which the given
condition is true. The condition is repeatedly evaluated, with
the special token ?? being replaced with each note in the original
phrase. For example, 'c,d,e,f,g'{??.pitch>'e'}
would be equal to 'ft288,g'.
Note and Phrase Attributes
Attributes of phrases and the notes within them
can be manipulated with a syntax reminiscent of
C structure elements. For example, 'c'.pitch
is equivalent to 60, the MIDI pitch value for that note.
The valid attributes are:
- pitch
-
MIDI pitch value (0-127).
- vol
-
MIDI volume value (0-127).
- chan
-
MIDI channel number (1-16).
- dur
-
Note duration, in clicks.
- time
-
The starting time of a note,
in clicks, relative to the beginning
of the phrase in which it resides.
- length
-
The length of a phrase, in clicks. This attribute is
independent of the duration and placement of notes within
the phrase. It's primary use is
in the semantics of the phrase+phrase operation; the starting
time of the second phrase is the length of the first phrase.
- type
-
This attribute of a note indicates what type it is (for example, whether it's
a note or a sysex message). The possible values are pre-defined constant
values, and a list is given below.
- number
-
Within the conditional expression of a select operation,
this attribute can be used to refer to the position (starting
at 1) of a
note within the selected phrase. For example,
the expression 'a,b,c'{??.number>2} is equivalent to 'c'.
- attrib
-
This string-valued attribute of a note can be used to
store arbitrary user-defined information. To save memory, this feature
may not be enabled on all versions of KeyKit.
- flags
-
This integer-valued attribute of a note can be used to
store arbitrary user-defined information. By convention, the lowermost
bit of this integer is used to identify picked notes in
the graphical interface of KeyKit.
Here is the list of possible values for the .type attribute:
- NOTE
-
a normal note, implying a MIDI note-on and note-off
- NOTEON
-
a note-on only, e.g. '+a'
- NOTEOFF
-
a note-off only, e.g. '-a'
- CHANPRESSURE
-
a channel pressure message
- CONTROLLER
-
a controller message
- PROGRAM
-
a program change message
- PRESSURE
-
a pressure message
- PITCHBEND
-
a pitch bend message
- SYSEX
-
a system exclusive message
- SYSEXTEXT
-
a system exclusive "text note" (see above)
- POSITION
-
a song position pointer message
- SONG
-
a song message
- CLOCK
-
a clock message
- STARTSTOPCONT
-
a start, stop, or continue message
- MIDIBYTES
-
an unrecognized sequence of MIDI bytes
The value of an attribute for a multi-note phrase is
the average of the attribute values of the individual
notes. When used on the left-hand
side of an assignment, an attribute expression changes
all notes in the phrase. For example, x='a,b,c' ; x.vol = 60
would set the volume of all 3 notes. Increment, decrement, and
operator-assignment statements work on each note independently.
For example, x='c,d,e'; x.pitch += 2; print(x) would
produce 'd,e,f+'. However, the right-hand side is only
evaluated once. For example, x='c,d,e'; x.pitch += rand(4); would
add the same random value to the pitch of each note.
An attribute of a single note within
a phrase can be obtained and set by using the % operator. For
example, x='c,ed12'; x itch=x itch; print(x) would
produce 'e,ed12'.
Type Conversions
When used in numeric expressions, strings are converted to
numbers.
A musical phrase (constant or variable) can also be used in a
numeric expression -
its value is the pitch value (0 to 127) of its first note
or, if it's a non-NOTE note (e.g. PROGRAM or MIDIBYTES),
the value of its first byte.
Explicit conversion to a particular type can be done with a built-in function
whose name is the same as the type (similar in style to C++). For example,
string(4+5) is equivalent to "9", integer(4.9) is
equivalent to 4, float("9"+"."+"9") is equivalent to 9.9,
and phrase( "'a" + ",b'" ) is equivalent to 'a,b'. Note that
when converting a string to a phrase, the value of the string must
contain the surrounding single quotes. And, a phrase converted
to a string will contain surrounding single quotes. Strings converted
to integers can be interpreted as hexidecimal if they include an initial "0x",
for example integer("0x40") is equivalent to 64.
Looping and Conditions
As in awk, the for(var in array) construct
iterates over the current set of indicies for an array. Since arrays
are associative, the values assigned to var are always strings,
although if the original index value was a phrase, it can easily
be converted back into a phrase with a type conversion: phrase(var).
The similar for(var in phrase) construct can
be used to iterate
over the notes in a phrase; the value of var becomes a
single-note phrase, once for each note in phrase.
As in awk,
the if(string-expression in array) construct can be
used to test whether a particular array element exists,
without having the side effect of creating the array element.
A similar
construct using phrase
expressions, if(phrase-expression in phrase-expression),
is true if each note in the first phrase is included anywhere
in the second phrase.
Only pitch is relevant in this test; time, volume, channel, and
duration are ignored.
Functions
User-defined functions can have arguments and return values of any type.
The name of a function is actually a normal variable whose value
can be considered a pointer to the contents of the function.
This function pointer can be manipulated like any other value - for example
it can be used as a function argument or return value.
Any expression whose type is a function pointer can be used to call the
function, by following the expression with parenthesis.
This code illustrates:
# These functions expect a single note as an argument,
# and return a chord based on it.
function major(k) { return(k|transpose(k,4)|transpose(k,7)) }
function minor(k) { return(k|transpose(k,3)|transpose(k,7)) }
# The return value of randchord() will be a function pointer.
function randchord() {
if ( rand(2) == 0 ) # True 500f the time.
return(major)
else
return(minor)
}
f = randchord()
f('c') # Plays either 'c' major or 'c' minor.
randchord()('c') # Ditto.
A function definition can actually be used in an expression - its value is
the new function's pointer value. So, the return(major) statement
above could actually be written as:
return( function major(k){return(k|transpose(k,4)|transpose(k,7))} )
An "in-line" function doesn't need a specific name - a ?
can be used instead, and a unique function name will be substituted for it.
So, another variation would be:
return( function ? (k) {return(k|transpose(k,4)|transpose(k,7))} )
Variable Arguments
There are several mechanisms for handling a variable
number of function arguments. First,
the built-in argv() function lets you grab individual arguments
by position, and nargs() tells you how many arguments there are:
function add(...) {
sum = 0
for ( n=0; n<nargs(); n++ )
sum += argv(n)
return(sum)
}
The special token ... can be used in an argument list to represent
a variable number of arguments, and can actually be "passed" in the argument
list of another function call:
function compute(sign,...) {
sum = add(...)
return(sum * sign)
}
Arguments can be packed into an array by giving
two parameters to argv(). For example, the array returned
by argv(3,10) would contain the values of argv(3) up to (but not
including) argv(10).
Such an array can then be "unpacked" to create an argument list, by using
varg(). This lets you store a list
of arguments in a single value (the array), to be used later:
function savecall(f,...) {
# save a function and argument list
Callfunc = f
Callargs = argv(1,nargs())
}
function docall() {
# call the function we saved, with the saved argument list
Callfunc(varg(Callargs))
}
Function Loading
The #library statement is used to specify the file that contains
the definition of a function. By convention, each directory of the
Keypath should contain a file named keylib.k which
contains #library statements for all of the functions defined by
the files in that directory. When KeyKit encounters a reference
to an undefined function, it will automatically read the keylib.k
files in order to find the file that defines the function.
Variable Control
The statement "undefine variable" causes the specified variable
or function name to become undefined.
This is usually used to force the re-loading of a function after its
source file has changed.
The statement "global variable" forces the specified variable to
be considered global. There is also a function global() that can
be used to do the same thing in an expression. This is often used
when you want to use a function name as a value and the function
is not yet defined, since otherwise it would be considered a new local variable.
The statement "readonly variable" causes
the specified variable to become readonly - any subsequent attempts to
change its value will fail. This can be used to protect
important functions or variables. The onchange() function
can be used to automatically call a function whenever
the value of a specified variable is changed. The readonly
and onchange features of KeyKit have not been used
very much - they may be deleted someday, unless a good use is found for them.
Fifos
Fifos are used for a variety
of purposes within KeyKit. A fifo is a first-in-first-out queue
of arbitrary KeyKit data values (including array and function pointers).
Data values sent to a fifo need not be of the same type. A fifo is created
by the open() function, data is inserted with the put()
function, and data is retrieved with the get() function.
The put() function never blocks - an arbitrary number of data
items can be collected in the queue before they are retrieved.
The fifosize() function can be used to see how many unread
items are in a fifo.
The get() function will block if there are no items in the fifo.
Special fifos are used to
communicate with the console, mouse, and MIDI. These fifos are automatically
opened when KeyKit is booted, and their values are available in
the global variables Consolefifo, Mousefifo, and Midiinfifo.
For example, this code
monitors and prints console input:
for (;;)
print(get(Consolefifo))
The Consolefifo will return each character typed on the console as
a separate string. Another special fifo is the Midiinfifo:
for (;;)
print(get(Midiinfifo))
Each item read from Midiinfifo will be a single note-on,
note-off, or MIDI sysex message. Complete notes will not be
seen - if you want to process complete notes, you
should make use of the Recorded variable (described later)
that collects all MIDI input. In fact, most processing of MIDI input
should be done by using the Recorded variable, to avoid the
inefficiency of processing each note separately.
The Mousefifo can be read to detect changes in the mouse state:
for (;;) {
m = get(Mousefifo)
print("Mouse button state = ", m["button"])
print("Mouse x,y position = ", m["x"], ",", m["y"])
}
As this example shows, the value received from the Mousefifo is
an array - the subscripts of its elements
are "button", "x", and "y".
Fifos are also the mechanism by which files are read:
f = open("/etc/passwd")
for ( n=1; (v=get(f)) != Eof; n++ )
print("line ",n," is ",v)
close(f)
Values obtained from a file-reading fifo are normally strings that
contain entire lines from the file. The special value Eof is
returned when the end of the file is reached.
If you want to read individual characters (i.e. bytes) rather than entire
lines, you can use
the fifoctl() function to declare that the fifo should be
handled in "binary" mode:
f = open("/unix","r")
fifoctl(f,"type","b") # turn on "binary" mode for reading fifo f
for ( nc=0; get(f) != Eof; nc++ ) ;
close(f)
if ( nc > 500000 ) print("Too big.")
File fifos are opened for reading by default, but can also be
opened for writing, by using the "w" flag:
f = open("/tmp/debug","w")
put(f,"hello world\n");
close(f)
Fifos reading from pipes can (on those systems where pipes are supported)
be created by adding a third argument:
f = open("pwd","r","pipe")
pwd = get(f)
close(f)
print("The current directory is ",pwd)
Writing to a pipe can be done as follows:
f = open("lp","w","pipe")
put(f,"This should appear on the printer\n")
close(f)
Finally, if open() is given no arguments at all, it creates a generic
fifo that can be used for inter-task communication.
Tasks
KeyKit is multi-tasking. Any number of tasks can be executed simultaneously,
and their execution is interleaved along with realtime I/O at a very
fine-grained level. Tasks are
relatively cheap (in terms of execution and space overhead) to create and use.
It is expected that dozens, if not hundreds, of tasks will be alive
at any given time - however, most of them will be blocked on a fifo,
and when blocked or sleeping a task imposes no overhead.
All tasks have access to the same global variables.
A new task is created by invoking a function
with the task statement. The following example shows the
creation of a task that continuously monitors MIDI input:
# Play a chord whenever a note below a given pitch is seen.
# The 'chordfunc' parameter should be a function value,
# which is called to generate the chord.
function autochord(chordfunc,limit) {
while ( (n=get(Midiinfifo)) != Eof ) {
if ( n.pitch < limit )
realtime(chordfunc(n),0) # play the chord via MIDI output
}
}
function major(nt) {
return ( nt | transpose(nt,4) | transpose(nt,7) )
}
task autochord(major,64) # a C chord will be played whenever
# anything below pitch 64 is seen
print("Play away...")
After the autochord() function was invoked as a task,
it would continue on in the background,
and the "Play away..." message would be immediately printed. From then
on, any time a note below pitch 64 was seen on MIDI input, a major chord
corresponding to that note would be generated.
The realtime() function used in this example will play a phrase
via MIDI output - it will be described in more detail later.
Tasks that are blocked, either waiting for a message on a fifo or
waiting for a specific time, impose no overhead. The task
statement returns an integer value which is a task id - this value
can be given to the kill() function when you want to terminate the task:
tid = task autochord(major,64)
kill(tid)
Communication and synchronization between tasks is typically done through fifos,
since a get() on a fifo will block until there is something to read.
The wait() function can be used to wait until a particular task
is finished, and the sleeptill() function will wait until a particular
(absolute) time is reached. These concepts are demonstrated by
the example below, which creates an interactive mode in which
pressing keys on either the console or MIDI keyboard generates chords.
# A utility function for continuously
# forwarding messages from one fifo to another.
function fifoforward(fromfifo,tofifo) {
for ( ;; )
put(tofifo,get(fromfifo))
}
# Generate chords in response to messages received on fifo 'f'
function chordfifo(f) {
for ( ;; ) {
m = get(f)
# The message can be a single note (from the MIDI fifo)
# or a single-character string (from the Consolefifo).
if ( typeof(m) == "phrase" )
realtime( major(m), 0 )
else if ( m>="a" && m<="g" )
realtime( major(phrase("'"+m+"'")) )
}
}
function taskdemo() {
fmerge = open()
tid1 = task fifoforward(Midiinfifo,fmerge)
tid2 = task fifoforward(Consolefifo,fmerge)
tid3 = task chordfifo(fmerge)
sleeptill( Now+32b )
kill(tid1)
kill(tid2)
kill(tid3)
}
The fifoforward() function shown above is a simple utility that
continuously reads messages from one fifo and forwards them to another fifo.
The taskdemo() function spawns two instances of this utility, to forward
messages from the Midiinfifo and Consolefifo fifos into a single fmerge fifo.
The fmerge fifo is then read by the chordfifo() function, generating
a chord in response to each message it receives. After spawning the 3 tasks
that will do all the work, taskdemo() uses sleeptill() to wait
until 32 beats have elapsed, and then kills the 3 tasks.
Tasks can use the onexit() function
to arrange for cleanup operations when they are terminated or killed.
A task can also use onexit() to restart itself, resulting in
a robust daemon-like task that can recover from run-time errors.
Printing
The built-in printf function is used for formatted printing:
printf("num=0\n",num)
The output of the built-in printf function is always sent to "standard
output", which, in a graphics environment,
may result in a separate pop-up window.
Formatted output to other destinations can be done with sprintf:
f = open("tmpfile","w")
put(f,sprintf("The current tempo is 0\n",tempo()))
close(f)
Note that in the default user interface of KeyKit, the
printf function is immediately redefined so that it sends output to
the Console window.
The user-defined function library defines a
print function that is useful for simple printing - it merely prints its
arguments separate by spaces. For clarity, this function
(print) is used in most of the examples in this document,
rather than printf.
Writing and Reading Phrase Files
Files containing KeyKit phrases are by convention named with a ".k" suffix.
Such files are typically created with the following function
(found in the standard user-defined library):
function writephr(ph,fname) {
f = open(fname,"w")
put(f,string(ph))
close(f)
}
writephr(ph,"phrasefile.k") # example usage
Phrases can be read from files with the readphr() function
(built-in, not user-defined):
ph = readphr("phrasefile.k")
Realtime
KeyKit can do things in realtime. Time in KeyKit is measured in terms of
clicks, and the relationship of clicks to actual time is determined by
by the current tempo and the value of the variable Clicks.
The currrent tempo is set with the tempo() function and is specified
in terms of microseconds per beat.
The value of Clicks is the the number of clicks per beat.
KeyKit's default settings are:
Clicks = 96 # 96 clicks per beat
tempo(500000) # 500000 microseconds per beat, i.e. 120 bpm
The variable Now contains the current time, in clicks,
and is continuously updated. The sleeptill() function can be used to
pause until a specified absolute time:
function reminder(tm,msg) {
sleeptill(tm)
print(msg)
}
task reminder(Now+16b,"16 beats are up!")
This example would print the message "16 beats are up!" after 16 beats,
which, with the default tempo and Clicks values, would be 8 seconds.
The tempo of realtime playback can be set explicitly with
the tempo() function, whose argument is the number of
microseconds per beat. The tempo can also be varied during playback
with special
"text notes"
(described previously) of the form "Tempo=###",
where ### is the desired speed. For example, the phrase
'"Tempo=500000",c,g,"Tempo=400000",c,g,"Tempo=300000",c,g'
would slowly speed up during its playback.
These special text notes also get translated
into the tempo messages of a Standard MIDI File.
MIDI Output
Realtime MIDI output is managed by the realtime() function, which
creates a new task responsible for playing the output.
The following statement:
realtime('c e g, f a c')
would play 2 chords (C and F major) via MIDI output, beginning immediately.
A second argument to realtime() can specify the absolute
time at which to begin playback:
tid = realtime('c e g, f a c', Now+4b )
This would begin playing the phrase after 4 beats. Because realtime()
spawns a new task, it will always return immediately - the playing of
the MIDI output is done in the background by the new task.
The return value of realtime() is the id of the new task -
you can use it to kill the task like any other, thereby terminating
the playback of the phrase.
MIDI Input
As shown previously, MIDI input can be read from the special MIDI fifo.
Messages read from this fifo will be isolated note-ons and note-offs, suitable
for use when producing echoes and other realtime effects. To get and
manipulate MIDI input at a higher level, you should make use of the
special Recorded variable - a global phrase variable that
contains a complete copy of all MIDI input. This example takes whatever
MIDI input has occurred during the previous 4 beats, flips it, and plays it:
ph = cut(Recorded,CUT_TIME,Now-4b,Now)
ph = flip(ph)
realtime(ph)
Synchronization
Some of the examples shown previously have made cavalier use
of the Now variable. Precise scheduling of MIDI output and
other things requires a bit more care, though, since the value of Now
is continually changing, and since KeyKit does not execute
infinitely fast. This example attempts to play a drum pattern and melody
simultaneously:
realtime(drums,Now) # equivalent to realtime(drums)
realtime(melody,Now)
Since the value of Now might be incremented between the execution of these two
function calls, we would not be guaranteed that the drums and melody would
be in perfect sync. A slightly better method would be:
start = Now
realtime(drums,start)
realtime(melody,start)
This would synchronize the playback of the two phrases. However, the first note
of the drums phrase might still get played before the first note of the melody
(though they would be in perfect sync thereafter).
This is usually not enough of a problem to worry about, but if you really want to
schedule phrases independently and be assured of them starting playback
at exactly the same time, you should guarantee that they are all scheduled
sometime in the future:
start = Now + 1b/4
realtime(drums,start)
realtime(melody,start)
Of course, for this example you could finesse the whole issue with:
realtime( drums | melody )
Realtime Variables
Several global variables that have special meanings
and effects on the realtime operation of KeyKit:
- Clicks
-
The number of clicks in a single beat. The default is 96.
- Current
-
This phrase contains, at any point in time, all notes that are being held down (ie. note-ons without note-offs) at that time.
- Merge
-
If non-zero, all MIDI input is echoed to MIDI output. This is
used when your MIDI controller is separate from your MIDI synth.
- Now
-
This is the current time, in clicks.
- Record
-
If the value of this variable is zero, recording of MIDI input
is disabled, otherwise recording is enabled. The default value is 1.
- Recorded
-
This phrase records all MIDI input when Record is non-zero.
- Recsched
-
If non-zero, the Recorded phrase also records any MIDI output generated by KeyKit.
A complete list of special variables can be found
in the
keyvar(5)
manual page. There are many things that can
be tweaked through those variables, so reading that manual page is important
if you want to use KeyKit effectively.
Objects
Objects encapsulate methods and data.
The syntax of object references is similar to that of C structures -
object.data . However,
an object is treated more like a pointer to a structure than a structure.
For example, copying an object value does not duplicate the object, it
merely duplicates the pointer to the object.
The data elements of an object can take on arbitrary values, but these values
can only be accessed from within a method of that object.
So, the only way
in which objects are manipulated is through invocation of their methods,
and the data elements within an object are completely hidden.
Methods are used like functions. This example
invokes the method named meth of an object named obj,
passing it 3 arguments:
obj.meth(1,2,3)
While executing a method of an object, the special symbol $ is an
alias for that object.
(See below for an explanation of what the special symbol $$ means.)
So, the statement:
$.data = 99
would set the value of the data element in the current object (the
object on whose behalf the method is being executed). Since data elements
of objects are only accessible within methods,
the $ notation is actually the only way that object data can be
referenced. The $ notation also becomes a useful visual flag that
distinguishes object data from local variables.
To invoke a method whose name is known only at run-time, you can use
the following notation:
methname = "meth"
obj.(methname)(1,2,3)
Any expression in parenthesis following an object. will be treated
as a string value that will be used as the method name. This
lookup is (obviously) done at execution time, and in fact
even explicitly-named methods are executed by doing a lookup
at execution time.
Object Definition and Creation
Objects are defined with the class statement.
For this example, we want to define
an object class that acts like a point
(i.e. it has an "x" and "y" value). Here is the definition of
a class named point:
class point {
method init {
$.xvalue = 0
$.yvalue = 0
}
method x {
return($.xvalue)
}
method y {
return($.yvalue)
}
method set (x,y) {
$.xvalue = x
$.yvalue = y
}
}
An object of class point can then be created and manipulated as follows:
o = new point()
o.set(33,44)
print("x is ",o.x()," y is ",o.y())
Objects are (currently) not reference-counted or garbage-collected internally,
so they must be explicitly deleted when you want to get rid of them:
delete o
Although it is conventional for objects to have
a delete method, this method is not called automatically by the language.
The default user-defined library has a deleteobject function that,
if used, will call the delete method of an object,
allowing it to clean up any tasks and graphics that it owns.
The deleteobject function also automatically deletes
any children objects.
If you print the value of an object, you will see a result like this:
o = new point()
print(o)
$18448396
The number that gets printed after the $ is the internal id of
the object, which attempts to be a unique number (even between invocations
of KeyKit). This notation (a $ followed by an integer) can actually
be used within KeyKit code - it is a valid constant
that will refer to that object. In fact, if you use such a constant,
and an object with that id number does not currently exist, a generic
object with that id will be created automatically. This becomes the
mechanism by which objects can refer to each other, and by which these
references can be conveniently maintained between invocations of KeyKit.
For example, in the interactive
user interface, you can write the current page
(i.e. all objects on the current screen) to a file. If you look in this file,
you will see lots of such $ values. A button object that
refers to another object will contain (as one of the button's data elements)
the value of that other object. The button may very well be
created and initialized before the
other object even exists, but since the button refers
to the other object by using a constant such
as $12345678, it will create the other object automatically.
The other object will eventually get created, and the code
that creates it will use the same constant $123454678 to
initialize itself, and hence it will become the object that the button
is already referring to.
When you want to create an object of a given class, and you want to
use an existing object id (as just described), the following syntax should
be used:
o = new($123) point()
The value in parenthesis after new is the object that will be
initialized with the named class (in this case, point).
Inheritance and Children
All objects have a .inherit method that lets you specify
one or more other objects from which methods will be inherited
(if not overridden).
As an example, the code below defines a polarpoint() class
that creates an object that acts like a point object,
except that you can also set its value with polar coordinates.
class polarpoint {
method init {
$.pt = new point()
$.inherit($.pt)
}
method setpolar (ang,r) {
x = r*cos(ang)
y = r*sin(ang)
$.pt.set(x,y)
}
}
Note that inheritance requires explicit creation of an object
from which methods are inherited.
In this example, the setpolar method explicitly calls
the set method of $.pt. Because of the inheritance
that has been established, this call could actually be written
as $.set(x,y). Use of the polarpoint() class is illustrated here:
o = new polarpoint()
o.setpolar(3.14,100)
print("x is ",o.x()," y is ",o.y())
Note that the x and y methods of the polarpoint
object will be inherited from the point object.
When executing a method that has been inherited, the special symbol
$$ (rather than $) will refer to the higher-level object
which has established the inheritance relationship, rather than
the inherited object. This can be used with both method invocations
and object variable references. This code illustrates:
class A {
method init {
$.value = "AVALUE";
}
method id() {
return("A")
}
method basefunc(numdollars) {
if ( numdollars == 1 ) {
print($.value)
print($.id())
} else {
print($$.value)
print($$.id())
}
}
}
class B {
method init {
someA = new A()
$.inherit(someA)
$.value = "BVALUE";
}
method id() {
return("B")
}
}
b = new B()
b.basefunc(1)
# will print "AVALUE" and "A"
b.basefunc(2)
# will print "BVALUE" and "B"
Default Methods
All classes have the following built-in methods:
- addchild(child-object)
-
Each object maintains
a list of "children", typically used for forwarding
events within the graphical user interface. The addchild method
expects an object value to be given as an argument, and adds that object
to the list of children for the current object.
- removechild(child-object)
-
Removes an object from the list of children (as created
with addchild) for the current object.
- children()
-
Returns an array containing the list of children
for the current object. The index values of the array elements are
the object values, so you can conveniently loop through them.
- childunder(xyarray)
-
This method is
given an
xy
value representing a point on the screen,
and returns the value of the first child object that lies under that point.
- inherit(from-object)
-
Described above.
- inherited()
-
Returns
an array containing the list of objects from which the
current object inherits methods (as established with the inherit method).
Graphical Features
Graphics in KeyKit is supported by a few built-in object types
and a number of special global variables. The built-in support is
extremely minimal, designed to support the creation of almost all
user-interface semantics (all the way down to the behaviour of pop-up menus)
through the use of user-specified KeyKit code.
The standard library that comes with KeyKit implements a complete
graphical user interface that is described elsewhere;
only the very raw built-in graphical capabilities are described here.
Windows
First, an overview of the window features in KeyKit.
All KeyKit graphics are done within a single root window (making it
portable to environments that have no native window system).
Coordinates are expressed in device-dependent pixel units, relative to the
upper-left corner (0,0) of the root window. There is only one coordinate
space, that of the root window. Coordinates used within
sub-windows are expressed in that same coordinate space - they are not
relative or scaled (although there is a coordinate space
within phrase windows that uses clicks and pitches rather than pixels).
As a convention,
many of the graphical methods use arrays with elements whose subscripts
are "x0", "y0", "x1", "y1", and whose values
are interpreted as coordinates of the origin and corner of a rectangle.
This type of array is referred to as an xyarray, and
here is a function that creates one:
function xy(x0,y0,x1,y1) {
return( ["x0"=x0,"y0"=y0,"x1"=x1,"y1"=y1] )
}
In actuality, this function is a built-in function, since it is so
heavily used. And, the built-in function is also capable of dealing
with only 2 arguments, in which case it creates an array whose
subscripts are "x" and "y".
Window Objects
A window object is created with the special built-in class windowobject().
In addition to the standard object methods described above,
window objects have the following methods:
- style(type)
-
This sets the drawn style of a window - a type of NOBORDER means no border
at all, BORDER means a simple outline border, BUTTON means a
3-d button look, MENUBUTTON means a 3-d button with an extra underline under
the text (to distinguish a drop-down menu button), and PRESSSEDBUTTON means
a 3-d button that looks like it's pressed.
If given no argument, this method returns the current border type.
- contains(xyarray)
-
This method returns 1 (true) if the point specified by xyarray
is contained within the window. If xyarray specifies an area,
this method returns 1 if the area overlaps (by any amount) the window.
- ellipse(xyarray [,mode] )
-
This draws the outline of an ellipse or circle within the rectangle specified
by the coordinates in xyarray.
The optional mode can be set to CLEAR, or STORE.
The default mode is STORE.
- fillellipse(xyarray [,mode] )
-
This draws and fills an ellipse or circle within the rectangle specified by
the coordinates in xyarray.
The optional mode can be set to CLEAR or STORE.
The default mode is STORE.
- fillrectangle(xyarray [,mode] )
-
This fills a rectangular region using the coordinates in xyarray.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- line(xyarray [,mode] )
-
This draws a line using the coordinates in xyarray.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- mousedo(mouse-array)
-
This processes the data from a mouse event (as received from
the Mousefifo) and takes whatever action is appropriate for the
current window object. For example, many of the behaviours
of a menu object (scrolling, item highlighting)
are done in response to handing it mouse events with this method.
The return value of mousedo(), when used with a menu object,
indicates which item the user has selected. Other valid return
values for a menu object are MENU_DELETE (for the X-area in the upper-right
corner of a menu), MENU_MOVE (the bar area in the upper-left corner of
a menu), and MENU_NOCHOICE (no choice was selected).
- rectangle(xyarray [,mode] )
-
This draws a rectangle using the coordinates in xyarray. The optional
mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- redraw()
-
This redraws the window. Note that this does not redraw anything inside
the window.
- resize(xyarray)
-
This changes the size of the window to the value specified
in xyarray. If no argument is given to resize(), it
returns the current size of the window (as an xyarray).
- restoreunder()
-
Restores the latest bitmap saved with saveunder().
- saveunder()
-
Saves the screen area covered by the window as a bitmap, which can be
later restored with restoreunder(). Intended for use with
pop-up menu windows.
- setconsole()
-
Sets the window so that it is considered the "console" - all error
messages and the output of print statements are seen in this window.
- size(xyarraygp)
-
This is an alias for the resize method.
- textcenter(string,xyarray [,mode] )
-
Draw the string centered within the area specified byxyarray.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- textheight()
-
Returns the current height, in pixels, of text characters.
- textleft(string,xyarray [,mode] )
-
Draw the string left-justified within the area specified byxyarray.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- textright(string,xyarray [,mode] )
-
Draw the string left-justified within the area specified byxyarray.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- textwidth()
-
Returns the current width, in pixels, of text characters.
- windtype()
-
Returns the window type as a string - "generic", "phrase",
"menu", or "console".
- xmax()
-
Returns the x value at the right side of the window.
- xmin()
-
Returns the x value at the left side of the window.
- ymax()
-
Returns the y value at the bottom of the window.
- ymin()
-
Returns the y value at the top of the window.
Phrase Window Objects
Windows objects are by default "generic"
windows, suitable for drawing lines and text.
For displaying phrases, you can add a "phrase"
argument: o = new windowobject("phrase").
This creates a window object with the following additional methods:
- closestnote(xyarray)
-
Returns the note in the window that is closest to the specified point.
- drawphrase(phrase [,mode])
-
Draws the specified phrase in the window.
The optional mode can be set to XOR, CLEAR, or STORE.
The default mode is STORE.
- scaletogrid(xyarray)
-
Scales the coordinates in xyarray from raw window values (pixels)
to click (time) and pitch coordinates. The scaled coordinates are
relative to the window's current view (as set by the view() method).
- sweep(fifo,type,xyarray)
-
Begins a sweep operation.
- trackname(string)
-
Sets the name of the track displayed in the window.
- view(xyarray)
-
This method controls what area of the phrase is seen within the window,
i.e. it allows you to zoom and pan around the phrase, using the window
as a viewport.
The argument to this method is assumed to be an xyarray value
that specifies the desired viewing area. The coordinates
are specified in terms of click (time) and pitch values.
For example, if the phrase in window w were 32 beats
in length, this statement would cause it to be dislayed in its entirety:
w.view(xy(0,0,32b,127))
Menu Window Objects
For creating window objects that act like menus, use a "menu"
argument: o = new windowobject("menu").
This creates a window object with the following additional methods:
- menuitem(label)
-
Adds an item with the specified label to a menu object.
- menuitems()
-
Returns an array containing the current list of menu items in the menu.
The index values of the array are the menu item labels.
Built-In Functions
- acos ( x )
-
Returns the arc-cosine of x.
- argv( arg-index-start [,arg-index-end] )
-
Used within a user-defined function to give generalized access
to the arguments passed to it.
If given one argument, argv returns a single argument from
those passed to the current user-defined function. For example, argv(0)
will return the first argument.
If given two arguments, argv returns an array containing the specified
argument range (from the first value up to, but not including,
the second value).
The index values of the returned array start at 0.
For example, argv(0,nargs()) returns an array containing all
of a function's arguments.
- ascii( integer-or-string )
-
When given a string argument, this function returns the ascii value of
its first character.
When given an integer argument, this function returns a string containing
a single character whose ascii value is that integer.
- asin ( x )
-
Returns the arc-sine of x.
- atan ( x )
-
Returns the arc-tangent of x.
- chdir(dir)
-
Changes the current directory to dir.
- close ( fifo )
-
Closes the specified fifo.
- color ( colorindex )
-
Sets the current color index for drawing things.
- colormix ( colorindex, red, green, blue )
-
Sets the color for a given color index. The values for red, green, and
blue can range from 0 to 65535. Color index 0 is main background color,
color index 1 is the main foreground color, color index 2 is the "pick"
color (used for displaying highlighted notes in phrase windows),
color index 3 is the background in buttons, and color index 4 is the
shadow in buttons. Other color indicies can be used when drawing
lines.
- cos ( angle )
-
Returns the cosine of angle (a value in radians).
- currtime()
-
Returns the current time, in seconds (typically since Jan 1, 1970).
- cut( phrase, type, ... )
-
Returns a phrase containing
notes cut from phrase.
The type determines what the cut is based on.
Possible values for type (as pre-defined macros) are
CUT_TIME,
CUT_CHANNEL,
CUT_TYPE,
CUT_NOTTYPE,
and CUT_FLAGS.
If type is CUT_TIME, the cut is based on time.
The third and fourth arguments
specify the starting and ending time. The fifth argument, if present,
controls how this cut behaves at the boundaries - possible values are
NORMAL (the default), TRUNCATE, and INCLUSIVE.
The NORMAL type of time cut is an efficient equivalent to the
expression: phrase{??.time>=time1 && ??.time<time2}.
A TRUNCATE cut will chop off notes that cross the boundaries, while
an INCLUSIVE cut will include those notes unchanged.
If type is CUT_CHANNEL, the cut is based on channel.
The third argument is the channel
number (as a value from 1 to 16) of the notes that will be in the cut
If type is CUT_TYPE, the cut is based on type.
The third argument is the type -
any notes that have this value as their .type will be in the cut.
If type is CUT_NOTTYPE, the cut is based on the inverse of a type.
The third
argument is a type - any notes with this .type value will
not
be in the cut phrase.
If type is CUT_FLAGS, the cut is based on the
flags attribute of the notes.
The third argument is a mask that is or'ed with
the flags of each note - the cut contains any notes for which this
results in a non-zero value.
- debug(type)
-
Used as a debugging hook, whose meaning varies from time to time.
- defined(variable-or-function-name)
-
Returns non-zero if the named variable or function
has been defined, and 0 if it is undefined.
- error( message )
-
Generates an error, printing the specified message string and terminating
the calling task.
- exp ( x )
-
Returns the exponential function e**x.
- exit()
-
Quits the entire KeyKit program, completely and abruptly.
- fifoctl ( fifo, cmd, mode )
-
Sets the given fifo to a particluar mode. The cmd argument
is intended as a hook to machine-dependent fifo commands. The only
command universally accepted is "type".
If mode is "l", then
reads from the fifo are done a line at a time (this is the default
mode of fifos).
If mode is "b", then
reads from the fifo are done a byte at a time rather than a line at a time.
- fifosize ( fifo )
-
Returns the number of unread data values in the specified fifo.
- filetime ( filename )
-
Returns the modification time of the named file,
consistent with the values returned by currtime().
- finishoff()
-
Send note-off messages on MIDI output to terminate any currently-held notes.
- float ( value )
-
Converts its argument (typically an integer or string) to a
floating point value and returns it.
- flush ( fifo )
-
Flush all unprocessed data values in the specified fifo. If the
fifo is attached to a file or pipe, the data is flushed.
For other types of fifos, any unprocessed values in the fifo are discarded.
- funkey ( num, statement )
-
Assigns a KeyKit statement (specified as a string beginning with '{' )
to the num-th function key. Whenever that function key is pressed,
the statement will be immediately executed.
- get ( fifo )
-
Retrieves a value from the specified fifo. The task blocks
if the fifo is empty.
- gettid ( )
-
Returns the task id of the current task.
- integer ( value )
-
Converts its argument (typically a string or float) to an
integer value and returns it.
- kill ( task-id )
-
Terminates the specified task, possibly invoking a cleanup function
that the task has registered with onexit().
The return value of kill() is normally 0.
Killing a non-existant task is okay - no error is produced, and
the return value is 1.
- log ( x )
-
Returns the natural logarithm of x.
- log10 ( x )
-
Returns the logarithm of x to base 10.
- midibytes ( num-or-phrase, num-or-phrase, ... )
-
Returns a phrase containing a single MIDIBYTES note that
is the concatenation of the bytes specified by all the arguments.
Each argument can be either a number - specifying a single byte of the result;
or a phrase - all of its MIDIBYTES notes are copied to the output phrase.
- midifile(filename) or midifile(array,filename)
-
The first usage (with only a filename as an argument) reads a Standard MIDI
File and returns an array containing its tracks, starting at array index 0.
The global variable Mfformat is set to the format type (0, 1, or 2).
The value of global variable Defrelease specifies the default release velocity.
If the value of global variable Onoffmerge is 1 (its default value),
noteons and noteoffs are merged.
Used as midifile(array,filename), the elements of the specified
array are used as tracks to create a Standard MIDI File in the named
file. The array subscripts should be numeric, since they will be
sorted to determine the order of tracks in the file. If global
variable Tempotrack is 1 (its default value), a tempo track is
automatically created as the first track of the file. The value of
Clicks is used as the 'divisions' value in the header.
- milliclock ( )
-
Returns the (relative, not absolute) value of a millisecond-resolution clock.
- nargs ( )
-
Returns the number of arguments passed
to a user-defined function.
- onchange(variable, func)
-
Arranges for func (a function pointer value) to be called
whenever the value of the specified variable is changed.
- onexit(func [,arg(s)] )
-
Arranges for func (a function pointer value) to be called
when the current task is finished (either voluntarily or by being
killed). If there are additional arguments, they are passed
as arguments to func when it is called.
- open( [file-or-pipe [,mode] ] )
-
Allocates a new fifo and returns its id.
If given one argument, open interprets it as a filename to be opened
for reading.
A second argument can modify the interpretation: "w" will open the file for
writing rather than reading; "|" will interpret the first argument as a shell
command, opening a pipe that can be used to read its output;
and "|w" will execute a command,
opening a pipe that can be used to write to it.
- phrase ( value )
-
Converts its argument (normally a string which includes the single quotes) to a
phrase value and returns it. For example, a=phrase("'a,b,c'").
- pow ( x, y )
-
Returns x**y.
- printf(format [,arguments])
-
Print formatted output. See the section
on "Printing" above, and see the description of sprintf below for
the type of formatting that can be done.
- priority(task [,priority])
-
With one argument, priority() returns the current priority of the
specified task.
With two arguments, priority() sets the current priority of the
task to the specified priority value.
If the value of task-id is -1, priority() returns or sets
the global priority limit, which specifies
a lower limit for runnable tasks - only tasks with a priority
greater than or equal to the current global priority are permitted to run.
- put ( fifo, value )
-
Puts the value on the specified fifo. The return value is normally 0.
If fifo does not exist, the return value is -1.
- readphr(fname)
-
Reads the specified file, expecting it to contain a KeyKit phrase
whose value is returned. The value of Musicpath is used to search
for the file.
- realtime(phr [,time])
-
Spawns a new task for playing the given phrase in realtime via MIDI output,
and returns its task id.
An optional second argument specifies the starting time; the default
value is Now.
- reboot()
-
Forces a reboot, terminating all tasks and calling Rebootfunc(),
a function whose initially-null value is typically redefined in keyrc().
- rand ( n1 [,n2] )
-
Returns a random number between n1 and n2,
inclusive. If only n1 is given, the random number is
between 0 and (n1-1), inclusive. If only n1 is given,
and it is negative, then it is used to seed the random number generator.
- setmouse(type)
-
Set the cursor type for the mouse.
Values for type are:
ARROW, SWEEP, CROSS,
LEFTRIGHT, UPDOWN, BUSY, and NOTHING.
- sin ( angle )
-
Returns the sine of angle (a value in radians).
- sizeof(arg)
-
Returns the number of notes in a phrase, or the length
of a string, or the number of elements in an array.
- sleeptill(time)
-
Causes the task to go to sleep until the specified time, expressed
in absolute clicks.
- sqrt ( val )
-
Returns the square root of val.
- split(phrase-or-string)
-
When given a string as its first argument, this function breaks it
into white-space-separated words and inserts them as separate elements
into an array. The return value of this function is a pointer to this
newly-created array.
The subscript of the first array element is 0. When given a phrase,
this function breaks it into a array of short phrases, using the starting and
ending times of the notes in the original phrase to determine the
split points. To visualize this operation, imagine drawing vertical
lines through the starting and ending point of every note in the original
phrase. The array elements would be the phrases contained between these
vertical lines. For example, x = split('a,bt12')would result
in x[0]='ad12' ; x[1]='ad84t12 b' ; x[2]='bd12t96'.
This is useful for constructing monophonic phrases, and any
other operation in which you want to reconsider what notes should be playing
whenever any note starts or stops.
- sprintf ( format, args )
-
Formatted printing, with the result returned as a string.
The format may contain the following
conversion specifications: 0 (decimal), 0 (hex), 0.000000
(float/double),
(string), 0 (phrase), and % (literal percent character).
Width and precision prefixes (e.g. 0 and 0.00) are recognized.
- string ( value )
-
Converts its argument to a string and returns it.
- subbytes(phrase,start,leng)
-
Works vaguely like substr, but operates on notes whose
type is MIDIBYTES,
allowing you to pull off individual bytes or ranges of bytes.
For example, subbytes('xc005c106c207',3,2) would return 'xc106'.
Note that the start value for the first
byte is 1, not 0.
- substr(string,start,leng)
-
Returns a substring of a string.
Note that the start value for the first
character of a string is 1, not 0.
- system(string)
-
The string is executed by the shell (or whatever program is the
command interpreter for a given machine).
This may not be supported on all machines.
- tan ( angle )
-
Returns the tangent of angle (a value in radians).
- taskinfo("list") or taskinfo ( taskid, type )
-
If given a single argument (whose only valid value is "list"),
taskinfo() returns an array with entries for each currently-running
task - the array element indicies are the task ids, and the array element
values are all zero.
If given two arguments, taskinfo() expects the first to be a task id,
and the second is a string that indicates what piece of information
about the task should be returned by taskinfo(). The valid values of
type are:
"status" (returns a string describing
the running status),
"parent" (returns the id of the task's parent),
"count" (returns the number of interpreted instructions that the
task has executed),
"schedtime" (returns the time at which the
task is scheduled to awaken, if it is sleeping),
"wait" (returns
the id of the task whose termination is being awaited),
"blocked"
(returns the id of the fifo, if any, on which the task is blocked),
"fulltrace" (returns a string with a complete function traceback,
including parameter values),
"trace" (returns a function traceback without parameter values), or
"priority" (returns the priority value of the task).
- tempo( [newtempo] )
-
When invoked
with no arguments, tempo returns the current tempo.
When given an argument, the current playback speed is set to newtempo,
whose units are microseconds per beat. The return value is the old tempo.
- typeof(arg)
-
Returns a string describing the type of its
argument: "string", "integer", "float",
"phrase", "array", "function", or "uninitialized".
- undefine(variable-or-function-name)
-
Causes the definition of the named variable or function to be forgotten.
This can be used to force the rereading of a user-defined function from the
file that defined it, and is typically useful when a new function is
being written and tested.
- wait(task-id)
-
Causes the current task to go to sleep until the specifed task has finished.
- xy(x0,y0,x1,y1)
-
Returns an xyarray (see descrption in the Windows section above)
containing the specified values.
Acknowledgments
KeyKit has been a hobby project of mine for many years. In that time,
many people have contributed ideas, feedback, assistance, and encouragement.
Some of them are:
Jon Backstrom,
Tom Duff,
Geza Feketa,
Dick Hamilton,
Tony Hansen,
John Helton,
Tom Killian,
Peter Langston,
Hector Levesque,
Jason Levitt,
Howard Moscovitz,
Marty Shannon,
and Daniel Steinberg.
The people who have put significant effort into porting KeyKit to various
machines deserve special mention and special thanks -
Steve Falco (Mac),
Alan Bland (Amiga), Gregg Wonderly (Amiga),
Mike Healy (Atari ST), Greg Youngdahl (DOS)
Ag Primatic (Mac), and Jack Wright (Mac).
Many thanks to all.