A song is an Elixir file that starts with import Mx and ends with song. Between the two, plain functions build the music: each returns a value, and song plays the value it is given.

import Mx

melody = seq(e4: 1, g4: 1, a4: 2, g4: 1, e4: 1, d4: 2)
bass = seq(a2: 4, e2: 4)

song(
  tempo: 90,
  music:
    together([
      part(:flute, melody, synth: :flute, fx: :reverb),
      part(:bass, bass, synth: :double_bass)
    ])
)

mix mx.play song.exs plays the file, and mix mx.print song.exs prints each part's notes with their beats instead of playing them. A function refuses what cannot play, such as an unknown note name or an option the synth does not have, and mx prints the refusal with the line of the song file.

A longer song is better written as a module, which A song as a module describes.

Notes

note(:a4, 1.5) is the note A4 lasting 1.5 beats, and rest(1) is a beat of silence.

A pitch is a name or a MIDI note number, where 60 is middle C. A name is a letter from a to g, an optional s for sharp or b for flat, and an optional octave that defaults to 4: :c4 is 60, :fs3 is 54 and :eb3 is 51.

Options after the length go to the part's synth:

note(:a4, 1.5, amp: 0.8, release: 2)

Each synth has its own options, which Instruments lists. attack, decay, sustain, release and every option ending in _slide are measured in beats, so release: 1 lasts one beat at any tempo.

A list of pitches is a chord:

note(chord(:c4, :major), 2)
note(chord(:e3, :minor), 1, strum: 0.05)
note([40, 47, 52, 56], 1, vel: [0.8, 0.6, 0.5, 0.45])

strum: starts each note of the chord that many beats after the one below it, and a negative strum goes from the highest note down. An option given as a list has one value for each note of the chord.

Putting music together

verse = seq(c4: 1, e4: 1, g4: 2)
chorus = together([seq(c5: 2, b4: 2), seq(e4: 4)])

seq([verse, transpose(verse, 5), repeat(chorus, 2)])
  • seq plays its items one after another. It takes a list of music, or note names and lengths as a keyword list where r is a rest: seq(a4: 1.5, a4: 0.5, c5: 1, r: 1).
  • together starts its items at once, and lasts as long as the longest.
  • repeat(music, 4) plays the music four times. repeat(music) plays it until mx stops, and can only hold the whole of a song's music.
  • transpose(music, 12) moves every note an octave up.
  • delay(music, 7) is the music after 7 beats of silence, so inside together it starts 7 beats after the rest.
  • beats(music) is the length of music in beats.

Rhythm

part(:kick, steps("x... x... x... x.x.", 0.25), synth: :kick)
part(:cajon, at([{0, hit(slap: 1, vel: 0.8)}, {1.5, hit(vel: 0.5)}], 2), synth: :cajon)
  • steps("x..x x.x.", 0.25) is one drum hit for each x. Each character lasts a quarter of a beat, a dot is silence, and spaces are left out. Patterns describes what else it plays.
  • hit(vel: 0.5) is one hit of a drum at its own pitch, taking no time.
  • at([{0.5, music}, {1.5, music}], 2) is music of 2 beats that starts each item at its beat, so hits at known beats need no rests between them.
  • swing(music, fun) starts each note at the beat the function returns for the beat it was written on, so some notes can be pushed late and others early.

Patterns

A pattern is music written as text, and steps(pattern, step) plays it, each step of the pattern lasting step beats. ~MX writes a pattern, and reads it when the song compiles, so a bracket left open or a length that is no number is refused with its line before anything plays.

steps(~MX"g4:2 _ g4 bb4:2 _ a4 | g4:4 - -", 0.25, vel: 0.8)
steps(~MX"9... 9... 9..5 9.7."s, 0.25)

Without a modifier, ~MX reads a word to a step:

  • A word that is a note's name, such as g4 or fs5, is that note.
  • _ is silence.
  • - holds the word before it one step longer.
  • A number after a colon is a word's length in steps: g4:2 lasts two, and g4:1.5 one and a half.
  • Words joined by a slash sound together: g5/b4.
  • The words in brackets share one step evenly: [f f] is two in the time of one.
  • A word after ~ is not struck. The note before it glides to that pitch and lasts through it: eb2:2 ~g2:2 is one note of four steps that glides to G halfway.
  • A bar line, |, is only for reading.

