CAW (part 1)

CAW started out as a rewrite of Llama into Rust. It’s a software-defined modular synthesizer - a library that can be used to create synthesizers by declaratively describing a graph of interconnected modules. I rewrote Llama in Rust because I was frustrated with the state of OCaml development tooling (read more about this here). This post covers the state of CAW as of around the end of 2024 when I rewrote it pretty much from scratch with an eye for performance. I’ll cover the basic concepts in this post, as well as some considerations in switching from OCaml to Rust, and in part 2 I’ll focus on what I’ve changed and added since then.

The CAW logo - a crow playing a keytar

As with Llama, the core concept is signals - streams of values where elements are produced at the sound card’s sample rate (usually about 44kHz). In CAW the main type is Signal<T> representing a stream of some type T. Here’s an example showcasing the main differences between doing this in OCaml and Rust. I’ll show the code for a simple synthesizer implemented with Llama and CAW, then discuss the differences.

open Llama
open Dsl

let () =
  let osc = oscillator (const Saw) (const 100.0) in
  let gate = periodic_gate ~frequency_hz:(const 5.0) ~duty_01:(const 0.05) in
  let env = ar_linear ~gate ~attack_s:(const 0.01) ~release_s:(const 0.15) in
  let osc_filtered =
    osc |> butterworth_low_pass_filter ~cutoff_hz:(env |> scale 20000.0)
  in
  let output = osc_filtered *.. env in
  play_signal output
use caw::prelude::*;

fn main() -> anyhow::Result<()> {
    let osc = oscillator_hz(Waveform::Saw, 100.0).build();
    let gate = periodic_gate_s(0.2).duty_01(0.05).build();
    let env = adsr_linear_01(&gate).attack_s(0.01).release_s(0.15).build();
    let osc_filtered =
        osc.filter(low_pass_butterworth(env.clone() * 20_000.0).build());
    let output = osc_filtered * env;
    SignalPlayer::new()?.play_sample_forever(output)
}

Both of these programs play a 100Hz sawtooth wave modulated by an envelope that controls both the volume and filter cutoff.

A major difference is that in Rust there are no named function parameters. If the inputs to modules were passed as regular function arguments then it will be difficult to tell the meaning of each argument at a glance. To work around this, in CAW modules are created using the “builder pattern”. If there are mandatory arguments whose meaning is obvious without a label then they are passed as regular (anonymous) arguments to the “constructor” (functions like oscillator_hz and low_pass_butterworth) which creates a builder for the respective module type. Then methods can be chained onto the builder which override default values for any other module parameters. Finally the .build() method actually creates the module.

An alternative to the builder pattern might be to use structs of arguments. In Rust a struct literal is written with named fields, and if the struct implements the Default trait then it’s quite ergonomic to omit fields and use their default values. Had I gone down this route, the adsr_linear_01 module could be created like:

AdsrLinear01Args {
    gate,
    attack_s: 0.01,
    release_s: 0.15,
    ..Default::default(),
}.build()

I opted to not use this approach because it felt was less ergonomic. Code formatters would tend to split definitions over multiple lines rather than putting definitions all on a single line as with (at least short) chains of methods when using the builder pattern. It also requires that the struct implements Default which might not make sense. The builder pattern allows modules to have both mandatory and optional fields.

Another big difference is that in Llama, when passing a constant value to a function which expects a signal, the const function must be used to produce a signal which always yields a given value. This is not necessary in CAW. To understand, let’s take a look at the types of the periodic_gate/periodic_gate_hz functions.

In Llama its type is:

