Getting Started
Legato is an open source, graph-based Rust framework for making audio and MIDI applications such as VST plugins, synthesizers, samplers, sequencers, and more.
Legato has the following goals:
- Opinionated workflows that make audio programming accessible without alienating experienced audio programmers
- A minimal DSL for graph orchestration, while more advanced users can write custom nodes, UIs, and extensions with Rust
- A performant, portable runtime that can be used for a variety of applications
- A standard library and ecosystem of nodes, patches, and example applications
If you've worked with tools like Max/MSP, Pure Data, FunDSP, or even VCV Rack and JUCE, a lot of these concepts will look familiar.
Getting Started
To get started, you will want to install a Rust toolchain and create a Legato template:
cargo install cargo-legato
cargo legato init # optionally specify the path
cd legato-example
cargo run # you may need to tweak your audio config, run in release for serious projects
This will make a barebones application with a cross-platform cpal audio device. You can also take a look at some of the examples
if you would rather avoid downloading an additional binary.
Note: Legato has additional examples, including VST, TUI, and NixOS examples.
If you want to squeeze more performance out of your builds, I would suggest installing a nightly 1.98 toolchain and enabling the simd feature. SIMD allows Legato to process audio more efficiently, and this extra performance budget can be used to make larger graphs or lower latency applications.
For nix users, there is a development flake with a nightly toolchain that builds, deploys, and tests with SIMD enabled by default. There is also an example project that uses a Raspberry Pi 4B as a realtime synthesizer with effects.
A Minimal Graph
With the example project created, let's take a peek at our first graph.
We will start with a simple example, where we will spawn a sine node, give it a default value, fan the audio out to two channels, and tell Legato where to pull samples from in the end.
// Here we have a namespace, a named collection of node recipes
audio {
// We instantiate one sine node and pass in an initial frequency
sine { freq: 440.0 },
// Next, we fan this out to two channels.
// Some nodes can specify how many channels they output
mono_fan_out: master { chans: 2 }
}
// We run our sine wave, and then forward this to our stereo-out master
sine >> master
// Here, we've selected our sink. This is where we pull the samples from.
{ master }
This is admittedly a pretty barebones example, but let's go over the concepts first.
Namespaces and Declarations
The audio block is a namespace, a collection of nodes grouped by a name. This is mostly an organizational tool; all nodes, for instance, run at a sample-accurate audio rate, and there is really not much difference between them. Namespaces can be created and extended at application startup, enabling users to create and share custom nodes.
Next, we define the node and pass in various parameters. Nodes can have required and optional attributes and will fail early and loudly if they are missing any required data.
You'll notice that the mono_fan_out node has some additional syntax. We can create an alias for that node to avoid collisions and create more meaningful names in the graph.
Connections and Sinks
After our namespace, we declare where in the graph our nodes will process. This is quite similar to frameworks like Pure Data, except we can handle this all in code and track our application in git as opposed to using a graphical interface.
This is a very simple connection. Legato supports a few more types, including named selection, port selection, port slicing, and port striding.
Finally, we declare where we want the runtime to pull samples from. This is explicitly defined because we could continue processing elsewhere if we wanted to.
Adding a Patch
My favorite Legato feature, and admittedly the most frustrating to implement, is the macro patching feature. We can declare a recipe for a patch, instantiate that same patch multiple times, and even nest patches inside of each other. The best part is that these nodes are inlined into the graph as normal nodes without any additional performance hit.
Here is a more complicated polyphonic synth example. This does require registering a MIDI runtime, which can be seen in the complete demo at crates/examples/poly.rs.
patch voice(
attack = 50.0,
decay = 30.0,
sustain = 0.3,
release = 50.0
) {
in freq gate
audio {
saw { chans: 1 },
adsr { attack: $attack, decay: $decay, sustain: $sustain, release: $release, chans: 1 },
}
freq >> saw
gate >> adsr.gate
saw >> adsr[1]
{ adsr }
}
patches {
voice * 5 { },
}
audio {
svf { chans: 1, cutoff: 5400.0, q: 0.4, type: "lowpass" },
track_mixer: osc_mixer { tracks: 5, chans_per_track: 1, gain: [0.1, 0.1, 0.1, 0.1, 0.1] },
mono_fan_out { chans: 2 },
}
midi {
poly_voice { chan: 0, voices: 5 }
}
poly_voice[0:13:3] >> voice(*).gate
poly_voice[1:13:3] >> voice(*).freq
voice(*) >> osc_mixer[0..5]
osc_mixer >> svf
svf >> mono_fan_out
{ mono_fan_out }
Parameters, Templates and Defaults
When we first declare a patch, we give it a name and define a number of attributes with default values that can be used within the patch.
These variables are then passed into the nodes spawned inside the graph with a $value syntax. This is nice, as we can spawn the same patch with different parameters multiple times.
Virtual Inputs
We quickly see the in keyword, where we can declare a number of virtual inputs. These virtual inputs are then used to wire in connections with other nodes. We can now use our_patch.freq to create connections like this is a normal node.
Spawning a Patch
We then spawn patches in the patches namespace, like typical nodes. In the above example, we actually spawn five of them. Nodes spawned multiple times need to be accessed differently, with a syntax similar to ports, such as voice(*).
Working With Feedback Cycles
Legato is designed around block-based processing. This mirrors what most DAWs do internally. In fact, a good way to think about Legato is that it is a headless DAW.
This design comes from the fact that block-based processing is generally more performant than per-sample processing: it works intuitively with the OS threading model, we have stronger cache locality, and we can more easily take advantage of SIMD processing.
However, there is an issue with this design. Many effects and instruments, such as FM synthesizers with feedback, Karplus-Strong synthesis, reverb, and chorus, require feedback shorter than the audio block size.
To get around this, Legato has four suggested paths, depending on the use case:
- Interpreted
kernelnodes: users can definekernelnodes and gain access to primitives that process one sample at a time. This is nice for rapid iteration and prototyping. - Generated
kernelnodes: rather than interpreting these nodes, we can generate some Rust code and compile our node instead. This is a strong middle ground for most users, although experimental. - Custom Rust nodes: more technical users that want the best performance possible can write nodes in Rust and register them at startup.
- If you require feedback that is longer than the audio block length, you can do so with the
delay_readanddelay_writenodes. This may be enough for traditional tape-style devices.
Now, let's look at an example kernel declaration, a minimal two-operator FM synth.
This can be interpreted at runtime (in the graph), or declared and later registered with the include_node! macro.
kernel fm(
ratio = 2.0,
index = 300.0,
feedback = 80.0
) {
in freq
audio {
mult: mod_freq { val: $ratio },
mult: fb { val: $feedback },
sine: modulator { freq: 0.0 },
mult: depth { val: $index },
add: car_freq { val: 0.0 },
sine: carrier { freq: 0.0 },
}
freq >> mod_freq >> modulator
modulator >> fb >> modulator
freq >> car_freq
modulator >> depth >> car_freq
car_freq >> carrier
{ carrier }
}
patches {
fm: voice { ratio: 3.0, index: 400.0, feedback: 120.0 },
}
audio {
adsr { attack: 5.0, decay: 300.0, sustain: 0.2, release: 400.0, chans: 1 },
mult: level { val: 0.3 },
mono_fan_out { chans: 2 },
}
modular {
random { rate: 180.0, prob: 0.6, range: [36.0, 72.0] },
quantize { notes: [0, 3, 5, 7, 10] },
trig_to_gate { gate_time: 120.0 },
}
random.stepped >> quantize
quantize.trig >> trig_to_gate >> adsr.gate
quantize.freq >> voice.freq
voice >> adsr[1]
adsr >> level >> mono_fan_out
{ mono_fan_out }
A kernel is declared just like a patch, but its body runs one sample at a time.
The difference between this graph and the other examples is the graph cycle.
modulator >> fb >> modulator creates a cycle, which is illegal for traditional legato nodes.
Within kernel nodes, Legato adds a one sample delay where the edge of the cycle exists, so the modulator can receive its own output from one sample ago.
This can then be used like a normal node within the graph, without having to write Rust code (but you should try!).
This is less performant than a custom node, and myself I typically just use this for prototyping.
Legato additionally ships the include_node! macro to make code-gen nodes, and you can then later
graduate to a custom node if you feel comfortable writing Rust.
The macro lives in the legato-macros crate. Projects made with cargo legato init already depend on it; otherwise add it yourself:
[dependencies]
legato-macros = "0.2.0"
// Compiles the `modtap4` kernel in kernels/modtap4.legato into a `Modtap4` node
legato_macros::include_node!("kernels/modtap4.legato", "modtap4");
let (app, frontend) = LegatoBuilder::<Unconfigured>::new(config)
.register_node("audio", Modtap4::spec())
.build_dsl(GRAPH)?;