With the modifier s, as in ~MX"9..5 x..."s, it reads a character to a step: a dot is silence, and spaces and bar lines are left out. A plain string given to steps is read the same way.

A pattern says nothing of what its words sound like; the options of steps do:

steps(~MX"9...9..5"s, 0.25, sample: "kick.wav", release: 0.1)
steps(~MX"ccoc ccoc"s, 0.25, strokes: [o: [vel: 0.75, sustain: 0.2], c: [vel: 0.5]])
steps(~MX"..1. .5.3"s, 0.25, notes: [:g4, :a4, :bb4, :c5, :d5])
steps(~MX"x.xx"s, 0.25, each: fn step -> [vel: if(step == 0, do: 0.9, else: 0.5)] end)
steps(~MX"eb2:2 ~g2:2 d2:4", 0.25, slide: 0.3)
  • x is a hit of a drum at its own pitch.
  • A digit is a hit at that level, 9 the loudest. It sets vel:, or the option digit: names.
  • sample: plays each hit as that sample, where a digit sets amp:.
  • strokes: gives the options of other characters or words, so a drum's strokes can have their own letters.
  • notes: makes a digit a hit tuned to that note of the list, counted from 1.
  • each: is a function of the step a sound starts on, counted from 0, that returns more options for it. A function of two takes the sound's length in steps as well.
  • slide: is how many beats a glide takes.
  • Any other option goes to every sound.

A pattern is a list, so two patterns join with ++, and steps plays a list of patterns as one bar after another:

kick = [~MX"9...9...9..59.7."s, ~MX"9..59...9..59.7."s]
part(:kick, repeat(steps(kick, 0.25), 4), synth: :kick)

A sigil takes no interpolation; build a longer pattern by joining shorter ones.

Parts

part(:soprano, music, synth: :oboe) is music played by one player. The synth defaults to :tri. A part of one name is the same player wherever it appears in the song, so it must have the same options each time.

fx: sends the part through an effect, or through a list of effects in order, each with its options:

part(:keys, music, synth: :piano, fx: :reverb)
part(:keys, music, synth: :piano, fx: [echo: [phase: 0.75], reverb: [room: 0.8]])

defaults: gives every note of the part options it takes unless the note gives its own:

part(:guitar, music, synth: :guitar, defaults: [amp: 0.55])

The song

song(tempo: 72, music: music) is the song. The tempo defaults to 60 beats per minute.

The items of the song's outermost seq are its sections, also when the seq is inside repeat. A part plays only in the sections that hold it, so instruments can take turns:

import Mx

melody = seq(c4: 1, e4: 1, g4: 2)
answer = seq(r: 2, g5: 1, e5: 1)

first = together([part(:trombone, melody, synth: :trombone), part(:flute, answer, synth: :flute)])
second = together([part(:cello, melody, synth: :cello), part(:tenor_sax, answer, synth: :tenor_sax)])

song(tempo: 72, music: repeat(seq([first, second])))

When you save the file while the song plays, the new version takes over at the end of the current section. A song whose music is not a seq is one section.

tempo(140) changes the tempo for every part from the beat where it is written, and takes no time. tempo(130, over: 16) gets there evenly over 16 beats. In music that repeats, give the first section its tempo too, since each change comes round again:

repeat(seq([tempo(100), verse, tempo(140), chorus]))

A song as a module

A song file can hold a module that uses Mx.Song in place of ending with song:

defmodule Paredao do
  use Mx.Song, tempo: 92

  part :kick, synth: :kick, fx: [level: [amp: 0.6]]
  part :bass, synth: :double_bass, defaults: [amp: 0.5]

  def song, do: seq([intro(), beat(), beat()])

  defp intro, do: part(:kick, kick(2))

  defp beat do
    together([
      part(:kick, kick(4)),
      part(:bass, steps(~MX"g1:3 _ g1:3 _ bb1:3 _ d2:2 f2:2", 0.25))
    ])
  end

  defp kick(bars), do: repeat(steps(~MX"9...9...9..59.7."s, 0.25), bars)
end
  • use Mx.Song imports Mx, and takes the options of song except music:.
  • song/0 returns the song's music, and mx calls it when it loads the file.
  • part :kick, synth: :kick in the module's body declares a part with its options, once.
  • part(:kick, music) in a function is that part playing the music, with the options it was declared with.

