How the library is put together and why each module behaves as it does. The @moduledoc and
@doc in lib/ say what each function does and how to call it; this document holds the
design behind them, organised by module.
TuningFork
A sound is described rather than recorded: TuningFork.Voice is a waveform, a pitch, an
envelope and a filter, and TuningFork.Stage mixes whatever is sounding into one stream. The
library is pure Elixir with no dependencies, no C compiler and no audio device required.
Where the sound goes is decided by a TuningFork.Sink, the output backend. Any module
exporting open/1, write/2 and close/1 is a sink:
TuningFork.Sink.Silentis the default and discards everything.TuningFork.Sink.Collectsends samples to a process, for a test that asserts on them.TuningFork.Sink.Bufferkeeps everything written, for recording.TuningFork.Sink.Speakerplays through the machine. It lives in the separatetuning_fork_speakerpackage.
TuningFork.Score.render/3 returns PCM without opening a device.
Output is stereo unless a :channels of 1 says otherwise. Voices are synthesised in mono and
panned where they are placed.
TuningFork.available?/0 is false where tuning_fork_speaker is not installed or its device
will not open. A stage whose sink will not open falls back to silence rather than refusing to
start, so an application never has to branch on whether sound is possible.
TuningFork.start_stage/1 starts the stage under the library's own DynamicSupervisor
rather than linking it to the caller, so a stage that dies does not take the calling process
with it.
TuningFork.Cache
Rendered audio is kept on disk under $XDG_STATE_HOME/tuning_fork, falling back to
~/.local/state/tuning_fork. A relative $XDG_STATE_HOME is ignored.
A stored file is named for its key and for a fingerprint the caller supplies. A fingerprint that
changes when the code rendering the audio changes is what stops a stale file being read back.
fingerprint/1 hashes the vsn of the given module together with those of TuningFork.Voice
and TuningFork.Mixer, so audio rendered by an older version of the renderer is not read back
by a newer one.
Storing is best-effort: a directory that cannot be written is logged at debug level and the audio is returned anyway, so a caller never has to handle a cache failure.
TuningFork.Chord
A chord name is a root, an optional accidental, and a quality: C, Eb, F#m7, Bb^9. The
root is read at octave 4, so C is middle C at 60, and octave/2 moves it.
A name the module does not know gives [] rather than raising, so a chord typed wrong falls
silent instead of stopping the music.
The qualities notes/1 accepts:
nothing, M, maj | major triad |
m, min, - | minor triad |
+, aug | augmented |
o, dim | diminished |
7 | dominant seventh |
^7, maj7, M7 | major seventh |
m7, min7, -7 | minor seventh |
o7, dim7 | diminished seventh |
m7b5, ø | half diminished |
6, m6 | sixths |
9, ^9, m9 | ninths |
sus2, sus4 | suspended |
5 | the bare fifth |
TuningFork.Curve
A curve is a multiplier over the field it is attached to, never an absolute value. A freq curve
of 2.0 is an octave up on whatever note it is used with.
Reading before the first breakpoint or after the last gives that breakpoint's value, so a curve never has to state what happens outside its own range.
simplify/2 drops interior points one at a time, each time whichever point its two neighbours
already predict most closely, so peaks survive and points sitting on a straight line do not. It
keeps at least two breakpoints: a curve of one point is a value that does not move, which is
hold/1, not something thinning can arrive at.
TuningFork.Envelope
duration/1 is attack, decay, hold and release together, and it is what decides how many
samples a voice renders. When sustain is 0.0 it is attack and decay alone: the level is
zero from the end of the decay on, so hold and release would be silence, and a piano chord
held for four cycles must not cost four cycles of synthesis.
:curve bends the decay. A curve of 1.0 is a straight line; above 1.0 the level falls away
quickly and then trails.
TuningFork.Filter
The filter is what makes a saw sound like an instrument rather than a buzz. TuningFork.Voice's
:cutoff is a one-pole smoother of 6 dB an octave with no resonance, which barely dents a
sawtooth, whose own harmonics already fall at 6 dB an octave.
The two models
:ladder is four one-pole stages with the output fed back into the input, each stage
saturating: the transistor ladder. Because the feedback is subtracted from the input, resonance
makes it quieter rather than louder, and because every stage saturates, it cannot run away
however hard it is driven. :q is scaled down by feedback/1 and the level put back by
makeup/1.
:svf is a topology-preserving state-variable filter: cleaner, no saturation, and the only
model that can be a highpass or a bandpass. A :highpass or :bandpass is therefore always
:svf. It has no resonance compensation, so a high :q on an :svf really is much louder.
Resonance, drive and makeup
feedback/1 scales :q by 0.13 and holds it at 8, so the numbers it takes are the ones
Strudel's resonance takes: a :q of 9 is a squelch, not a siren. A filter asked for a :q of
40 is the same as one asked for 62; both are as resonant as it goes.
:drive is an exponent, so 0.0 is unity and the default 0.69 is very nearly 2. The output is
divided by the same number in makeup/1, so driving harder saturates harder at the same volume
rather than simply being louder.
makeup/1 does two things at once: 1 / drive takes back the gain the drive put in, and
1 + k puts back the level a ladder loses as it resonates. That second part is held at 1.75 so
a filter at full resonance does not run away.
For the :svf, a bandpass or a resonant highpass has a gain of about q at its corner, so at
any real resonance it arrives over full scale and is clipped square. The output is divided by
q to leave the shape and take away the loudness. The lowpass is left alone: makeup/1
handles the ladder, and a flat :svf lowpass has a gain of one already.
The ladder's drive, feedback and makeup gain depend only on the filter, so start/1 works
them out once and carries them in the state step/5 threads through, rather than taking
two exponentials per sample. The state is opaque for that reason: its shape is the filter's
business.
The sweep
With an :envelope and an :amount, the cutoff starts at :hz, rises to amount octaves
above it as the envelope opens, and falls back as it closes. An :amount of 3 with a short
decay is the classic acid sound:
TuningFork.Filter.new(hz: 300, q: 9.0,
envelope: TuningFork.Envelope.new(attack: 0.002, decay: 0.18, sustain: 0.1),
amount: 3.5)Stability
Neither model blows up as the cutoff sweeps, the way a naive digital filter does when its
coefficients change mid-note: the ladder saturates and the :svf is a topology-preserving
transform. The cutoff is held between 20 Hz and just under Nyquist whatever it is asked for.
TuningFork.Gm
A MIDI file names its instruments as program numbers, and General MIDI says what the numbers
mean: 0 is a grand piano, 33 an electric bass, 57 a trumpet. Gm maps those numbers onto
voices built from the waveforms, envelopes and filters TuningFork.Voice has, one family of
settings per group of programs, so an arbitrary file comes out as an arrangement of
distinguishable parts rather than as one voice playing everything.
Every voice is derived from a base voice the caller supplies, so its gain, pan and filter
defaults carry through. The result is an ordinary TuningFork.Voice and can be overridden:
%{Gm.for_program(0, base) | shape: :sine}These voices are what gm_* names play while their soundfont is still arriving, or on a
machine that cannot decode one. A :struck voice decays over 1.2 seconds to nothing and
ignores how long the note is held,
which is what a hammer on a string does; every other family holds at its sustain level for
the note and releases after.
The families are :struck (piano and tuned percussion), :organ, :guitar, :bass,
:strings, :brass, :reed, :pipe, :lead and :pad. :other covers programs 96 to
127, the sound effects, and leaves the base voice unchanged.
The drum kit is entirely filtered noise: low-passed for drums and toms, high-passed for hats
and cymbals, and through both for snares. score/2's :gain default of 0.2 is a per-note
level; dense music needs less.
TuningFork.Midi
What the conversion keeps and drops
- Tempo changes are kept, as the score's tempo map.
- Note lengths are kept, as envelope decay, rounded to
:quantize. - Velocity becomes gain, scaled against the synth's own.
- Pitch bend is read two ways. Whatever the wheel is at when a note starts multiplies that
note's frequency; a bend that moves while the note sounds becomes a
TuningFork.Curvemeasured against that starting value. - Instruments are not chosen here. A program change is a number, and
:synthssays what it plays. - Control changes other than pitch bend are parsed and then dropped.
- Aftertouch is dropped at parse time.
Naming the instruments
:synths maps a channel to a voice. :drums maps a note number to a voice, and applies on a
percussion channel, where a note number names a drum rather than a pitch. :default is what
anything unclaimed is played with.
to_score(midi,
synths: %{0 => lead, 2 => bass},
drums: %{36 => kick, 38 => snare, 42 => hat},
default: pad
)Which channel the drums are on
:drum_channels defaults to [9]: channel 10 counted from zero, the General MIDI
convention. Files that ignore the convention, such as a library of loops that writes
everything on channel 1, need it stated:
to_score(midi, drum_channels: [0], drums: %{36 => kick, 38 => snare, 46 => hat}):auto guesses instead. Channel 9 is always included. Another channel is taken as percussion
only when all three of these hold:
- no program change anywhere on that channel
- every note it plays is between 27 and 87, the General MIDI percussion range
- at least half its distinct note numbers are between 35 and 59, the core of a kit
Percussion played entirely above 59 (congas, bongos, timbales) is not found by the guess,
because those numbers are also the middle of a keyboard. Such a file needs :drum_channels
said outright.
Note lengths and the render cache
TuningFork.Score keys its synthesis cache on the voice, so every distinct note length is a
separate render. :quantize rounds lengths to a grid, 10 ms by default, which collapses a
performance's hundreds of distinct lengths into tens. nil turns it off and is slow.
Encoding
encode/2 writes format 0: one track, 480 ticks a beat. A note becomes a note on and a note
off, the gap between them being how long the voice sounds; a voice's pitch becomes the
nearest semitone and its gain becomes velocity. What a synthesised voice is does not survive:
a MIDI file carries notes, not instruments. Reading one back gives the notes, and to_score/2
is where the sounds are named again.
A MIDI delta time is seven bits a byte, with the top bit set on every byte but the last.
TuningFork.Mixer
Everything works on signed 16-bit little-endian PCM. Buffers of different lengths mix to the length of the longest.
A sample is one 16-bit number. A frame is one sample per channel, so in stereo a frame is two
samples and four bytes. mix/2, scale/2, loudness/1, peak/1, clipped/1 and
normalise/3 work a sample at a time and take no channel count. silence/2, take/3,
mix_at/4 and pan/3 measure in frames and take one, defaulting to 2.
fold/3 is what closes a rendered loop without a join: a note or a reverb tail still sounding
at the end is heard at the beginning of the next time round.
pan/3 is constant power: the two channels are the cosine and sine of the same angle, so a
centred sound sits about 3 dB below its mono render and a pan sweep does not dip in the
middle.
Mixing sums and then clamps, and the clamp leaves no other trace: a mix that ran over comes
back merely loud. More than a handful of samples counted by clipped/1 means the parts need
less gain before rendering; normalising afterwards scales the flattened peaks down rather than
restoring them. soft_clip/2 bends loud samples towards full scale instead, so a mix that ran
over comes back loud rather than square; a lower threshold rounds more of the signal and
sounds more compressed, and 1.0 is the hard clamp mix/1 already does.
TuningFork.Notes
A note name is an atom: a letter a to g, an optional s for sharp or b for flat, then
an octave, which may be negative. :a4 is 440 Hz. A number is taken as a frequency in Hz and
passed through unchanged. scales/0 and chords/0 list the names that can be asked for;
anything else raises.
semitone/1 accepts a string that has never been an atom, so a name read from a file or
typed into an editor resolves without String.to_atom/1. name_of/1 always uses sharps, so
a flat comes back as the sharp that sounds the same.
TuningFork.Osc
A message is an address, a type tag string, and the arguments themselves, each padded to a multiple of four bytes. Everything on the wire sits on a four-byte boundary, so what follows a value starts after its padding rather than straight after its bytes.
| Elixir | Tag | |
|---|---|---|
| integer | i | 32-bit |
| float | f | 32-bit |
| binary | s | a string |
{:blob, bytes} | b | arbitrary bytes |
true false nil | T F N | no argument bytes at all |
{:int64, n} | h | 64-bit |
{:double, f} | d | 64-bit |
bundle/2 puts messages under one time tag, so everything in it is meant to take effect
together. :now is the tag every receiver treats as immediately.
decode/1 returns {:error, :not_a_message} rather than raising, so a stray packet on the
socket is reported rather than crashing the reader. The module is only the bytes;
TuningFork.Osc.Client is the socket.
TuningFork.Osc.Client
OSC is UDP, and :gen_udp comes with the runtime, so the client needs no dependency and no
native code. The default port, 57120, is SuperCollider's.
:listen binds a port and sends what arrives to the owner process as
{:osc, address, args, from}:
{:ok, client} = Osc.Client.start_link(listen: 57_121, owner: self())
receive do
{:osc, "/tempo", [bpm], _from} -> Stage.pattern_cps(cps_of(bpm))
endA packet that is not a message this understands is dropped rather than delivered, so a stray one on a shared port cannot take the reader down.
TuningFork.Osc.Out
Each note becomes one message when its moment arrives. Nothing is synthesised here: whatever
is listening makes the sound. The default is /play with the frequency in hertz, the gain,
how long the note sounds in seconds, and the pan:
"/play", [440.0, 0.8, 0.35, 0.0]There is no OSC convention for notes; every receiver has its own, so :address and :shape
are how a different one is met. messages/2 gives what would be sent without sending it.
Every message is timed against one monotonic clock reading taken at the start, so a long
piece does not drift. Stopping sends the :hush address so the far end is not left sounding.
TuningFork.Rand
A generator is a value, not a process. Every function takes one and returns the next alongside its result, so the caller threads it through. The same seed gives the same sequence on any node and in any process. The generator is a linear congruential one with modulus 2^31.
TuningFork.Reverb
The reverb is Freeverb: eight comb filters into four allpasses per channel, with Jezar's delay lengths scaled to the sample rate, the right channel's lines 23 samples longer than the left's so the two sides decorrelate, damping of 0.4 in the comb feedback, an input gain of 0.015 and a wet gain of 0.5. The state carries between calls, so a chunk of audio handed in leaves a tail that comes back in the chunks after it. That is what makes it a room rather than an echo: feed it silence and it keeps ringing until the room has died away.
A comb filter is a delay fed back on itself: one hit becomes a run of repeats a fixed time apart. Eight of them at lengths that share no common factor give repeats that never line up, which is what turns a stutter into a wash. The allpasses that follow leave the tone alone and only smear the timing, so the wash stops sounding like separate delays.
size is how long the room rings, in seconds to silence, as Strudel's roomsize is: the
comb feedback is exp(-6.9 × 0.03 / seconds), the gain that brings a 30 ms comb down 60 dB
in that time, held between 0.1 and 10 seconds. A mix of zero returns the input untouched
and does not disturb the state, so a pattern that asks for no room costs nothing. Stereo
goes through a left and a right room; mono through the left.
Each delay line is a :queue: the oldest sample comes off the front and the new one goes on
the back, amortised constant time with no copying, where a functional array copies part of
its tree on every write. The state stays a value — two players never share a room — and the
output is sample-for-sample what it was.
TuningFork.Ring
An index past the end wraps to the start, and a negative one counts back from the end, so a
counter that only ever climbs still gives a note. With TuningFork.Tick.tick/1 this is how a
line walks through a set of notes a round at a time rather than repeating one:
part(bpm: 120, synth: Kit.voice(%{note: "c2", shape: :saw}, 0.4))
|> play(Ring.at(~w(c2 e2 g2 a2)a, tick()), 4)TuningFork.Scale
A degree is a step along the scale, not a semitone. Degree 0 is the root, 1 the next note up, and a degree past the end of the scale carries on into the octave above: degree 7 of a seven-note scale is the root an octave up, and degree 9 is the third above that. Negative degrees go down the same way.
"g:minor" is a root and a scale. The root may carry an octave, as in "g2:minor", and
without one it is octave 3, so "g:minor" starts at g3. Sharps are s and flats are b:
fs, eb. The scale name may be left off, in which case it is :major.
TuningFork.Pattern
Patterns of events over cycles, in the style of TidalCycles and Strudel. A pattern is asked about a stretch of time and answers with the events in it:
TuningFork.Pattern.query(pattern, {0.0, 0.25})
[%{whole: {0.0, 0.5}, part: {0.0, 0.25}, value: :bd}]Values are whatever was put in: atoms, maps, numbers. Nothing in the module knows about frequencies or synthesis.
Cycles
Time is counted in cycles. A cycle is one turn of the loop; how many seconds that is gets decided at playback. Cycle numbers count up from zero and keep counting, and a pattern is a function of that number, so asking about cycle 97 gives cycle 97's events whether or not cycles 0 to 96 were ever played.
Every combinator returns a one-cycle-long pattern, so fast(p, 3) stacks beside a pattern of
four without either being rescaled.
Events
query/2 returns a list of %{whole: span | nil, part: span, value: term}. whole is where
the event sits as written and is nil for a continuous value; part is the portion of it the
query covers; value is what it is.
A query that cuts across an event returns a fragment: the same whole, a shortened part.
onset?/1 tells a note beginning from one already sounding:
query(pure(:x), {0.0, 0.5}) whole {0.0, 1.0}, part {0.0, 0.5}, onset? true
query(pure(:x), {0.5, 1.0}) whole {0.0, 1.0}, part {0.5, 1.0}, onset? falseThe span end is exclusive, so querying {0.0, 1.0} and then {1.0, 2.0} reports each event
once. A zero-width span samples continuous patterns and reports nothing discrete.
The functions, grouped
pure/1, silence/0, run/1, binary/1 | one value a cycle, nothing, counting, bits |
fastcat/1, slowcat/1, stack/1 | in a cycle, a cycle each, all at once |
arrange/1, polymeter/2 | cycles each in turn, several metres at once |
fast/2, slow/2, shift/2, rev/1, palindrome/1 | move it about |
zoom/3, linger/2, ribbon/3, clip/2 | play a part of it, or hold it |
iter/2, iter_back/2, swing/2, swing_by/3 | shift it on as it goes |
every/3, first_of/3, last_of/3, when_cycle/3 | change it on some cycles |
chunk/3, chunk_back/3, fast_chunk/3, inside/3, outside/3 | change part of it |
off/3, superimpose/2, layer/2, stut/3, echo_with/4 | lay copies over it |
jux (in TuningFork.Pattern.Control) | left as written, right changed |
euclid/3, euclid_rot/4, euclid_legato/3, bjorklund/2 | evenly spread hits |
degrade/3, undegrade/2, filter_events/2 | take events away |
sometimes/3, often/3, rarely/3, always/2, never/2 | change some of them |
some_cycles/3, some_cycles_by/4 | change some whole cycles |
sine/0, cosine/0, saw/0, isaw/0, tri/0, square/0 | values at every instant |
rand/1, irand/2, brand/1, choose/2, wchoose/2 | values chosen at every instant |
segment/2, range/3, with_value/2 | shape and rescale |
add/2, sub/2, mul/2, divide/2 | arithmetic on the values |
squeeze/2, squeeze_values/2, arp/2 | fit a pattern inside events, spread a chord out |
pick/2, invert/1, perlin/1 | choose between patterns, flip, drift |
Steps
A cycle is one reference point and a step is the other. steps/1 is how many a pattern is
counted as having: three for fastcat([:a, :b, :c]), and four for "a [b c] d e", where the
bracket is one step however much is inside it.
On its own the count changes nothing about what a pattern plays. It is what the stepwise
functions read: stepcat/1 gives each pattern room in proportion to it, and pace/2 plays a
pattern at so many steps a cycle whatever it was written as. Two patterns paced the same run at
the same speed, which is what makes a ten-step phrase and an eighteen-step one line up.
stepcat/1 | end to end, each given room in proportion to its steps |
pace/2 | play at so many steps a cycle, whatever it was written as |
expand/2, contract/2, extend/2 | count it as more or fewer, and play it over |
take/2, drop/2 | the first or last so many steps |
shrink/2, grow/2 | wear it down or build it up, a step a cycle |
zip/1, tour/2 | one step from each in turn, each variation in turn |
Where fastcat/1 gives every pattern the same slice of the cycle whatever is in it,
stepcat/1 gives a three-step pattern three times the room of a one-step one, so the steps
come out the same length however they were grouped. expand/2 on its own does nothing
audible; under stepcat/1 the pattern takes factor times the room, and under pace/2 it
runs factor times slower. extend/2 is fast/2 and expand/2 together, which is what
makes a repeated phrase take proportionally more room in a stepcat/1 rather than being
squeezed. shrink/2 and grow/2 give a phrase that unravels over several cycles rather than
repeating.
fast/2 carries the step count through rather than scaling it: fastcat/1 sets its own
afterwards, and extend/2 relies on speeding up and re-counting being two separate things.
stepalt/1 takes a step from each group in turn, each group offering a different pattern each
time round: a group of two beside a group of three takes six passes to come back to where it
started, and the whole thing is stepcat/1ed into one pattern.
Combining values
add/2, sub/2, mul/2 and divide/2 keep the left pattern's timing and read the right one
at each event's start, so the left decides when things happen and the right only says by how
much. Where the values are control maps rather than numbers, every key they share is combined
and the rest are kept, so add(pattern, %{note: 12}) transposes without disturbing anything
else. Dividing by zero leaves the value as it was.
app_left/3 is the general form, and the one Strudel calls appLeft: the left pattern's
wholes are kept, but each event is cut into parts wherever the right pattern's events begin
and end inside it, every part joined with the right value it overlaps. Only the first part
starts where the whole starts, so only it is an onset and only it sounds — but every part is
still there to be queried, which is what lets a later segment/2 or struct/2 find a value
that began mid-event. Control.set/3 given a pattern is app_left/3, so
chord("<Bbm9 Fm9>/4") |> n("[0 ~ 2 ~](3,8)") keeps the chord's four-cycle whole while
carrying each n in its own part, and the voicing and segment(4) after it play the melody
Strudel plays rather than four cycles of one chord. An event the right has nothing for is
left out, as s("bd").gain("~") is silent in Strudel. A continuous right pattern is read
once per event, at its start, as add/2 reads it.
segment/2 reads value_at/2 at each step start, discrete or continuous, so it chops a
signal and also re-times a discrete pattern to a grid. clip/2, fast/2, slow/2 and
ply/2 given a pattern of amounts go through patterned/3: each amount is applied to the
whole pattern and queried over the amount's own span.
Squeezing
squeeze_values/2 replaces every event with a whole pattern of its own, fitted into the
event's span. It is what turns a chord name into the notes of the chord, sounding for exactly
as long as the name was written for. squeeze/2 fits a copy of a pattern inside every event
of a structure, so where fast/2 speeds a pattern up evenly, an uneven structure gives
unevenly sized copies.
Fitting uses compress/3, which measures from the start of a cycle, so an event in cycle 4
is asked for as the same slice of a cycle rather than as 4.25 to 4.5. An event straddling a
cycle line cannot be put that way and gives silence.
Changing part of a cycle
chunk/3 applies its function to the whole pattern and only then narrows the result to the
current chunk, so something like rev/1, which moves events out of the part it was handed,
still fills the chunk. inside/3 changes the scale a function works at: inside(p, 2, &rev/1)
reverses each half of the cycle rather than the whole of it.
Euclidean rhythms
euclid/3 is Bjorklund's algorithm: (3, 8) is the tresillo, (5, 8) the cinquillo. Rests
are silence/0, so the result stacks over another pattern without gaps of its own. A negative
hit count inverts the result, so euclid(p, -3, 8) sounds on the five rests. euclid_legato/3
holds each hit until the next one rather than for one step: the difference between a
euclidean rhythm played staccato and played legato.
Randomness
rand/1 gives a new number at every instant, hashed from the position and a seed, so the same
position always gives the same value and a different seed gives a different sequence.
degrade/3 and sometimes_by/4 roll each event by the start of its whole, so the same
event in the same cycle is dropped or kept the same way every run, and
stack([degrade_by(p, 0.4), undegrade_by(p, 0.4)]) is p again with nothing counted twice.
perlin/1 wanders between one whole cycle and the next rather than jumping, so a filter swept
by it slides where one swept by rand/1 rattles.
degrade_by/3 is the Strudel spelling of degrade/3. always/2 and never/2 exist for
writing always or never where a chance was expected.
TuningFork.Pattern.Control
Patterns of notes and the settings that shape them, written as a chain. Each function takes a
pattern and returns one, so they read left to right and stack in any order. The values are
maps of controls, which TuningFork.Kit turns into voices.
import TuningFork.Pattern
import TuningFork.Pattern.Control
stack([
s("bd*4") |> gain(0.9),
s("hh*8") |> gain(0.4) |> pan(sine()),
n("<0 4 0 9 7>*16")
|> scale("g:minor")
|> transpose(-12)
|> shape(:saw)
|> cutoff(300)
|> resonance(9)
|> lpenv(3.5)
|> lpdecay(0.12)
])Starting a pattern
s/1 | a pattern of sounds: s("bd*4") |
n/1 | a pattern of scale degrees: n("0 4 7") |
note/1 | a pattern of notes by name or number: note("c3 eb3 g3") |
Each takes mini-notation, an already-built pattern, or a bare value.
Shaping it
scale/2 | which scale the degrees are in: "g:minor" |
transpose/2 | semitones up or down |
octave/2 | which octave the scale sits in, 3 without one |
shape/2 | :sine, :saw, :square, :triangle, :noise |
gain/2 pan/2 | how loud, and where |
release/2 | how long the tail is, in seconds |
acid/2 | the 303 squelch from one knob, 0.0 to 1.0 |
cutoff/2 | where the filter turns over, in hertz |
resonance/2 | how much it peaks there: 0.707 flat, 8 a howl |
lpenv/2 | octaves it sweeps above the cutoff over the note |
lpattack/2 lpdecay/2 lpsustain/2 lprelease/2 | the shape of that sweep |
attack/2 decay/2 sustain/2 adsr/5 | the shape of the note itself |
highpass/2 | a one-pole highpass, 0.0 to 1.0 |
velocity/2 | how hard it is struck; multiplies gain/2 |
speed/2 | how fast it runs: 2.0 is an octave up and half as long |
bank/2 | which drum machine the names come from; see TuningFork.Kit.banks/0 |
crush/2 distort/2 | bits to round to, and how hard to drive it |
delay/2 delaytime/2 delayfeedback/2 | the note played again, later and quieter |
room/2 roomsize/2 | the space it is all heard in |
jux/2 jux_by/3 | this on the left, a changed copy on the right |
TuningFork.Pattern.add/2 | add to the controls rather than replacing them |
fm/2 fmh/2 fmattack/2 | a second oscillator bending this one |
vib/2 vibmod/2 | how fast the pitch wavers, and how far |
coarse/2 phaser/2 phaserdepth/2 | hold samples, sweep a notch |
ftype/2 bpf/2 bpq/2 hpq/2 | which kind of filter, and how it peaks |
vowel/2 | make it speak |
orbit/2 postgain/2 xfade/2 compressor/2 | which bus, and what happens on it |
voicing/1 | a chord name to the notes that play it |
Every one of these takes a plain value or a pattern, so gain(pattern, sine()) sweeps the
level across the cycle the same way gain(pattern, 0.5) pins it. The named functions are all
set/3 with the key filled in; a pattern given as the value is sampled at each event's start,
and where it has nothing to say the event is left alone.
The names Strudel uses
lpf/2 is cutoff/2, lpq/2 is resonance/2, hpf/2 is highpass/2 and degrade_by/3
is degrade/3, so an example copied from Strudel's workshop reads the same here. size/2 is
roomsize/2, struct/2, mask/2, early/2 and late/2 are Strudel's, and s/2, n/2
and note/2 set their control on an existing pattern so a chain can start with either.
Strings are mini-notation everywhere
Every setter given a string reads it as mini-notation, as Strudel does: room("<0 .2>")
is 0 one cycle and 0.2 the next, and bank("crate") is a one-word pattern whose value is
"crate". struct/2, mask/2, late/2 and early/2 take strings the same way, and
mini/1 is the same reading with no control around it, for a string that has a method
called on it ("<0 1>/16".early(.5)). A string that will not parse raises as it does in
s/1, so a typo is reported rather than becoming a sound name.
A setter given a pattern is Pattern.app_left/3: the receiver's wholes, cut into parts
where the value pattern changes. set/2 merges a whole pattern of controls the same way,
which is how n("0 2") |> set(chords) |> voicing() gives each degree the chord sounding
under it.
Banks and General MIDI names
bank/2 with a name from TuningFork.Kit.banks/0 adjusts the synthesised kit; any other
name goes in front of each sound with an underscore, so s("bd sd") |> bank("crate") plays
crate_bd and crate_sd from TuningFork.Sample.Bank. A sound gm_* from
TuningFork.Gm.Names plays the General MIDI program it names on a TuningFork.Gm voice, at
the event's :note. An index after a colon picks a file from a bank name that holds several
(sd:3) and is ignored on a name that holds one.
The filter controls
resonance/2 at 0.707 is flat, 2 gives the filter a voice, 8 is a howl, and past about 12 it
rings on its own. lpenv/2 is the movement an acid line is made of: a low cutoff, high
resonance, and a sweep of three or four octaves that falls away over lpdecay/2.
acid/2 sets the whole filter from one knob, so a lead needs nothing else said about it.
Turning it up lowers nothing and raises everything else: more resonance, a deeper sweep, a
faster fall. At 0.0 it is a plain filtered saw. It sets cutoff, resonance, lpenv,
lpsustain and lpdecay, and anything chained after it wins, so acid(0.5) |> lpdecay(0.3)
keeps the squelch and lengthens the fall, and acid(0.5) |> cutoff(600) moves where it sweeps
from.
ftype/2 chooses :lowpass, :highpass or :bandpass. :highpass and :bandpass are
always the clean state-variable filter, since the saturating ladder is a lowpass; see
TuningFork.Filter. highpass/2 is a separate plain one-pole highpass with no resonance;
hpq/2 applies only to the real filter chosen by ftype(:highpass) and cutoff/2.
Buses
room/2, postgain/2, xfade/2 and compressor/2 belong to a bus rather than to a note.
orbit/2 says which bus, counting from zero, and TuningFork.Pattern.Player keeps one
TuningFork.Reverb per bus and mixes each on its own before summing them, so the drums can
be dry on bus 0 while the lead swims on bus 1. postgain/2 sets what a bus is worth in the
mix, so a whole layer can be brought down without touching how any of it was written;
xfade/2 is the same said as a crossfade, equal at 0.5. A compressor pulls the loud moments
down so the quiet ones can come up: 0.5 is firm, 1.0 flattens.
Delay and room are not the same kind of thing
delay/2 is the note played again: real events, later and quieter, keeping the filter sweep
and pitch they had rather than being a copy of the mix. echoes_of/1 gives the four repeats
of one event, with delaytime capped at half a cycle each, and echoes/1 is the same as a
pattern, querying two cycles back so echoes of earlier notes land in the span asked for.
TuningFork.Pattern.Player uses echoes_of/1, not echoes/1: when a note with a delay
starts it puts the repeats on a pending list and starts each when its time comes, so the
pattern is queried once per block rather than over two extra cycles, and a repeat still
lands after the pattern it came from has been swapped out.
room/2 is one space that everything on an orbit shares, run by TuningFork.Reverb, but
each note sends its own amount into it: the player mixes every voice dry, mixes the voices
scaled by their own room into a send, runs the send through the room and adds what comes
back, as superdough's orbit bus does. So a dry drum stays dry beside a wet chord, and the
tail rings on after the notes stop. roomsize/2 (Strudel's size) is how many seconds
the room rings, 2 unless set; the last note to ask sets it for the orbit.
Sound design controls
fm/2 is frequency modulation. A whole-numbered fmh/2 gives a harmonic tone (bells,
electric pianos, basses with a bite) and anything else gives a clangorous one. fmattack/2
holds the modulation back for a portion of the note, so the note starts clean and grows
teeth, which is most of what makes an FM bass sound plucked.
vowel/2 is three bandpass filters at the frequencies a mouth resonates at. It works on
anything with harmonics to filter, so a saw speaks and a sine does not.
crush/2 coarsens the level and coarse/2 coarsens the time. phaser/2 is four allpass
filters swept together, which move the notches they make about: the whooshing in a phaser
pedal.
speed/2 doubles put the pitch up an octave and halve the length. Negative values are not
played backwards; they are taken as their size.
bank/2 is carried through to TuningFork.Kit, which uses it to pick between kits of the
same drum names. A kit that does not know the bank plays its own sound rather than falling
silent.
Drawing
pianoroll/1 and scope/1 mark a row to be drawn. Nothing is drawn for a row that does not
ask. Drawing costs a picture sent to the terminal every time it moves, so a session showing
one row moves smoothly where a session showing eight does not. A scope shows what reached the
speakers, which is everything playing rather than the row alone, so one is plenty.
Chords
chord/1 tags chord symbols, dict/2, anchor/2, mode/2 and offset/2 set how they
are voiced, and voicing/1 turns each symbol into its notes through
TuningFork.Pattern.Voicing, one event per note with the symbol's whole and part — the
join Strudel calls outerJoin, so a chord written <Bbm9 Fm9>/4 sounds for four cycles
and a chord cut by n/2 keeps only the note n asks for. The voicing controls are dropped
from the notes; every other control on the chord event is kept. A symbol the dictionary does
not know is silent. TuningFork.Pattern.arp/2 after it spreads the chord out instead.
voicing/1 given a string is chord/1 then voicing/1, for the older spelling.
TuningFork.Pattern.Voicing
A port of Strudel's renderVoicing and its dictionaries (ireal, ireal-ext, lefthand,
triads, guidetones, legacy), generated from the Strudel source so a chord voices to the
same MIDI numbers here as there. A symbol is split into root and quality, the quality looked
up in the dictionary (with Strudel's aliases: ^ is major seventh, - minor, +
augmented), and each of the dictionary's voicings for it placed against the anchor: :below
keeps the top note at or under the anchor, :above the bottom note at or over it, :root
puts the root at the anchor, and :duck keeps the whole voicing under it. offset moves
along the list of voicings, and n picks one note of the chosen voicing, counted from the
bottom and wrapping by octaves. render/2 answers :error for a symbol or quality it does
not have rather than guessing.
TuningFork.Pattern.Mini
The mini-notation is that of TidalCycles and Strudel, read from a string.
bd sn hh | in order, sharing the cycle | |
~ | a rest | bd ~ sn ~ |
[ ] | one step, subdivided | bd [sn sn] |
< > | one per cycle, in turn | bd <sn cp> |
, | at the same time | [bd*4, hh*8] |
* | that many times a step | bd*3 |
/ | over that many cycles | bd/2 |
! | repeated as separate steps | bd!3 |
@ | that many steps' worth | bd@3 sn |
? | dropped at random | hh*8? or hh*8?0.3 |
( ) | euclidean, hits and steps | bd(3,8) or bd(3,8,2) |
: | an index on a word, plain or patterned | bd:3, sd:<2 3> |
Modifiers stack left to right: bd*2!3 is three steps of a doubled bd. * and / take
a bracketed or angled pattern as well as a number — x*<1 2> — read through
TuningFork.Pattern.fast/2's patterned form. A number may be written without a leading
zero, so [0 .01]*4 reads as Strudel writes it. A patterned index after a colon gives a
pattern of word:index strings, so rd:<1!3 2>*2 is rd:1 three cycles and rd:2 the
fourth, twice a cycle.
Steps are counted at the top level, so a [b c] d is three steps however many things the
bracket holds. An item written x@3 is three steps' worth, which is what its weight says.
A string that will not parse raises ArgumentError naming what was wrong and where, rather
than returning silence. parse_safe/1 wraps that for a front end taking what somebody is still
typing.
located/1 keeps every value paired with the character span it was written at, and
locate/2 reads that back for one absolute cycle position, so a front end can highlight the
token that is sounding. Timing applied outside the string (fast/2 on the parsed pattern) is
not seen, so a highlight follows the notation rather than the chain around it.
TuningFork.Pattern.Player
The player owns no clock, in the same way as TuningFork.Transport: the frames it is asked
for are the clock, so its position is exactly the audio that has been produced. A pattern has
no end, so the player loops forever and cycle/1 reports where it has reached.
Swapping while it plays
update/3 puts a new pattern in without stopping. at: :cycle holds the new pattern until the
next cycle line, which is what makes an edit land in time rather than halfway through a bar.
at: :now takes effect on the next block. Either way the position carries on, so a pattern
that counts cycles keeps counting. Notes already sounding finish as the pattern read when they
started.
Voice cap
Past the :voices cap the oldest notes are faded out over 10 ms rather than cut, which would
click. A fading note is still counted by sounding/1 for the few milliseconds it takes to go
quiet; playing/1 counts only the ones that are not on their way out.
Recordings still on their way
A voice for a recording that has not been fetched from the web yet would block the mix for
as long as the download takes, so the player's voice function on a stage is
TuningFork.Kit.voice/3 with wait: false: the fetch starts in the background, that hit is
silent, and the next one plays. TuningFork.Sample.Set.load/1 also starts fetching every
file of a set as soon as the set is named, so by the time a pattern reaches a sound it is
usually there. Offline rendering keeps waiting, since a file is worth a pause there.
Buses
Each voice carries its route, {orbit, room}. Each orbit is mixed on its own — every voice
dry, plus the room fed by each voice at its own room amount — levelled on its own, and only
then summed. That is what makes a bus a bus: the drums can be dry while the lead swims.
A bus setting (room, roomsize, postgain, xfade, compressor) belongs to the bus
rather than to one note, so the loudest ask in a block wins and holds until something says
otherwise, which lets a reverb tail keep ringing after the note that asked for it.
The compressor pulls the peak of each block back towards a target level, which levels one bus against another without needing a detector that runs ahead of the audio.
Rendering without a device
render/3 renders a stated number of cycles to signed 16-bit PCM, ready for
TuningFork.Wav.encode/2, which is how a pattern is heard somewhere with no speaker of its
own, a notebook in a browser being the usual case. The result closes without a break: a tail
of silence is rendered past the last cycle and folded back over the beginning, so a note held
across the cycle line is heard at the start of the next time round.
TuningFork.Pattern.Source
A typed line is one of two kinds. A line beginning with a function name from
TuningFork.Pattern or TuningFork.Pattern.Control followed by ( is evaluated as Elixir
with both modules imported; anything else is read as mini-notation. starts_code?/1 is the
test, so a front end can show which of the two a line is before running it.
The openings are taken from the two modules' exported functions rather than listed by hand, so the list cannot go stale when either module grows. A stale list shows up as a perfectly good line being read as mini-notation and reported as a syntax error in it.
Either kind reports rather than raising: {:error, message} with the reason as the parser or
the compiler gave it, trimmed to one line.
A line of Elixir is evaluated, so it can do whatever Elixir can. That is the same bargain TidalCycles and Strudel make, where the person typing is the person running it, but it means a line from somewhere else should be read before it is played.
A line beginning |> carries on the one above it. A front end writing a chain down the screen
joins such lines to the one they follow before parsing; on its own a continuation is not a
pattern.
TuningFork.Part
A part is written by moving through it rather than by counting into it. It carries a cursor
measured in beats: play/4 puts a note where the cursor is and moves it on by the note's
step, and rest/2 moves it without playing anything.
How long a note lasts and how long until the next one are two separate things. The step is
how long until the next note; :release is how long this note goes on sounding, and may be
longer than the step, in which case notes overlap.
Each part is written from its own beat zero and they are mixed by TuningFork.Score, so a
part that comes in later says so itself with a leading rest/2.
Every part has its own seeded random generator, so anything random in it renders the same
every time. {:between, low, high} option values, play_any/4, maybe/4, pick/3 and
between/3 all draw from it.
steps/4 places every step the same distance apart; pattern/4 is for when the spacing
itself varies.
TuningFork.Score
A score is a length in beats, a tempo map, notes placed on beats, and layers: groups of notes
with their own effects. render/3 produces exactly :beats beats of audio. A note whose tail
runs past the end is wrapped back to the start, so the loop point is inaudible. Each layer is
rendered on its own so its effects reach nothing else, and whatever those effects add past the
end is folded back over the start.
Notes are synthesised in mono and panned as they are placed, so two notes differing only in gain or pan share one render.
A score runs at one tempo at a time. Parts at genuinely different rates at the same moment
are not supported: from_parts/2 refuses parts written at different tempos rather than
playing them at the first one's, which would put every other part's notes at the wrong
moment. Give each part the same :bpm, or build a score for each.
repeat/4 refuses a step of zero or less, which would never reach the end of the score.
TuningFork.Session
A live-coding session is a list of rows. Some are switched off, some carry on the row above them, and together they make one pattern. That much is the same whether the rows are lines in a terminal, cells in a notebook or fields on a page, so it lives here rather than in any of them.
A row is any map with a :source. checked/1 also writes an :error. Anything else on the
map is left alone, so a front end can keep its own things there: a cursor, a colour, a widget
id.
A row is off when it is empty or begins with one of --, // or _. An off row is not
played, not drawn and not checked, so a half-written line parked behind a _ never reports
anything. The markers are checked longest first so the longest match wins.
A row beginning |> carries on the one above it, so a chain too long for one line is written
the way it reads:
1 n("<0 4 0 9 7>*16")
2 |> scale("g:minor")
3 |> acid(0.55)Those are one row as far as playing and error reporting go. joined/1 reports
{first, last, source}: first is the line to hang an error on, last the line to draw a
picture under. Off rows are left out with their line numbers, so the indexes reported are
always into the list as given. A continuation with nothing above it is dropped rather than
run on its own.
combined/1 leaves out a row that will not parse rather than failing the lot, so one bad line
does not silence the rest and a session keeps playing while something is half typed.
Rows that read as Strudel — TuningFork.Strudel.strudel?/1 on all of them joined, which
looks for method chains and $: labels and no |> — are given whole to
TuningFork.Strudel.chains/1 instead of being folded row by row, so a piece pasted from
strudel.cc plays without being rewritten. The chains it gives carry the line span of the
statement they came from, so an error still hangs on the line and a picture still draws
under the last one. A piece that will not translate becomes one chain on the line of the
fault whose source is the whole text; fault/1 recognises that as Strudel and reports the
translator's message rather than the Elixir parser's. tempo/1 reads setcps from a
Strudel piece and nil from anything else, so a front end can take the tempo the piece
asks for and otherwise keep its own.
asks?/1 reads what a row wants drawn from the parsed pattern rather than by looking for the
word in the text, so a row mentioning scope in a sound name is not mistaken for one asking
for it. Nothing is drawn for a row that does not ask, which keeps a session with eight rows
from sending eight pictures a frame.
TuningFork.Session.View
Everything here answers a question about a row that a terminal, a notebook and a web page all ask in the same words: where is the playhead, which token is sounding, what does this row's waveform look like, where do its notes sit. None of it knows what a pixel is. Turning any of it into blocks, an image or an SVG belongs to the front end.
A row that is off or will not parse gives nothing back rather than raising, so a half-typed line draws blank instead of stopping the drawing.
The scope is rendered from the row's own pattern rather than read off the speakers, so it
shows the row it is written under and not everything playing. Its window starts at the row's
last note, not at the playhead, and is scaled by fade/2, so it snaps to full height as a
note lands and dies away until the next one. The beat is visible without the picture having
to scroll, and a row between notes settles on one picture instead of being redrawn.
fade/2 never falls quite to nothing, so a quiet row stays faintly visible rather than
blinking out. It is quantised to a fixed number of steps, so a trace that is not changing is
identical rather than merely close, which lets a front end skip redrawing it. struck/2 gives
the playhead itself when nothing has started yet, so an empty row is measured from the
playhead rather than from nowhere.
The notation a code row plays is inside its first quoted string, so sounding/2 measures the
highlight there and then moves it out to where that string sits in the line.
widths/2 is how wide a pianoroll draws each note, as a bar rather than a dot.
TuningFork.Sink
A sink is opened once, written to in chunks, and closed. TuningFork.Sink.Silent,
TuningFork.Sink.Collect and TuningFork.Sink.Buffer ship with the library;
TuningFork.Sink.Speaker comes from the separate tuning_fork_speaker package. Any module
implementing the behaviour is a sink.
write/2 must not block the caller for longer than the audio it is given represents, or
playback falls behind. TuningFork.Stage passes :rate and :channels to open/1, so a sink
is always told what shape the samples it is handed are.
A write/2 that returns {:error, reason} ends playback: the stage logs the reason and stops
feeding that sink, rather than looping on a destination that is no longer taking samples. A
sink reports a failure rather than returning :ok, or a device that has gone away is
indistinguishable from one playing correctly.
TuningFork.Sink.Silent is the default sink and the one a stage falls back to when its
configured sink will not open. TuningFork.Sink.Collect's write/2 sleeps for as long as
the audio it is given lasts, so it paces a stage the way a real device does.
TuningFork.Sink.Buffer
The buffer records a stage rather than playing it. Writing does not sleep: chunks are taken as fast as the mixer makes them, so a piece records in as long as it takes to render rather than in as long as it lasts.
The buffer is a process the caller starts and owns; the sink writes into it but does not own
it, so the recording outlives the stage that filled it. The rate and channel count the stage
opened with are remembered in the buffer for wav/1 and duration/1.
TuningFork.Source
TuningFork.Pattern.Source and TuningFork.Part.Source read different languages, a row of
pattern notation and a block of loop code, and report problems the same way through this
module. run/1 evaluates without letting diagnostics reach standard error, since
Code.eval_string/3 writes to standard error before it raises, which a rescue cannot take
back. one_line/1 turns an exception message into the one sentence worth putting under the
line it belongs to.
TuningFork.Stage
Mixing and writing
play/2 is a cast and returns immediately; the voice is rendered inside the stage and mixed a
chunk at a time until it runs out. A separate linked process does the writing and pulls each
chunk from the stage when the sink is ready for one, so the sink's pace is what drives the
mixer. Stopping a stage stops its writer with it, and does not disturb the process that called
start_link/1.
A stage whose sink will not open logs and falls back to TuningFork.Sink.Silent rather than
failing to start. A silent stage still keeps time: its writer pulls a chunk every :chunk
frames' worth of wall clock and throws it away, so beat/1 advances and a score plays through
to its end on a machine with no audio device.
The default :chunk of 512 frames is about 12 ms at 44100. The default :scope window of
4096 frames is about 93 ms at 44100; one chunk is too short for a scope to settle on.
:limit is where peaks start being rounded off rather than flattened. nil turns it off and
lets a loud mix clip square.
Held notes
hold/3 sounds a voice under a key and release/3 lets it go: the voice is started with its
envelope's hold stretched to an hour, kept in the sounding list tagged with the key, and on
release replaced by the same voice fading from the level it has reached, as
TuningFork.Voice.Live.release/2 does for the voice cap. A voice whose envelope decays to
nothing ends on its own under the key. Holding a key already held releases the first, so a
keyboard's repeated note does not stack. This is what TuningFork.Midi.In plays a keyboard
through.
Three ways to put music through it
start_pattern/3plays aTuningFork.Pattern, swapped at the cycle line.start_loop/4plays named loops, each on its own length.start_score/3plays one piece, through once or looped.
They mix, so a pattern can run under a set of loops. A stage plays one pattern and one score
at a time. update_pattern/3 changes the pattern without going back to cycle zero, which
starting it again would do.
The bed
The bed is read by sample position and wraps, so it never drifts against the events mixed over it and the loop point is where the buffer ends. A bed shorter than one chunk is wrapped as many times as it takes to fill the chunk. Bed gain is applied as the bed is mixed, so it takes effect on the next chunk without the bed being rendered again.
Scores
The transport started by start_score/3 is advanced by the same chunk pull that drives the
sink, so its position is exactly the audio that has been written.
A finished piece loops better through TuningFork.Score.render/3 into bed/2, which closes
the loop cleanly and folds effect tails back to the beginning; a transport does neither.
Loops
Where start_score/3 plays one piece, start_loop/4 plays as many as you like at once, each
with its own length and its own place in it, which is what lets a four-bar bass run under a
three-bar melody without either being stretched to fit. A loop is a TuningFork.Score, which
is what TuningFork.Part writes.
update_loop/4 swaps a loop's score and by default holds it until the loop comes round, so an
edit lands on the downbeat rather than halfway through the bar. Starting a loop under a name
already running replaces it from the next chunk instead.
A loop given a :body is worked out again for every round. TuningFork.Tick counters step,
TuningFork.State values are read as they stand, and a loop can play something different
every time round rather than the same score for ever:
{:ok, body} = TuningFork.Part.Source.compile(source)
{:ok, first} = body.()
Stage.start_loop(stage, :bass, first, body: body)The body for a round is worked out during the round before it, in a process of its own, so the audio is never waiting on it. A body that raises, or that has not finished in time, leaves the loop playing what it played last; the sound carries on either way. A round's score is only accepted if the loop is still on the round it was worked out for.
after_round/3 is how one loop waits for another: a loop that wants to come in on another
one's downbeat waits there. Everyone waiting on a loop that has just come round is let go at
once, which is what makes several loops able to start together on one downbeat. A name that is
not running is refused at once rather than waited on forever, so a mistyped name is noticed.
Patterns
pattern_cpm/2 is the same knob as pattern_cps/2 in cycles per minute, where 120 is two
cycles a second. hush/1 silences what the pattern has already triggered while its transport
carries on, so the next events land in time. The reverb rooms for a pattern live in the
player, one per orbit, because that is where the notes know which bus they went to.
Meter and scope
level/1 is the peak of what actually went to the sink, after effects and limiting, so it is
what a meter should show.
scope/2 is what an oscilloscope draws, taken after effects and limiting from the left channel
only, so the shape is the one that reached the sink. The window is the last :scope frames,
not the last chunk. A chunk is a few milliseconds, less than one cycle of a bass note, so a
scope drawn from one chunk shows a different fragment every frame and never settles. The
window is also aligned to a rising zero crossing, which is what holds a repeating waveform
still on screen instead of letting it slide.
TuningFork.State
set/2 writes at the time of the round doing the writing. get/2 reads the newest value
written at or before the time of the round doing the reading, so a loop that has run further
ahead than another cannot hand it a value from the future. Two loops on the same round get the
same answer whichever order they were worked out in, and a piece plays the same twice.
A TuningFork.Stage runs a loop's body for the round it is about to play and tells the store
which frame that round begins on. Outside a loop the time is 0, so set/2 then get/2 in
IEx behaves as anybody would expect.
get/2 never blocks; it answers with what it has. To hold a loop until another has been
round, use TuningFork.Stage.after_round/3.
TuningFork.Store
Two ETS tables owned by one process, started with the application. Reads and writes go straight to ETS rather than through the owner, so a loop body reading state never waits on a process.
A value is stored with the logical time it was set at, as a frame count on a stage's clock.
The table is an :ordered_set keyed by {key, time}, so reading the value as of time t is
a walk backwards from {key, t + 1}: one :ets.prev/2, whatever the history's size.
A loop body is run for the round it is about to play, not the one playing now, so the time it
reads and writes is that round's own start. as/3 sets it; at/0 reads it back. Outside a
loop the time is 0.
Everything a tick or a value does is a function of the store, so a piece plays the same from a
fresh one. clear/0 empties both tables.
TuningFork.Voice
A voice is a waveform at a pitch, shaped by an envelope, optionally swept in pitch and
filtered. It is always synthesised in mono, whatever the output is: render/2 gives that mono
buffer, which is the same for a note wherever it ends up sitting, and render/3 places it for
a given channel count.
Filters
:filter is a TuningFork.Filter, a resonant lowpass cut off at a frequency in hertz and
optionally swept by its own envelope. It is the one that shapes a tone. :cutoff is a one-pole
lowpass of 6 dB an octave with no resonance, which barely dents a sawtooth: a smoother rather
than a filter. Use :filter to shape a sound and :cutoff to take the edge off one.
:highpass set together with :cutoff gives a band.
Modulation
:curves holds TuningFork.Curves, each a multiplier over the field it names, read across the
note from start to end:
Voice.new(freq: 440.0, curves: %{freq: Curve.linear(1.0, 1.5)}):sweep is the same thing stated shortly. A :freq curve supersedes it where both are given,
including a flat one. modulation/1 resolves the curves actually moving on a voice;
TuningFork.Voice.Live uses it so that a voice rendered a block at a time reads the same
curves as one rendered whole.
Frequency modulation and vibrato both bend the carrier's phase rather than its frequency, which
is the same thing said in a way that needs no state beyond the modulator's own phase. :fm
is superdough's index: the modulator, at :fmh times the carrier, swings the carrier's
frequency by fm × fmh × freq hertz, which bends its phase by fm radians — fm / 2π of a
cycle — so fm(4) is the same sound here as there. The bent phase is wrapped before the
waveform reads it, so a square or triangle never sees a phase outside its cycle.
Saturation
A resonant filter has enormous gain at its cutoff, roughly the square of :q for four poles,
so a voice with any real resonance on it goes over full scale. Cut off there it becomes a
square wave, which is the harsh digital sound a filter sweep is not supposed to make. An analog
filter saturates instead, and that saturation is most of why a resonant sweep sounds the way
it does. saturate/1 bends a sample towards full scale rather than letting it run past and be
cut off; below 0.7 nothing is changed, so an ordinary voice is untouched.
Waveshaping
:waveshape is Strudel's shape: (1 + k) x / (1 + k |x|) with k = 2s / (1 - s), from
its shape worklet, so shape(.3) in a Strudel piece bends the same way here. It sits after
:distort and before :crush. Pattern.Control.shape/2 sends a number there and an atom
to the waveform, since Strudel's shape and this library's shape had the same name for
different things.
Vowels
A vowel is not a pitch but a pattern of resonances: the mouth has peaks at three frequencies,
and which three decides which vowel is heard however high or low the voice. Passing a sound
through bandpass filters at those frequencies makes it speak. TuningFork.Filter normalises a
resonant bandpass by its own q, which is right for a filter asked for on its own and wrong for
a formant, where the peak is the point, so the formant sum is multiplied back up.
Phaser
The phaser is four one-pole allpasses in series, their corner swept by an LFO. An allpass leaves the loudness alone and only moves the phase, so it is inaudible until it is added back to the dry signal; where the two disagree they cancel, and the notches that makes travel with the sweep. That travelling is the whole sound.
Crush, coarse and distort
crush/2 coarsens the level: 4 bits is unmistakable and 1 is a square wave. Fractional values
work, since the step size is what matters rather than the bit count being whole. coarsened/3
coarsens the time instead, holding each sample for several; the aliasing that comes of it is
the point. distort/2 drives through a soft clipper and takes the level back afterwards, so
turning it up dirties the sound rather than simply making it louder.
TuningFork.Voice.Live
TuningFork.Voice.render/2 synthesises a whole note at once. A live voice is the same
arithmetic stopped at the end of a block instead. advance/2 hands back that block and a voice
that remembers where it got to, so the next block carries on from the same phase, filter state
and noise seed, and the voice can be changed in between.
The note's length is fixed at start/2 from the voice's envelope, and curves are read across
that length. Changing the envelope with control/2 afterwards does not change the length;
release/2 does. control/2 resolves curves again, so new ones take effect and are still
measured across the note's whole length, while phase, filter state and noise seed are
untouched. The pan is read per block, so a voice whose :pan is changed while it sounds moves.
release/2 replaces the envelope by a release from whatever level the voice had reached, and
restarts the voice at frame zero with a length of the release time. Every curve is frozen at
the value it had reached and :sweep is set to 1.0, so pitch and filter stay where they were
heard while the level falls.
A live voice cannot be cached, since it is a different thing on every play.
TuningFork.Transport plays a voice with no modulation from a rendered buffer instead, and
uses a live voice only where something is moving.
TuningFork.Wav
Sixteen-bit little-endian out, in either mono or stereo. encode/2 writes a WAV that
Kino.Audio, an <audio> element or a Phoenix response can take directly:
score
|> TuningFork.Score.render(44_100)
|> TuningFork.Wav.encode(rate: 44_100)
|> then(&Kino.Audio.new(&1, :wav))The rate and channel count must be given to encode/2 because nothing in a PCM buffer records
them.
decode/1 reads audio TuningFork did not produce, for playing through
TuningFork.Stage.play_pcm/2 or loading as a TuningFork.Sample. Chunks other than fmt
and data are skipped, so a file carrying a LIST of who made it reads fine. A file that ends
inside a chunk is :truncated when the missing part might be fmt or data itself. Once
both of those have been read the audio is whole, so a stray chunk cut short after them is
ignored rather than losing a file that plays.
What comes back is always 16-bit: 8-bit unsigned, 24- and 32-bit integer and 32-bit float
samples are converted on the way in, floats clipped to -1.0..1.0, and an extensible header
(0xFFFE) is read for which of those its sub-format names. Sample packs published for
Strudel are mostly 24-bit, so this is what lets samples('github:…') play. A depth other
than those is refused by name.
TuningFork.Wave
Noise ignores phase and takes a seed instead, returning the next value with it. It is a linear congruential generator, so the same seed gives the same sequence on any node and in any process.
sample/3 is what a voice uses; sample/2 is the raw shape, and :saw and :square alias
at high frequencies without the correction sample/3 applies. :saw and :square jump
instantaneously once a cycle, and sampling that jump directly folds every harmonic above
Nyquist back down as noise that is not related to the note. sample/3 rounds the jump over
the sample either side of it, which removes most of that folded-back energy. :sine has no
jump to round, and :triangle bends rather than jumps, so both ignore the step.
Mix.Tasks.TuningFork.Render
The file is read with TuningFork.Midi, given instruments by TuningFork.Gm and rendered by
TuningFork.Score, which closes a loop cleanly and folds an effect's tail back over the join.
No audio device is opened. --gain is worth lowering for dense music; --no-guess treats only
channel 10 as percussion, as General MIDI specifies, where otherwise the drum channels are
guessed.
TuningFork.Kit
Levels follow superdough's: a recording plays at 1.0, a synthesised note and a soundfont note
at 0.3, so a Strudel piece balances here as it does there. A sound named after a waveform —
sine, sawtooth/saw, square, triangle/tri, white/pink/brown — is that raw
oscillator with no filter, as Strudel's synths are; TuningFork.Strudel puts s("triangle")
on any row that names no sound, since that is Strudel's default. A note with no sound in
this library's own rows keeps the filtered saw.
voice/2 answers to drum names (bd sn hh oh cp rim lt mt ht rd cr and
their aliases), a drum name with an index (bd:2 — the index detunes it), a note name (c4
fs3 eb5), a whole number as a MIDI note, a float as hertz, a TuningFork.Voice as
itself, a TuningFork.Sample.Bank name as a voice carrying the recording, and a map. Anything
else is nil, which a player takes as silence. A synthesised drum wins over a bank sample of
the same name.
A map carries a sound and what to change about it: :sound, :note or :degree say what to
play; :gain, :pan, :shape, :release, :cutoff (hertz), :resonance (0 flat, 9
squelches, 62 as far as it goes), :lpenv, :lpattack, :lpdecay, :lpsustain, :highpass
and the rest of TuningFork.Voice's fields say how. :cutoff is hertz because the filter is
TuningFork.Filter, a saturating four-pole ladder; a :lpenv of 3 with a short :lpdecay is
the acid sound. Only a lowpass can be the ladder; a highpass or bandpass is the clean filter.
The amp envelope's :hold is left as the voice set it, so shortening a note's attack does not
change how long it sounds. Frequency modulation puts its sidebands well above the note, and the
filter a pitched voice comes with sits just above it, so a voice asked for FM and not for a
cutoff has its filter opened up to 12 kHz.
Banks are sets of adjustments to the synthesised drums — how much longer they ring, how much
the low ones are detuned, how bright the rest are — under the names Strudel uses
(RolandTR808, RolandTR909 and so on). A name the kit does not know changes nothing.
TuningFork.Flac
A pure-Elixir FLAC decoder. Every subframe type (constant, verbatim, fixed and LPC of any
order), both Rice partition widths, wasted bits, fixed and variable block sizes, and all four
stereo layouts (independent, left/side, right/side, mid/side) are read. Output is always
signed 16-bit little-endian interleaved PCM: 8-bit is scaled up, 24-bit down. CRCs are not
checked, so a corrupted stream comes back as noise rather than an error; a truncated stream is
{:error, :truncated}, and more than two channels is {:error, :unsupported}.
Rice decoding reads unary runs a byte at a time through a leading-zero table, and prediction
walks a reversed sample list so the last order samples are always at its head. A 30-second
24-bit stereo file decodes in under half a second; a drum hit in a millisecond.
TuningFork.Sample
A sample is mono 16-bit PCM with the rate it was recorded at. On a voice it becomes an ordinary voice: envelope, filters, pan, modulation curves and every effect apply as they do to an oscillator, and it sits in a part alongside synthesised notes.
Pitch is speed: the recording is resampled, so a note an octave up is half as long. :root
says what pitch the recording is, and a voice asking for another is read faster or slower by
the ratio; without a root the recording plays at its own speed whatever pitch is asked for.
There is no time-stretching. Reading between frames is a four-point cubic Hermite curve,
which keeps the top octave of a cymbal that linear interpolation dulls; sustained material
pitched far up still aliases.
A voice sounds for as long as its envelope, never for as long as its recording. A voice given a sample and no envelope gets one cut to fit; a shorter envelope gates the recording, a longer one leaves room for a varispeed sweep to finish.
load!/2 reads WAV or FLAC, told apart by the file's header rather than its name.
TuningFork.Sample.Bank
One public ETS table owned by tuning_fork's supervisor, mapping names to a list of files
with load options and the samples loaded from them so far, keyed by index. A file is read on
the first fetch/2 of its index and the sample kept, so a bank of hundreds of files costs
nothing until a name is played. A name may hold several files — Strudel's sd:3 picks the
fourth — and an index past the end wraps, so bell:3 on a single recording is bell. A
file may be an http(s):// URL, fetched through TuningFork.Sample.Fetch when first
played. Names are strings or atoms and are the same either way. A path that will not load
answers :error and is not retried as a success. tuning_fork_samples fills the bank with
Sonic Pi's recordings when its application starts.
A name registered from a map of note names to files is a pitched instrument, Strudel's
piano.json shape: the files are kept in note order with the MIDI note each was recorded
at, and nearest/3 picks the file recorded closest to the note asked for, n choosing among
files that share a note. TuningFork.Kit plays it with that file's note as the root, so the
nearest recording is repitched the least. A plain recording given a note is repitched from
C2 (MIDI 36), which is where Strudel puts an unpitched sample.
n on a recording or a kit drum picks which file, as Strudel's n does for samples —
n("0 1").s("hh") is hh:0 hh:1 — unless a scale or a note is on the event, in which
case it is a degree as it is for a synth.
The bank comes before the synthesised drum kit: a bd registered from a sample set is what
s("bd") plays, and the kit's own bd only sounds while no recording of that name is
registered. prefetch/1 starts fetching every file of a name in the background; the
TuningFork.Pattern.Player calls it, through TuningFork.Kit.prefetch/1, for every sound
in the first eight cycles of a pattern it is given, and for the soundfonts behind gm_*
names, so that by the time a note is due its recording is on disk.
TuningFork.Sample.Fetch
Downloads a URL once into $XDG_CACHE_HOME/tuning_fork/samples (or
~/.cache/tuning_fork/samples), named by a hash of the URL with the URL's extension kept,
and answers the local path; a second fetch of the same URL is a file check. It uses
:httpc from :inets, following redirects, so there is no HTTP dependency to add.
Downloads run as tasks under TuningFork.Sample.Fetch.Supervisor, each registered by URL in
TuningFork.Sample.Fetch.Registry, so a URL is never fetched twice at once: fetch/1 joins
a download already under way and waits for it, and prefetch/1 walks a list in the
background one file at a time (a list already being walked is skipped), returning at once.
A download that fails leaves nothing in the cache, so the next fetch/1 tries again and
reports the error.
TuningFork.Sample.Set
A strudel.json is a map of names to a file or a list of files, with an optional _base
every file is relative to. load/1 reads one from a github:user/repo[/branch] reference
(the raw file on that branch), any URL, a local path, or a map already decoded, and
registers every name in the bank with its file URLs, so the files themselves are only
fetched as they are played. It is what Strudel's samples('github:eddyflux/crate') becomes.
The JSON itself goes through TuningFork.Sample.Fetch, so it is downloaded once, and
registering a name again with the same files keeps its loaded samples; both matter because
a front end translates a Strudel buffer — and so reaches samples(...) — every time it
checks or draws it.
TuningFork.Sample.Font
Strudel's gm_* instruments are soundfonts served as webaudiofont files: one JavaScript
file per instrument holding a list of zones, each a key range, a root pitch in cents, loop
points and an MP3 in base64. parse/1 reads the zones out of the file with a small
regular-expression scanner rather than a JavaScript parser, since the files are all of one
shape. load/1 fetches the file through TuningFork.Sample.Fetch, writes each zone's MP3
into the cache and decodes it through TuningFork.Sample.Decode, keeping the result in an
ETS table as a TuningFork.Sample with root from the zone's pitch and tuning and loop
from its loop points, scaled if the decoder's rate differs. A note picks the zone whose key
range covers it, as Strudel's findZone does. sample/3 with wait: false starts the load
in the background and answers :loading, so a stage never waits on a font; prefetch/1
does the same without asking for a note. Fonts that will not decode load with the zones
that did, and a note no zone covers is :error, which TuningFork.Kit turns into a
synthesised voice.
TuningFork.Gm.Fonts is the list of font files behind each name, in Strudel's order, so
gm_epiano1:1 is the second file as it is there. The voice a font plays through has
Strudel's soundfont envelope — an instant attack, sustain for the note, a 10 ms release —
at 0.3 of a recording's gain, which is the level Strudel gives them.
TuningFork.Sample.Decode
MP3, OGG and AAC are not decoded here; to_wav/1 hands them to afconvert on macOS or
ffmpeg where it is installed, writing a 16-bit WAV into the cache directory once, named
by a hash of the source path. A WAV or FLAC is returned as it is. With neither converter
the answer is {:error, :no_decoder}, and TuningFork.Sample.load!/2 raises with it, so a
font or a piano note on such a machine is skipped rather than played as noise.
TuningFork.Gm.Names
Strudel's gm_* instrument names and the General MIDI program each stands for, generated
from Strudel's soundfont list, so s("gm_epiano1") plays program 4 on a TuningFork.Gm
voice. A name with an index, gm_epiano1:1, is the same program; the index chooses a
soundfont in Strudel and has nothing to choose here.
TuningFork.Fx
Each whole-buffer effect gives back PCM longer than it started, because an echo or a reverb
goes on after the last note; TuningFork.Score wraps that tail back to the start of a loop.
:mix is how much of the result is the effect. Every effect takes :channels, default 2,
because a delay is measured in frames while the buffer is walked a sample at a time; both
channels are delayed by the same whole number of frames, so nothing here widens a stereo
image. Effects TuningFork.Fx.Live has and this module does not — filters, slicers, the
compressor and the rest — are run through a fresh streaming chain over the whole buffer.
TuningFork.Fx.Live
The same echo, reverb and drive as TuningFork.Fx, held as state so a block can be handed in
at a time and the tail carries into whatever is played next; the arithmetic and delay lengths
match, so a piece sounds the same either way. TuningFork.Stage puts one over everything it
mixes and TuningFork.Transport runs one per effected layer.
The streaming-only effects: :level; :lowpass, :highpass and :bandpass as a
topology-preserving state-variable filter with one state per channel (a :q of 0.7 is flat);
:slicer and :tremolo as an amplitude driven by a low-frequency oscillator; :wobble as a
lowpass whose cutoff sweeps between two frequencies on the same oscillator; :crush as bit
reduction plus sample-and-hold, with :sample_rate turned into a hold count from the render
rate; :compressor as an envelope follower with attack and release coefficients and a gain
computed from where the envelope sits against the threshold; :pan and :panslicer as
equal-power channel gains, static or swept, which leave a mono stream alone; :flanger as a
short delay line read at a position that the oscillator moves, with linear interpolation, kept
as a map so a write per sample stays cheap.
The oscillator is one of :saw (falling), :square (on for :pulse_width of the phase),
:triangle and :sine (a cosine, so it starts at its peak), and every effect that has one
counts frames so its position carries across blocks.
TuningFork.Transport
A transport owns no clock: the frames it is asked for are the clock, so its position is
exactly the audio produced. A note lands on its own sample inside a block rather than at the
block boundary. The score can be swapped while it plays and the position is kept. What it
cannot do, and TuningFork.Score.render/3 can, is close a loop without a seam, fold a tail
back to the start, or cache a repeated note. With :loop the position goes back to the start
at the end; notes already sounding finish rather than being cut.
A score's per-layer effects are applied live: every sounding voice carries its layer's effect
list, blocks are grouped by that list, each distinct list has a TuningFork.Fx.Live chain of
its own, and the chain keeps being fed silence after the layer's last note so an echo or a
reverb rings out. A chain silent for eight seconds is dropped.
TuningFork.Part.Source
What TuningFork.Pattern.Source is for a row of pattern notation, this is for a block of
loop code: the one place that turns what somebody typed into something a stage will play, and
says what is wrong when it will not. The source runs under use TuningFork.SonicPi, so a loop
reads either as a TuningFork.Part pipeline or in Sonic Pi's words; a bare part is wrapped in
a score of its own length, a score is taken as it is, and source that plays with play,
sample and sleep gives the round those made. Errors come back as one line and nothing is
written to standard error, so a terminal front end is not scrolled over by a half-typed loop.
The source is Elixir and can do whatever Elixir can — the same bargain Sonic Pi and TidalCycles
make — so a loop from elsewhere should be read before it is played.
TuningFork.Tick
A loop's source runs again every round, so a tick in it gives 0 the first time round, 1
the second, and so on; with TuningFork.Ring that is how a line walks through a set of notes.
tick/1 steps a counter and gives the value it stepped from; look/1 gives the count without
stepping. tick/0 uses the loop being run — inside a stage loop, the loop's own name — so each
loop counts on its own; tick/1 names a counter directly, for two counters in one loop or one
shared between loops. Outside a loop the counter is :default. Counters live in
TuningFork.Store, so a piece is a function of how many times round it has been and plays the
same from a fresh store.
TuningFork.SonicPi
Sonic Pi's model is a thread with a clock: play puts a note at the thread's time and
sleep moves the time on. Here that thread is a TuningFork.SonicPi.Thread kept in the
process dictionary, and every word of the vocabulary reads or writes it — which is what lets
rrand, choose, tick and sleep be bare expressions rather than functions threading a
part by hand. Time inside a thread is seconds; use_bpm only scales sleep, and the score
that comes out runs at 60 beats a minute.
A live_loop body is run once per round in a fresh thread: Blocks.run_round/2 seeds the
thread's generator from the loop's name and the round's frame, so every round draws
differently and the same piece plays the same from a fresh TuningFork.Store;
use_random_seed resets the stream, which is why Sonic Pi code that seeds at the top of a
loop repeats each round exactly. A loop inherits the tempo, synth, defaults, transposition and
open effects of the thread that started it, so a live_loop inside with_fx :reverb sounds
through the reverb. live_loop cues its own name at the start of every round unless
auto_cue: false, and sync: waits for another loop's cue before the first round; delay:
makes the first round that many beats of silence.
in_thread runs its block now without moving the thread's time or its generator. with_fx
opens a segment: notes played inside carry the segment's effects, and control on the effect
starts a new segment for what follows, so a changed mix becomes a second layer in the score.
Nested effects are applied inner first. control on a note records the change against the
note and, when the round is scored, becomes a TuningFork.Curve on the voice — a step, or a
slide over note_slide/amp_slide/cutoff_slide seconds — with cutoff moving the voice's
TuningFork.Filter through its own curve in seconds.
cue writes to TuningFork.State and sync reads it: a round that syncs on a name not yet
cued is given up as waiting and the loop tries again next round, so sync gates the start of
a round rather than pausing inside one. A body that never sleeps is refused, since a loop of no
length cannot come round; a thread that runs past the loop's sleeps is cut at the round's
end. stop ends the round where it is.
run/1 evaluates a buffer of source: top-level code becomes a score played once, running to
the end of its last note, and every live_loop is collected with the settings it inherited.
render/4 plays a buffer offline through TuningFork.Transport.render_rounds/4;
play_buffer/2 starts it on a stage.
Names that Sonic Pi has and this does not: define (write an Elixir fn), at scheduling
beyond at times, args, fn, sync waiting mid-round, per-note pan_slide, pitch_shift,
octaver, ring_mod, vowel, autotuner and chorus effects, and the mod_* synths'
square-wave pitch modulation, which is played as vibrato. Unknown options on play, sample
and with_fx are ignored, as are use_debug, puts and print.
Sonic Pi words that would collide with TuningFork.Part's — play, chord, pick, synth,
at — take a %TuningFork.Part{} as their first argument and hand it to the part's function,
so a pipeline and a Sonic Pi block can sit in the same buffer.
TuningFork.Strudel
A translator from the JavaScript written at strudel.cc to this library's chains, so a piece
is pasted rather than rewritten. It is a tokenizer, a recursive-descent parser for the
subset of JavaScript a Strudel piece uses — numbers, strings in any quote, identifiers,
calls, method chains that may continue on the next line with a dot, arrays, arrow
functions with zero to two parameters, + - * / % and unary minus, let/const
assignments, $: labels, // and /* */ comments, semicolons — and an emitter from that
tree to Elixir source. Object literals and anything else are refused with the line.
The sounds a piece expects
strudel.cc registers a set of sample banks before any piece runs: its drum kit
(uzu-drumkit, where bd, sd, hh, cp and the rest come from), the drum machines
and the short names for them (tr808_bd for RolandTR808_bd, through aliases/1), the
Salamander piano, the VCSL orchestral set, the mridangam, and a handful of Dirt-Samples.
defaults/0 registers the same sets from the same CDN, in the background the first time a
piece is read, without fetching a file; files come as they are played and
TuningFork.Pattern.Player prefetches the ones a pattern will need. The application
environment :strudel_defaults set to false turns it off, which the test suites do.
What becomes a row
A $:-labelled statement is a row and _$: a silent one; a piece with no labels plays its
last expression. A stack, cat, seq or other list word at the top of a row is split
into one chain per voice, and the methods called on the stack are appended to every
voice's chain, so stack(a, b).room(.3) is a |> room(0.3) and b |> room(0.3); nested
stacks flatten the same way. let variables are substituted into every chain that uses
them, which is what lets one chords pattern feed three voices. setcps and setcpm
are reported in the meta rather than emitted, samples(...) is loaded through
TuningFork.Sample.Set on the way through, and hush() and await are skipped.
How a call is spelt
A method chain is a pipeline: s("bd").gain(.5) is s("bd") |> gain(0.5). A string with a
method called on it is mini("…") first. A bare transformer in argument position —
rarely(ply("2")), chunk(4, fast(2)) — is a function of the pattern, &(&1 |> ply("2")),
and a bare word there — every(4, rev) — is a function capture, &rev/1. every and
when_cycle take the pattern last, so they are emitted as then(&every(4, f, &1)). A
signal is a call, sine(). An arrow function is fn x -> … end. Strudel's spellings map
onto this library's through one alias table (sound→s, sz→size, legato→clip,
hurry→fast, cat→slowcat, seq→fastcat, camelCase→snake_case), and a word that is
neither a pattern nor a control function here is refused by name and line, so a piece
either plays as written or says which word it cannot.
Where things came from
Each chain carries the first and last line of the statement it came from, counted from
zero, so TuningFork.Session can hang an error on the right row and draw a picture under
the last one. Parse errors carry the line of the token that stopped them.
TuningFork.SonicPi.Names
A note is a MIDI number, a name with or without an octave (:e3, :e, :Fs4, :Eb2,
"c4"), or hertz as a float; a name without an octave is in octave 4, so :c is middle C,
and note(:F, octave: 1) moves it. Chords and scales come back as MIDI numbers, as they do
in Sonic Pi, so arithmetic on them works. Chord names include the short ones (:m7, :M,
:dom7, :dim, :aug, "7") and scale names are Sonic Pi's set, sixty of them.
TuningFork.SonicPi.Synth
Each Sonic Pi synth is a preset over TuningFork.Voice: a shape, a default lowpass as a MIDI
cutoff and a resonance from Sonic Pi's synthinfo.rb, and whatever extras the sound needs —
FM for the bells and the electric pianos, vibrato for the leads, crush for the chip sounds, a
filter envelope for the 303 and the pluck. Detuned synths (dsaw, supersaw, tech_saws,
hoover, prophet, winwood_lead, dark_ambience) are several voices spread by :detune
semitones; subpulse and bass_foundation add a sine an octave down; organ_tonewheel is
three harmonics. The sc808_* names are the kit's drums.
Sonic Pi's envelope is attack, decay, sustain (a time, not a level) and release, all in
seconds, with sustain_level for the held level; the default is a one-second release and
nothing else. amp 1 is normal and maps to a gain of 0.8; cutoff is a MIDI note; res from
0 to 1 becomes a filter q of 1 / (1 - res), held at 62. divisor and depth are the FM
ratio and depth. The mod_* synths' pitch modulation is played as vibrato at 1 / mod_phase
hertz over half mod_range semitones.
TuningFork.SonicPi.Effects
Each Sonic Pi effect becomes one or two TuningFork.Fx effects with Sonic Pi's option names
and defaults: reverb/gverb, echo (decay in seconds to fall 60 dB becomes a feedback of
10^(-3 phase / decay)), slicer, wobble, ixi_techno (a sine wobble), panslicer, pan,
level, tremolo, bitcrusher and krush (drive and a lowpass), distortion/tanh,
compressor, the six filter names, the four bandpass names and flanger. Cutoffs are MIDI
notes and resonance runs 0 to 1; an LFO wave is Sonic Pi's number, 0 saw to 3 sine. An
amp: on any effect appends a level. reps: on with_fx runs the block that many times.