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 kernel nodes: users can define kernel nodes and gain access to primitives that process one sample at a time. This is nice for rapid iteration and prototyping.
  • Generated kernel nodes: 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_read and delay_write nodes. 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)?;