A part that is not declared, and a part declared twice, do not compile, and mx names the line. A file holds one such module; a module that uses Mx.Instrument can stand beside it. examples/funk_br.exs is a whole song written this way.

mix format keeps a declaration without parentheses when the project's .formatter.exs has import_deps: [:mx].

Loops

loop(4, fn pass, inputs -> music end) runs the function again every 4 beats and plays the music it returns, so a part can vary from pass to pass:

import Mx

drums =
  loop(16, fn pass, _ ->
    last_bar = if rem(pass, 4) == 3, do: "x.x.xxxx", else: "x...x..."
    steps("x...x... x...x... x...x... " <> last_bar, 0.5)
  end)

song(tempo: 96, music: together([
  part(:bass, repeat(seq(c2: 4, g2: 4), 8), synth: :saw),
  part(:drums, drums, synth: :kick)
]))
  • pass counts the passes the loop has played, from 0, and ring(list, pass) steps through a list one item per pass.
  • Beside other music a loop plays until the longest of the rest ends. Alone, or beside only loops, it plays until mx stops, unless times: 4 gives its number of passes.
  • Music shorter than the loop is followed by silence until the next pass, and music longer than the loop is refused.
  • A pass that raises prints its error, and the loop plays its next pass.
  • mix mx.print song.exs --pass 4 prints the fifth pass.

inputs holds what has arrived from outside: inputs.cc the latest value of each MIDI controller, inputs.notes the velocity of the latest note on for each note, inputs.osc the arguments of the latest message to each OSC address, and inputs.values each shared value. An entry is nil until something has sent it.

part(:bass, loop(1, fn _, inputs -> note(:c2, 1, cutoff: (inputs.cc[74] || 64) * 50) end), synth: :saw)

mx works a pass out half a second before it plays, so an input that changes shows from the next pass on.

A loop can also play a pass each time something arrives, as soon as possible, for playing live:

loop(fn trigger, _ -> note(trigger.note, 1) end, on: :midi_note)

on: :midi_cc waits for controller changes and on: {:osc, "/hit"} for messages to that address. trigger is what arrived: %{note: 60, velocity: 100}, %{number: 74, value: 64}, or %{address: "/hit", args: [1]}.

Samples

sample(:bd_haus, 1) plays a sample of Sonic Pi's pack by name, then a beat passes. Its names are the files in Sonic Pi's samples folder, without the extension. sample("kick.wav", 1) plays a WAV, AIFF or FLAC file by path.

sample(:loop_amen, 4, beats: 4)
sample(:loop_amen, 1, from: 0.5, to: 1.0)
sample(:loop_amen, 0.5, slices: 8, slice: 3)
sample(:ambi_choir, 4, rate: 0.5, amp: 0.6)
  • rate: 0.5 plays at half speed an octave lower, and a negative rate plays backwards.
  • beats: 4 plays the sample faster or slower so it lasts 4 beats at the tempo, in place of rate.
  • from and to cut the sample at seconds into its file, and start and finish cut it at positions from 0 to 1.
  • slices: 8, slice: 3 plays the fourth of eight equal pieces, counted from 0.
  • normalize: true scales the sample so its loudest moment reaches full scale before amp, for files recorded at very different levels.
  • A sample also takes amp, pan and the envelope options attack, decay, sustain and release.

A sample can come from a public GitHub repository, at a commit, tag or branch:

sample({:github, "sgossner/VCSL", "c1ea7bcc3c7309650ab0da9d15c9cd1fbc4a4c7e", "Idiophones/Struck Idiophones/Agogo Bells/Agogo_High_v1_rr1_Mid.wav"}, 1)

mx downloads the file into your cache when the song loads, and plays it from the cache after that. mix mx.samples.download SOURCE downloads a whole collection ahead of time, where SOURCE is sonic_pi or a repository as owner/repo@ref, optionally followed by :folder:

mix mx.samples.download "sgossner/VCSL@c1ea7bcc3c7309650ab0da9d15c9cd1fbc4a4c7e:Idiophones/Struck Idiophones/Agogo Bells"

Sampled instruments

sampled(folder) makes an instrument from a folder of recordings, one file for each key, and a part plays it with note and hit as it plays a synth:

