KinoTuningFork

Livebook smart cells for TuningFork.

Setup

In the notebook's setup cell — the one at the very top, not an ordinary cell:

Mix.install([
{:tuning_fork, "~> 0.1"},
{:tuning_fork_samples, "~> 0.1"},
{:tuning_fork_kino, "~> 0.1"}
])

Working from a checkout instead, give each a path:{:tuning_fork_kino, path: "/absolute/path/to/tuning_fork_kino"} — and re-run the setup cell after editing the code.

notebooks/composer.livemd is the tour: the Compose grid, then TF Patterns, TF Loops and MIDI to audio, then the same from code; notebooks/sonic_pi.livemd has the Sonic Pi website examples, each in a cell; notebooks/strudel.livemd has a Strudel piece pasted into a TF Patterns cell, eddyflux's "coastline" with its sample pack, and the same from code.

A stage in the notebook

use TuningFork.SonicPi
KinoTuningFork.stage()

The stage cell shows a player: press ▶ listen once. From then on any cell is Sonic Pi:

live_loop :bells do
sample :perc_bell, rate: rrand(0.125, 1.5)
sleep rrand(0, 2)
end

Evaluating a loop cell again swaps the loop in when it next comes round; a play or sample at the top of a cell sounds at once; hush() stops everything. The stage streams what it mixes to the page as it plays, so nothing is rendered ahead and there is no file. KinoTuningFork.stage(name: nil) gives a stage a bare live_loop does not see, for use through live_loop :name, stage: pid do … end.

Smart cells

+ Smart in the cell menu, and pick one of:

CellFor
ComposeWriting a piece visually, in a grid, without writing code
MIDI to audioTurning a .mid into sound
TF PatternsLive coding patterns, the Strudel/TidalCycles way
TF LoopsLive coding named loops, the Sonic Pi way

The two live-coding cells each run a stage of their own and stream it to the page, so the Evaluate button inside the cell swaps the music in at the next cycle or round rather than rendering a file. Evaluating the notebook cell itself runs the source the cell wrote — the same loops as live_loop blocks — on the notebook's named stage from KinoTuningFork.stage().

Smart cells are registered when the package's application starts, which happens during Mix.install. If nothing appears in the menu:

Compose

A step sequencer. Tracks down the side, steps across, a click puts a note in a box.

Drum tracks are on or off. Pitched tracks hold a degree of whatever key the piece is in — a click walks up the scale, a right-click walks down — so a wrong note is hard to reach. Drag a note to the right to hold it over the steps that follow.

ControlWhat it does
Tempo, BarsHow fast and how long
Beats/bar, Steps/beatThe shape of the grid. 4 and 4 is sixteen sixteenths; 3 and 8 is a waltz; 4 and 3 is triplets
Key, ScaleWhat the pitched tracks are allowed to play
Kitsynth for the kit's own drums and synthesised instruments, or a drum machine — RolandTR909 and the others in TuningFork.Kit.banks/0 — for its recordings and General MIDI soundfonts, fetched as they are first heard
GainLevel per note. Lower it as tracks pile up
ReverbRoom size, 0 to 1
+ trackAdd a row; each one picks drum or pitched, and a sound

Per track: M mutes it (dimmed, and left out of the generated code — unmuting brings it back untouched), ring is how long its notes sound by default in beats, and a note dragged out overrides that for itself.

The source is the artifact

The grid is a way of writing TuningFork.Part, not a format of its own:

import TuningFork.Part
alias TuningFork.{Gm, Notes, Score, Voice}
base = Voice.new(shape: :saw, gain: 0.5, cutoff: 0.45, ...)
kit = Gm.drums()
bass = Gm.for_program(33, base)
drum_kick =
part(bpm: 96, synth: kit[36], gain: 0.9)
|> repeat(2, fn bar -> steps(bar, "x...x...x...x...") end)
pitched_bass =
part(bpm: 96, synth: bass, gain: 0.5)
|> repeat(2, fn bar ->
steps(bar, [:a2, nil, nil, nil, :a2, nil, nil, nil, :e3, nil, nil, nil, :d3, nil, nil, nil])
end)
song = Score.from_parts([drum_kick, pitched_bass], bpm: 96, beats: 8)