(* The ['a t] type constructor represents signals yielding values of type 'a. *)
val periodic_gate : frequency_hz:float t -> duty_01:float t -> Gate.t

In CAW its type is:

fn periodic_gate_s(freq_s: impl Into<Signal<f64>>) -> PeriodicGateBuilder

CAW uses the Into trait to allow functions to take values of any type which can be converted into signals. The f64 type implements this trait, converting scalar values to constant-valued signals. Similarly in Rust the arithmetic operators can be overloaded to allow signals to be multiplied by other signals or by scalars as in osc_filtered * env and env.clone() * 20_000.0. In Llama I needed to define a new operator *.. for multiplying signals with other signals, and the helper function scale multiplies a signal with a scalar.

Rust lacks a pipeline operator like OCaml’s “|>”, however code that would be written as pipelines in OCaml translate naturally into chains of method. I wanted to avoid making every module a method of Signal<T> so I introduced the concept of a Filter, which is a trait defined like:

trait Filter {
    type Input;
    type Output;

    fn run(&self, input: Self::Input, ctx: &SignalCtx) -> Self::Output;
}

The Input and Output types are usually Signal<f64>. This trait is a little too generic in hindsight, and is refined in more recent versions of CAW.

The filter method in the example above takes an implementation of Filter and applies it to self. It’s common to chain successive calls of filter to apply a sequence of filters to a signal.

Since CAW is written in Rust we have to think a bit about memory. Note how the CAW example calls the .clone() method on the envelope. Here’s the relevant parts of the code again:

...
let env = ar_linear ~gate ~attack_s:(const 0.01) ~release_s:(const 0.15) in
let osc_filtered =
  osc |> butterworth_low_pass_filter ~cutoff_hz:(env |> scale 20000.0)
in
let output = osc_filtered *.. env in
...
...
let env = adsr_linear_01(&gate).attack_s(0.01).release_s(0.15).build();
let osc_filtered =
    osc.filter(low_pass_butterworth(env.clone() * 20_000.0).build());
let output = osc_filtered * env;
...

If the Rust example above were changed to do env * 20_000.0 instead of cloning then the env variable would be consumed by that multiplication, and wouldn’t be available the next time it’s needed by the osc_filtered * env on the following line.

This highlights a design consideration when building Llama as well as CAW, which is how to handle the situation where the output of one module is used as the input to multiple other modules. In the analog world, depending what type of cables you use you might be able to plug multiple cables into each other to easily split or join a signal.

An analog synthesizer module with a stack of multiple cable terminators plugged into the same jack socket.

Llama and CAW both evaluate modules “top-down”. When evaluating an operation like osc_filtered * env during each audio sample, first a sample will be produced from osc_filtered, then a sample will be produced from env, then those samples will be multiplied. Evaluating osc_filtered means computing the output of the low-pass filter, which in turn needs to compute the output of the oscillator and so on. Top-down evaluation can be thought of as the sample values being pulled out of modules which can then pull values out of other modules and so on. This is distinct from a bottom-up evaluation where values are pushed from the most simple modules in the signal graph through more complex values.

The envelope generator env is evaluated twice in the example. We really want both evaluations to result in the same value each sample, and we don’t want to have to repeat the work of recomputing its value if it’s already been computed once during the current sample. For this reason, each signal internally includes a cache of the sample value computed for that signal during the current sample. In CAW when you .clone() a signal you get a shallow copy that shares its cache with any other copies.

Computing the value yielded by a signal might modify the signal’s internal state. Since signals are shared when they are cloned, this means we need a shared mutable value. In Rust this requires wrapping signals in something like Rc<RefCell<...>> and calling .borrow_mut() before mutating. This is all handled internally by the Signal<T> type, so it doesn’t leak into synthesizer code. Still doing this felt a little ugly and removed the ability of the compiler to optimize in some cases but it seemed essential for ergonomics. Eventually I found a way to have my cake and eat it too, which will be covered in part 2.

That’s all for part 1. It didn’t take long to reach feature parity with Llama since all the complex logic was already implemented. I went a bit further, adding browser support and a couple of other modules such as reverb, a bit crusher, and synthesized drums.

CAW is easy to include in other Rust projects. Here are some of my projects that use CAW:

  • Generative Music Experiment, plays random notes while maintaining a musical context so the music feels like it’s in a particular key and mode.
  • Electric Organ is a roguelike I made for a game jam where the music is procedurally generated along with game content.