mbira = sampled("VCSL/Idiophones/Plucked Idiophones/Mbira dzaVadzimu Nyamaropa, Zimbabwe, Low B")
part(:mbira, seq([note(:e4, 1), note(:fs4, 1), note(chord(:e3, :major), 2, strum: 0.05)]), synth: mbira)

mx reads each file's note, strength and take from its name, as the VCSL library names them: MBira4_pluck_Main_E4_19_50_100_rr2.wav is the mbira's E4, take 2. A note plays the recorded key nearest to it, sped up or slowed down to its pitch. vel from 0 to 1 picks among the recorded strengths, and repeated notes play the takes in turn.

For files named another way, list them with what each one is:

sampled([{"low.wav", note: :c2}, {"high.wav", note: :c3, strength: 2, take: 1}])

sampled({:github, repo, ref, folder}) plays a folder of a GitHub repository.

MIDI and OSC

A song sends MIDI and OSC as it plays notes, placed in the music the same way:

part(:synth, seq([midi(60, 1, velocity: 90), midi_cc(74, 64), midi(64, 1)]), midi: "IAC Driver Bus 1")
part(:lights, seq([osc("/flash", [1]), rest(4)]))
  • midi(60, 1) sends a note on and, a beat later, its note off. velocity is 100 and channel is 1 unless given.
  • midi_cc(74, 64) sends a controller change, and takes no time.
  • osc("/hello", [1, "a"]) sends an OSC message with numbers and strings as its arguments, and takes no time.
  • A part's midi: names the output port its MIDI goes to. Without it, MIDI goes to every connected port.
  • The song's osc_to: {"127.0.0.1", 9000} names where OSC goes, and osc_from: 9001 the port a loop's inputs.osc arrive on.

Shared values and changes

set(:root, 40) shares a value that every loop reads as inputs.values[:root], from its next pass on.

change changes the sounds of a part that are still playing, or one of its effects:

change(:lead, cutoff: 80, cutoff_slide: 2)
change({:lead, :reverb}, mix: 0)

An option's _slide partner makes the change glide over that many beats instead of jumping. A note can also carry its own later changes, so a held note glides to the next pitch without a new attack:

part(:lead, note(:a4, 4, changes: [{1, note: :c5, note_slide: 0.1}]), synth: :flute)

Live audio

live_audio: [mic: []] in song plays the sound card's first input through the speakers while the song plays. It takes input: 2 for another input, stereo: true, amp, pan and fx:. mx must start with a song that has live_audio to read the input.

Helpers

Pitches and chords:

  • pitch(:e5) is 76, the number of a note name.
  • chord(:a3, :minor7) and scale(:e3, :minor_pentatonic, octaves: 2) are lists of note numbers.
  • Chords: aug, dim, dim7, dom7, dom7_flat9, dom9, half_dim7, major, major6, major7, minor, minor6, minor7, sus2, sus4.
  • Scales: blues, chromatic, dorian, harmonic_minor, major, major_pentatonic, minor, minor_pentatonic, mixolydian.
  • intervals([60, 64, 67]) is the step from each note to the next, [4, 3].
  • melody(60, [4, 3, -2]) is the notes reached by taking those steps, [60, 64, 67, 65], so melody(:d4, intervals(motif)) plays a motif again from D4.
  • nearest(65, chord(:c4, :major)) is 64, the note of the chord in any octave closest to 65.
  • below(76, chord(:a4, :minor7)) is 72, the note of the chord closest below 76.
  • voice(chord(:g2, :major6), from: 62, to: 73) places each note of the chord once in that range, [62, 64, 67, 71].
  • fit(scale(:a4, :minor), chord(:e4, :dom7)) moves the scale's notes a semitone to hold the chord's notes, so G becomes G sharp.
  • cents(386) is an interval in cents as semitones, 3.86, and ratio(5 / 4) is the interval of a frequency ratio, also 3.86.

Lists and numbers:

  • ring([60, 64, 67], 4) is 64, the item at an index, wrapping round.
  • clamp(12, 2, 11) keeps a number from 2 up to 11, so it is 11.

Chance:

  • pick([60, 63, 67]) is one item of a list or a range.
  • chance(0.45) is true about 45 times in 100.
  • between(0.6, 0.95) is a number from 0.6 up to 0.95.

Random numbers are seeded from the song file, and in a loop from the part's name and the pass number, so a song plays the same notes each time it loads.