TuningForkComposer

A step sequencer as data. No process, no screen — a struct and pure functions over it, so the same composer can be drawn by a terminal, a notebook or a web page.

{:tuning_fork, "~> 0.1"},
{:tuning_fork_composer, "~> 0.1"}
alias TuningFork.Composer
project =
Composer.new(bpm: 100)
|> Composer.add_track(kind: :drum, sound: "kick")
|> Composer.toggle_step(0, 0)
|> Composer.toggle_step(0, 4)
Composer.to_source(project) # Elixir source, as a string
Composer.to_score(project) # a TuningFork.Score, ready to render

Modules

ModuleFor
TuningFork.ComposerThe project struct, and every edit
TuningFork.Composer.TrackOne row: its instrument and its steps
TuningFork.Composer.SourceWriting a project out as Elixir
TuningFork.Composer.JsonTo and from string-keyed maps, for the wire

The grid

meter beats to a bar, division steps to a beat. Width is their product.

WantmeterdivisionSteps
the usual4416
a waltz3412
triplets4312
5/45420
thirty-seconds4832

Composer.set(project, :meter, 3) resizes every track for you — grown with rests, or cut short.

Steps

A step is one of three things:

0 # silence
3 # scale degree 3, one step long
{3, 4} # scale degree 3, held over 4 steps

Read them with Track.read/1, which normalises all three to {degree, length}, and build them with Track.write/2. Degree 1 is the first note of Composer.scale(project).

Writing a front end

Hold a project. Turn input into one call. Draw what comes back.

def handle_event({:cell_clicked, track, step}, state) do
%{state | project: Composer.toggle_step(state.project, track, step)}
end
def handle_event({:cell_dragged, track, from, to}, state) do
%{state | project: Composer.set_length(state.project, track, from, to - from + 1)}
end

Every editing function takes a project and returns one. Indices out of range are no-ops, so there is nothing to check before calling.

To draw a row:

track = Composer.track(project, index)
held = Track.held(track) # [false, true, true, false, ...]
for {step, i} <- Enum.with_index(track.steps) do
case Track.read(step) do
{0, _} -> if Enum.at(held, i), do: "held", else: "empty"
{degree, _} -> "note #{degree}"
end
end

For menus: Composer.drums/0, Composer.instruments/0, Composer.scales/0, and Composer.settings/0 for what set/3 accepts.

Playing and writing

# play what is on screen, without evaluating source
project |> Composer.to_score() |> TuningFork.Score.render(44_100)
# write source that runs on its own
File.write!("song.exs", Composer.to_source(project, output: :wav))

Both skip muted and empty tracks.

Front ends that exist

PackageDraws with
tuning_fork_drafterDrafter, in a terminal — mix tuning_fork.compose
tuning_fork_kinoKino, in Livebook — the Compose smart cell

Neither knows about the other, and this package knows about neither.