See TypeScript reference implementation.
Melee is a domain-specific language for defining complex musical sequences using generators. This specific repository is a reference implementation written in TypeScript to faciliate an Ableton Max4Live device.
In some programming languages, a generator is a self-contained piece of code like a function or a coroutine that can be paused and resumed as it "yields" values out to the caller while maintaining its internal state. In practice, they're often used for lazily evaluating extremely large (sometimes infinite) loops.
It's a weird code thing that we're gonna use to make music loopies.
While Melee is not the most fully featured language, you can still do things like basic arithmetic, assign variables, and bundle code into functions.
a := 1 + 2; // 3
isThree := fn (x) {
return x == 3;
};
if (isThree(a)) {
print(5 * 10);
} // 50
Unlike other general purpose languages, Melee is intended to be used in a runtime environment that can send MIDI messages to a hardware instrument or some other software. Because of that, the note()
function returns a special MIDI data type.
note(0, C3);
note(0, Fb2, 2);
note(0, G#7, 4, 64);
The note
function can receive up to four pieces of data: note(channel, pitch, duration, velocity)
. Velocity is a special value that is commonly used to determine how hard a key is pressed, or how strongly a note should be played. It ranges from 0 to 127 (that's just a MIDI thing), and if left out, it defaults to the smack-dab in the middle (64). Duration is determined by whoever is using Melee, and is a multiple of the main clock. For example, if the program is grabbing a new value every eighth note, then a duration of 2 would be a quarter note, 4 would be a half note, etc...
Adding the channel for each note gets tedious. It can be useful if you want to bounce around from channel to channel, but if you have no need for that, you can use the instr
function to define new note functions like so:
trumpet := instr(0);
bassoon := instr(1);
drums := instr(2);
trumpet(C4, n4); // Equivalent to note(0, C4, n4)
bassoon(F2, n8); // Equivalent to note(1, F2, n8)
drums(B1, n16); // Equivalent to note(2, B1, n16)
Functions and notes aside, the real sweet spot for Melee happens when we start incorporating generator functions to create new sequences.
seq := gen () {
yield note(0, C3);
yield note(0, D3);
yield note(0, C3);
yield note(0, G3);
}
Here we have a generator function that will create a simple four-note sequence C-D-C-G. The gen() { ... }
means that everything enclosed within the brackets is the code that gets run step by step to build up the sequence. Each line in this example yield
s the value (in this case they're MIDI note
s), out to whoever is using the sequence.
In this first example, the sequence reaches the G3 note and ends, but this doesn't have to be the case with a loop
!
main := gen () {
loop {
yield note(0, C3);
yield note(0, D3);
yield note(0, C3);
yield note(0, G3);
}
}
Now that it loops forever, we can start making the sequence a bit more complex. Perhaps we ditch the 4/4 pattern and have it drift around randomly.
main := gen() {
loop {
yield note(0, C3);
yield note(0, D3);
if (rand(3) != 1) {
yield note(0, C3);
}
yield note(0, G3);
}
}
Now every time we go through the loop, it'll first yield a C, then a D, and then it'll generate a rand
om positive integer equal to or less than the number that immediately follows it (rand(3)
generates 0
, 1
or 2
, rand(4)
generates 0
, 1
, 2
, or 3
, etc...).
If that number doesn't match (!=
) 1, then it yields an extra C, otherwise it skips it and moves on to the G. Each loop has a one-third chance of being 3 notes instead of 4.
You don't have to yield note
s or cc
messages from generators, they can be any value, which could come in handy as you compose different sequences together in interesting ways. Below we use a second sequence generator to spit out a cycle of int
s (integer numbers) that we'll use in the note's duration field.
subseq := gen() {
loop {
yield n16;
yield n8;
yield d8;
}
}
duration := subseq();
// You can also do the above with the cycle() built-in function
// to turn an array of values into a sequence.
//
// duration := cycle([1, 2, 3]);
main := gen() {
loop {
yield note(0, C3, next duration);
yield note(0, D3, next duration);
yield note(0, C3, next duration);
yield note(0, G3, next duration);
}
}
What we start getting here is a loop of MIDI notes, but the durations drift with each loop: C3 for 1 beat, D3 for 2, C3 for 3, G3 for 1, C3 for 2, etc...
Hopefully you're starting to see how with a little bit of code you can build complex sequences that you may have not have been able to dream up otherwise.
This barely cracks the surface of what's capable with Melee, so check out the examples for more in-depth demonstrations.
By default, any note or rest without an explicit duration lasts for a sixteenth note. When supplying a duration, you may provide an integer value or use a built-in variable. Variables like n1
, n2
, n4
, n8
, and so on represent whole notes, half notes, quarter notes and eighth notes respectively. There are also the aliases WHOLE
, HALF
, QUARTER
, EIGHTH
and SIXTEENTH
for common note lengths. Triplets can be specified by swapping the n
for a t
like t4
for a triple quarter note, and dotted notes can be specified with a d
, like d4
.
Under the hood, all these values are integers that are multiples of the number of clock ticks per measure. By default this is set to 48, meaning that the smallest note you make is a 32nd note triplet at one tick. Two ticks is a 16th note triplet, and three ticks is a 16th note. If you were to use a Melee runtime that doubled the amount of ticks per measure, it would support 32nd notes.
Because these are all just numbers, you can do math operations on them, such as n4 + n8 + 16
or n4 * 3 - n8t
.
The scale()
and quant()
functions convert an interval or note to a MIDI pitch number relative to the provided scale and root note. You can provide any scale by setting an array like fibonacci := [0, 1, 2, 3, 5, 8]
, however some scales are pre-loaded into the language and can be accessed with the variable names below:
SCALE_MAJOR // or
SCALE_IONIAN
SCALE_MINOR // or
SCALE_AEOLIAN
SCALE_PENTA_MAJOR
SCALE_PENTA_MINOR
SCALE_BLUES
SCALE_DORIAN
SCALE_PHRYGIAN
SCALE_LYDIAN
SCALE_MIXOLYDIAN
SCALE_LOCRIAN
In the current design of Melee, polyphony is a bit of a tricky thing. In real human compositions, notes of a chord don't always play for the same length for the same length. To allow users the flexibility of flowing seamlessly between harmony and melody, it's easier for chords to simply be arrays of note
objects rather than a standalone data type.
That being said, there are a number of helpers to work with groups of notes.
The chord
function can take either a note
object or a int
pitch value, an array of chord intervals, and an optional inversion number. This means you can call chord
a couple of different ways.
chord(A2, MAJ) // A2 Maj triad
> [A2, C#2, E2]
chord(A2, MAJ, 1) // A2 Maj triad, first inversion
> [C#2, E2, A3]
chord(note[A2, n8, 127], MAJ) // A2 Maj triad, for 4 clock cycles, with full velocity
> [note(0, A2, n8, 127), note(0, C#2, n8, 127), note(0, E2, n8, 127)]
chord(note[A2, n8, 127], MAJ, 2) // A2 Maj triad, second inversion, for 4 clock cycles, with full velocity
> [note(0, E2, n8, 127), note(0, A3, n8, 127), note(0, C#3, n8, 127)]
The full list of built-in chords are shown below, but because these chords are just arrays, you can define your own in the Melee code, use scales as chords, chords as scales... whatever you want!
ROOT_4 // Root + perfect 4th
ROOT_5 // Root + perfect 5th
ROOT_6 // Root + sixth
SUS_2 // Root + 2nd + 5th
SUS_4 // Root + 4th + 5th
ROOT_5_ADD_9 // Root + 5th + 9th
ROOT_6_ADD_9 // Root + 6th + 9th
MAJ // Root + 3rd + 5th
MIN // Root + flat 3rd + 5th
MAJ_7 // Root + 3rd + 5th + 7th
MIN_7 // Root + flat 3rd + 5th + flat 7th
DOM_7 // Root + 3rd + 5th + flat 7th
MIN_MAJ_7 // Root + flat 3rd + 5th + 7th
MAJ_9 // Root + 3rd + 5th + 7th + 9th
MAJ_ADD_9 // Root + 3rd + 5th + 9th
MIN_9 // Root + flat 3rd + 5th + flat 7th + 9th
MIN_ADD_9 // Root + flat 3rd + 5th + 9th
DOM_9 // Root + 3rd + 5th + flat 7th + 9th
MAJ_11 // Root + 3rd + 5th + 7th + 9th + 11th
MIN_11 // Root + flat 3rd + 5th + flat 7th + 9th + 11th
Because Melee notes have durations baked into the data type, it can be tough for the runtime to know how long a group of notes should play. More importantly, it can be difficult to know when to pull the next item from the main sequence.
For example, let's say you want to play two sequences in a sort of two-voice sequence, where a melody plays while another note plays every 4 beats to reinforce a bass note.
// Melody | C5 | A5 | G5 | F#5 | F5 | D5 | G5 | B4 |
// Bass | C2 | F2 |
melody := gen() {
yield note(0, C5)
yield note(0, A5)
yield note(0, G5)
yield note(0, F#5)
yield note(0, D5)
yield note(0, D5)
yield note(0, G5)
yield note(0, B4)
}
bass := gen() {
yield note(0, C2, n4)
yield note(0, F2, n4)
}
main := merge(melody(), bass())
If we were to do a plain old merge
to join these two generators into a single main
sequence, then on the first beat, the runtime receives the two notes [note(0, C5), note(0, C2, n4)
. The runtime would need to make a choice on whether to the next items after one beat or four. If the runtime pulled another note on the second beat, then the sequence would return [note(0, A5), note(0, F2, n4)]
. That's not what we want.
Hence, the poly
function is available to provide a way to merge sequences that is aware of note duration. If you were to create a main
sequence using poly(melody(), bass())
, rather than the second beat returning both a new melody and bass note, we would recieve the array [note(0, A5), HOLD]
.
HOLD
is a special value useful to runtimes to know not to worry about that particular note, and that whatever its currently doing should be fine.
As a user, you shouldn't have to worry too much about passing
HOLD
values around, but if you're daring enough to create your own program that uses Melee, you'll want to look out for it.
Type | Example | Description |
---|---|---|
array | [1, 2, 3] |
A list of any of the above data types |
bool | true |
True or false; mostly used for branching your code |
cc | cc(0, 3, 64) |
MIDI CC message |
fn | fn(...args) { ... } |
A function |
gen | gen(...args) { ... } |
A generator function capable of creating a generator instance |
int | 5 |
An integer value; used for math or converting into note or cc data |
note | note(0, D4, n16, 127) |
Returned from the note function; represents a MIDI note, must contain the MIDI channel and pitch (note(0, D4) ), but you can also provide a duration and a velocity; a note with pitch -1 is a rest |
null | null |
Nothing at all; if you got this, something probably went wrong |
rest | rest(n8) |
Returned from the rest function; musical rest between notes |
seq | N/A |
A sequence object you get by calling a gen function. Iterate over it with next . |
Type | Example | Description |
---|---|---|
cc | cc(int, int, int) |
Creates a new MIDI CC message with the given channel, key, and value |
chord | chord(...) |
See Chords and Polyphony for a full explanation |
concat | concat(arr1, arr2, ..., arrN) |
Merge multiple arrays into one |
conv | conv(arr) |
Converts an array into a sequence |
cycle | cycle(arr) |
Converts an array into an infinitely looping sequence |
dur | dur(note) |
Returns a note's duration |
filter | filter(arr, fn) |
Creates a new array of items where fn(item) returns truthy |
instr | instr(int) |
Create a new "instrument" for a static MIDI note |
len | len(arr) |
Returns the length of the array |
map | map(arr, fn) |
Creates a new array by performing fn on each array item |
max | max(arr) |
Returns the maximum value of an array of int s |
merge | merge(seq1, seq2, ..., seqN) |
Merges multiple sequences together so that next returns an array of each next value of the given sequences |
min | min(arr) |
Returns the minimum value of an array of int s |
note | note(int, int, int, int) |
Creates a new MIDI note with the given channel, pitch, duration, and velocity |
pitch | pitch(note) |
Returns a note's pitch |
poly | poly(seq1, seq2, ..., seqN) |
Polyphony helper; almost identical to merge , honors note duration |
pop | pop(arr) |
Pulls an element off the end of an array and returns it |
print(...) |
Prints arguments to the console | |
push | push(arr) |
Pushes a new element onto an array |
quant | scale(scaleArr, root, note) |
Quantizes a note by snapping it to the next highest pitch in the scale |
rand | rand(n) |
Generates a random int from 0 up to and not including n |
range | range(n) |
Returns an array of length n containing the numbers 0 up to and not including n |
rest | rest(int) |
Creates a new rest for the duration provided |
rev | rev(arr) |
Returns a reversed array |
rrand | rrand(lo, hi) |
Generates a random int in the provided range from lo up to and not including hi |
scale | scale(scaleArr, root, interval) |
Work with intervals of a scale rather than chromatic MIDI pitches (see Scales below for more information) |
send | send(note | cc) |
Sends a MIDI message directly to the runtime, primarily for CC or program control messages |
shift | shift(arr) |
Pulls an element off the front of an array and returns it |
sort | sort(arr) |
Returns a sorted array |
take | take(seq, n) |
Pulls the next n elements out of a sequence and puts them in an array |
vel | vel(note) |
Returns a note's velocity |
Melee is heavily inspired by the Monome Teletype eurorack module, the ORCΛ esoteric programming language, and the Nestup markup language for writing complex rhythms. Melee is also inspired greatly by Thorsten Ball's Monkey programming language. Thorsten's books Writing an Interpreter in Go and Writing a Compiler in Go were an indispensible resource in learning the ins and outs of language design and implementation. If you're at all interested in writing your own language, I cannot recommend these books enough!