That runs in a Mix project with no Livebook, no Kino, and nothing of this package in sight. Convert the cell back to code and it keeps working — which is the point of composing here rather than in something that exports a .mid.

Notes are written out by name rather than as lookups into a scale, so a line can be read and one note changed without working out what index it was. Change the key in the cell and it rewrites them; change them in the code and the cell is no longer in charge, which is the right way round.

What it cannot do

Sixteen steps, one note per step per track. No chords in a single row — stack two tracks on the same instrument instead — and no note lengths, since every step is a sixteenth. Those are grid limits, not TuningFork.Part limits: take the generated source and it will do whatever part/1 will do.

MIDI to audio

Point it at a .mid, set tempo, gain, drum handling and reverb, and play the result in the browser. Same principle — a form over generated code.

Livebook has no sound card and does not need one. tuning_fork is pure Elixir and renders to a buffer, TuningFork.Wav puts a header on it, and Kino.Audio hands the bytes to the browser, so this works over ssh and in a container.

TF Patterns

Live coding patterns, the Strudel/TidalCycles way. One pattern per row; every switched-on row is folded into one pattern and played on a loop.

s("bd*4")
|> gain(0.8)
|> scope()

A row starting |> carries on the row above it. A row behind --, // or _ is switched off. A row asking for scope() or pianoroll() gets one drawn under it, moving with the sound.

ControlWhat it does
Evaluate, or Ctrl+EnterStart the pattern, or swap it in at the next cycle line
StopStop
cpsCycles per second, taking effect as it is changed
VolumeLevel

An edit lands at the next cycle line rather than sending the pattern back to cycle zero. A row that will not parse is reported underneath it and the rest still play.

Same vocabulary as mix tuning_fork.live, so a buffer moves between the two.

A piece pasted from strudel.cc plays as written — $: rows, method chains, let variables, setcps, samples('github:…') — and the cps field takes the tempo the piece sets. An error is reported under the line it came from. See TuningFork.Strudel for what is read.

setcps(.75)
let chords = chord("<Bbm9 Fm9>/4").dict('ireal')
$: s("bd").struct("<[x*<1 2> [~@3 x]] x>").bank('crate')
$: chords.offset(-1).voicing().s("gm_epiano1:1").room(.5)

TF Loops

Live coding named loops, the Sonic Pi way. Each loop has its own name, source and length, and they run against each other without being stretched to fit — a four-beat loop against a three-beat one keeps that relationship.

part(bpm: 120, synth: Kit.voice("bd", 0.3))
|> steps("x...x...x...x...")

or in Sonic Pi's words:

sample :perc_bell, rate: rrand(0.125, 1.5)
sleep rrand(0.5, 2)

A loop's source is Elixir under use TuningFork.SonicPi: a TuningFork.Part pipeline must come to a part or a TuningFork.Score, and a Sonic Pi block is the round it played. ? lists both vocabularies. Sample names come from tuning_fork_samples, listed above.

ControlWhat it does
Evaluate, or Ctrl+EnterStart every loop, or swap each changed one in when it comes round
StopStop
+ loopAdd a loop, opening on a drum part that plays as it stands
×Remove a loop
?What a loop can be written with
VolumeLevel

Each loop shows the beat and the round it is on, and waiting while an edit is held for its downbeat. A loop taken off the board stops when it next comes round. A loop that will not read is reported under it and the rest still play.

Same vocabulary as mix tuning_fork.loops, so a board moves between the two.

Sound in a notebook

A notebook has no speaker: it runs on a server and is looked at through a browser. The stage widget and the live-coding cells run a TuningFork.Stage on the server with KinoTuningFork.Sink, which streams each chunk to the page a little ahead of real time, and the page schedules them back to back with Web Audio. That works over ssh and in a container, and a browser will only start audio from a click — which is what ▶ listen and Evaluate are.

The source a smart cell writes out is the same live code — live_loop blocks under use TuningFork.SonicPi, or Stage.start_pattern — and plays on the notebook's named stage, so it needs a KinoTuningFork.stage() cell above it.